From b417a100f9a4c18f34ce1ef2fbe1ce7142bf21b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:05:25 +0100 Subject: [PATCH 001/806] test: clarify daemon cli json actions --- src/cli/daemon-cli.coverage.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 968113ace74..9c2bf093c88 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -325,7 +325,8 @@ describe("daemon-cli coverage", () => { expect(serviceStop).toHaveBeenCalledTimes(1); const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); - expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + expect(parsed.filter((entry) => entry.ok).map((entry) => entry.action)).toEqual( + expect.arrayContaining(["start", "stop"]), + ); }); }); From 695d4ccd1b3d039553667663212cecbf504ec725 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:07:11 +0100 Subject: [PATCH 002/806] test: clarify gateway tools catalog server assertions --- src/gateway/server.tools-catalog.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/gateway/server.tools-catalog.test.ts b/src/gateway/server.tools-catalog.test.ts index edce17ae455..26ecd13716c 100644 --- a/src/gateway/server.tools-catalog.test.ts +++ b/src/gateway/server.tools-catalog.test.ts @@ -21,8 +21,8 @@ describe("gateway tools.catalog", () => { expect(res.payload?.agentId).toEqual(expect.any(String)); expect(res.payload?.agentId).not.toBe(""); const mediaGroup = res.payload?.groups?.find((group) => group.id === "media"); - expect(mediaGroup?.tools?.some((tool) => tool.id === "tts" && tool.source === "core")).toBe( - true, + expect(mediaGroup?.tools?.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain( + "core:tts", ); }); }); @@ -35,9 +35,9 @@ describe("gateway tools.catalog", () => { groups?: Array<{ source?: "core" | "plugin" }>; }>(ws, "tools.catalog", { includePlugins: false }); expect(noPlugins.ok).toBe(true); - expect((noPlugins.payload?.groups ?? []).every((group) => group.source !== "plugin")).toBe( - true, - ); + expect( + (noPlugins.payload?.groups ?? []).filter((group) => group.source === "plugin"), + ).toEqual([]); const unknownAgent = await rpcReq(ws, "tools.catalog", { agentId: "does-not-exist" }); expect(unknownAgent.ok).toBe(false); From d1a482ba0bc92dab9081c5ec0732f0bbb289c11e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:09:03 +0100 Subject: [PATCH 003/806] test: clarify qqbot stt guarded fetch --- extensions/qqbot/src/engine/utils/stt.test.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 5e179c69203..66f7459d70c 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -4,8 +4,15 @@ import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveSTTConfig, transcribeAudio } from "./stt.js"; +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + describe("engine/utils/stt", () => { afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); vi.unstubAllGlobals(); }); @@ -72,12 +79,13 @@ describe("engine/utils/stt", () => { const audioPath = path.join(tmpDir, "voice.wav"); fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); - const fetchMock = vi.fn(async () => - Response.json({ + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: Response.json({ text: "hello from audio", }), - ); - vi.stubGlobal("fetch", fetchMock); + release, + }); const transcript = await transcribeAudio(audioPath, { channels: { @@ -92,8 +100,28 @@ describe("engine/utils/stt", () => { }); expect(transcript).toBe("hello from audio"); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.example.test/v1/audio/transcriptions", + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + auditContext: "qqbot-stt", + url: "https://api.example.test/v1/audio/transcriptions", + init: expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer secret" }, + body: expect.any(FormData), + }), + }), + ); + expect(release).toHaveBeenCalledTimes(1); + const [{ init }] = fetchWithSsrFGuardMock.mock.calls[0] as [ + { + init: { + body: FormData; + headers: Record; + method: string; + }; + }, + ]; + expect(init).toEqual( expect.objectContaining({ method: "POST", headers: { Authorization: "Bearer secret" }, From b78295b4dd0e9001ed8fd409c63d02113fadecc1 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Thu, 7 May 2026 23:10:21 -1000 Subject: [PATCH 004/806] fix(ui): hide sender metadata in control chat (#78790) Summary: - Strip untrusted sender metadata from Control UI live stream and transcript rendering. - Preserve canvas preview anchors while suppressing metadata-only render items. - Stop operator UI clients from injecting internal client IDs as sender identity while preserving external channel attribution. Verification: - pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/ui/chat/build-chat-items.ts ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/chat/message-normalizer.ts ui/src/ui/chat/message-normalizer.test.ts src/gateway/server-methods/chat.ts src/gateway/server-methods/chat.directive-tags.test.ts - pnpm check:changelog-attributions - git diff --check - pnpm test ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/chat/message-normalizer.test.ts -- --reporter=verbose - pnpm test src/gateway/server-methods/chat.directive-tags.test.ts -- --reporter=verbose -t 'operator UI client sender context' - GitHub PR checks green on a67ab34fbeeb7ba60a6dc72d3bcff4fd53442669 Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev. --- CHANGELOG.md | 1 + .../chat.directive-tags.test.ts | 30 +++++++ src/gateway/server-methods/chat.ts | 11 ++- ui/src/ui/chat/build-chat-items.test.ts | 86 +++++++++++++++++++ ui/src/ui/chat/build-chat-items.ts | 60 ++++++++----- ui/src/ui/chat/message-normalizer.test.ts | 21 +++++ ui/src/ui/chat/message-normalizer.ts | 25 ++++-- 7 files changed, 202 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f96341eb0..ecd99ab70a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai - Slack/performance: reduce message preparation, stream recipient lookup, and thread-context allocation overhead on Slack reply hot paths. Thanks @vincentkoc. - Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines. - Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev. +- Control UI/chat: strip untrusted sender metadata from live streams and transcript display, preserve canvas preview anchors, and stop operator UI clients from injecting their internal client id as sender identity. Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev. - Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context. - Control UI/chat and Sessions: label inherited thinking defaults separately from explicit overrides while preserving provider-supplied option labels. Fixes #77581. Thanks @BunsDev and @Beandon13. - Agents/runtime: add prepared runtime foundation contracts for carrying provider, model, tool, TTS, and outbound runtime facts through later reply-path migrations. Thanks @mcaxtr. diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 7b6847ef019..bb54099cc85 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -3433,3 +3433,33 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); }); }); + +describe("chat.send operator UI client sender context", () => { + it("does not inject sender identity fields for Control UI clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-control-ui-sender", + message: "hello from control ui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + version: "dev", + platform: "web", + }, + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.SenderId).toBeUndefined(); + expect(mockState.lastDispatchCtx?.SenderName).toBeUndefined(); + expect(mockState.lastDispatchCtx?.SenderUsername).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index f38e7e8da33..8ff12ec5452 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -51,6 +51,7 @@ import { import { INTERNAL_MESSAGE_CHANNEL, isGatewayCliClient, + isOperatorUiClient, isWebchatClient, normalizeMessageChannel, } from "../../utils/message-channel.js"; @@ -2266,9 +2267,13 @@ export const chatHandlers: GatewayRequestHandlers = { ...(commandSource ? { CommandSource: commandSource } : {}), CommandAuthorized: true, MessageSid: clientRunId, - SenderId: clientInfo?.id, - SenderName: clientInfo?.displayName, - SenderUsername: clientInfo?.displayName, + ...(!isOperatorUiClient(clientInfo) + ? { + SenderId: clientInfo?.id, + SenderName: clientInfo?.displayName, + SenderUsername: clientInfo?.displayName, + } + : {}), GatewayClientScopes: client?.connect?.scopes ?? [], ...pluginBoundMediaFields, }; diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index 6e818f76bef..956e2890e6f 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import type { MessageGroup } from "../types/chat-types.ts"; import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts"; +const SENDER_METADATA_BLOCK = + 'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```'; + function createProps(overrides: Partial = {}): BuildChatItemsProps { return { sessionKey: "main", @@ -151,6 +154,50 @@ describe("buildChatItems", () => { expect(items).toEqual([]); }); + it("suppresses active sender metadata streams before rendering", () => { + const items = buildChatItems( + createProps({ + stream: SENDER_METADATA_BLOCK, + streamStartedAt: 1, + }), + ); + + expect(items).toEqual([]); + }); + + it("strips sender metadata from active stream text that has visible content", () => { + const items = buildChatItems( + createProps({ + stream: `${SENDER_METADATA_BLOCK}\n\nVisible reply`, + streamStartedAt: 1, + }), + ); + + expect(items).toEqual([ + { + kind: "stream", + key: "stream:main:1", + text: "Visible reply", + startedAt: 1, + }, + ]); + }); + + it("suppresses metadata-only history messages before grouping", () => { + const groups = messageGroups({ + messages: [ + { + role: "user", + content: SENDER_METADATA_BLOCK, + senderLabel: "openclaw-control-ui", + timestamp: 1, + }, + ], + }); + + expect(groups).toEqual([]); + }); + it("renders only the last 100 history messages and shows a hidden-count notice", () => { const items = buildChatItems( createProps({ @@ -264,6 +311,45 @@ describe("buildChatItems", () => { expect(canvasBlocksIn(groups[1])).toEqual([]); }); + it("preserves a metadata-only assistant anchor when lifting canvas previews", () => { + const groups = messageGroups({ + messages: [ + { + id: "assistant-metadata-anchor", + role: "assistant", + content: SENDER_METADATA_BLOCK, + timestamp: 1_000, + }, + ], + toolMessages: [ + { + id: "tool-canvas-for-empty-anchor", + role: "tool", + toolCallId: "call-canvas-empty-anchor", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: "cv_empty_anchor", + url: "/__openclaw__/canvas/documents/cv_empty_anchor/index.html", + title: "Empty anchor demo", + preferred_height: 320, + }, + presentation: { + target: "assistant_message", + }, + }), + timestamp: 1_001, + }, + ], + }); + + expect( + groups.some((group) => firstMessageContent(group).some((block) => isCanvasBlock(block))), + ).toBe(true); + }); + it("does not lift generic view handles from non-canvas payloads", () => { const groups = messageGroups({ messages: [ diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index 3720b7ba946..c05c24632b6 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -5,7 +5,7 @@ import { } from "./heartbeat-display.ts"; import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; import { extractTextCached } from "./message-extract.ts"; -import { normalizeMessage } from "./message-normalizer.ts"; +import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts"; import { normalizeRoleForGrouping } from "./role-normalizer.ts"; import { messageMatchesSearchQuery } from "./search-match.ts"; import { extractToolCards, extractToolPreview } from "./tool-cards.ts"; @@ -249,12 +249,29 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] { return collapsed; } +function hasRenderableNormalizedMessage(message: unknown): boolean { + const normalized = normalizeMessage(message); + return normalized.content.length > 0 || Boolean(normalized.replyTarget); +} + +function sanitizeStreamText(text: string): string { + const stripped = stripMessageDisplayMetadataText(text); + return stripped.trim().length > 0 ? stripped : ""; +} + export function buildChatItems(props: BuildChatItemsProps): Array { - const items: ChatItem[] = []; + let items: ChatItem[] = []; const history = (Array.isArray(props.messages) ? props.messages : []).filter( (message) => !isAssistantHeartbeatAckForDisplay(message), ); const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; + const liftedCanvasSources = tools + .map((tool) => extractChatMessagePreview(tool)) + .filter((entry) => Boolean(entry)) as Array<{ + preview: Extract, { kind: "canvas" }>; + text: string | null; + timestamp: number | null; + }>; const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT); if (historyStart > 0) { items.push({ @@ -299,6 +316,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array extractChatMessagePreview(tool)) - .filter((entry) => Boolean(entry)) as Array<{ - preview: Extract, { kind: "canvas" }>; - text: string | null; - timestamp: number | null; - }>; for (const liftedCanvasSource of liftedCanvasSources) { const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp); if (assistantIndex == null) { @@ -331,16 +344,22 @@ export function buildChatItems(props: BuildChatItemsProps): Array item.kind !== "message" || hasRenderableNormalizedMessage(item.message), + ); const segments = props.streamSegments ?? []; const maxLen = Math.max(segments.length, tools.length); for (let i = 0; i < maxLen; i++) { - if (i < segments.length && segments[i].text.trim().length > 0) { - items.push({ - kind: "stream", - key: `stream-seg:${props.sessionKey}:${i}`, - text: segments[i].text, - startedAt: segments[i].ts, - }); + if (i < segments.length) { + const text = sanitizeStreamText(segments[i].text); + if (text.length > 0) { + items.push({ + kind: "stream", + key: `stream-seg:${props.sessionKey}:${i}`, + text, + startedAt: segments[i].ts, + }); + } } if (i < tools.length && props.showToolCalls) { items.push({ @@ -353,16 +372,17 @@ export function buildChatItems(props: BuildChatItemsProps): Array 0) { - if (!stripHeartbeatTokenForDisplay(props.stream).shouldSkip) { + const text = sanitizeStreamText(props.stream); + if (text.length > 0) { + if (!stripHeartbeatTokenForDisplay(text).shouldSkip) { items.push({ kind: "stream", key, - text: props.stream, + text, startedAt: props.streamStartedAt ?? Date.now(), }); } - } else { + } else if (props.stream.trim().length === 0) { items.push({ kind: "reading-indicator", key }); } } diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index d41bbe1a0ba..efa7625e957 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { normalizeMessage } from "./message-normalizer.ts"; +const SENDER_METADATA_BLOCK = + 'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```'; + describe("message-normalizer", () => { describe("normalizeMessage", () => { beforeEach(() => { @@ -29,6 +32,24 @@ describe("message-normalizer", () => { }); }); + it("strips sender metadata blocks before displaying message text", () => { + const result = normalizeMessage({ + role: "assistant", + content: `${SENDER_METADATA_BLOCK}\n\nVisible reply`, + }); + + expect(result.content).toEqual([{ type: "text", text: "Visible reply" }]); + }); + + it("drops standalone sender metadata blocks before display", () => { + const result = normalizeMessage({ + role: "system", + content: SENDER_METADATA_BLOCK, + }); + + expect(result.content).toEqual([]); + }); + it("does not reinterpret directive-like user string content", () => { const result = normalizeMessage({ role: "user", diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index dc090e97daa..85181147753 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -158,6 +158,21 @@ function mergeAdjacentTextItems(items: MessageContentItem[]): MessageContentItem return merged.filter((item) => item.type !== "text" || Boolean(item.text?.trim())); } +export function stripMessageDisplayMetadataText(text: string): string { + return stripInboundMetadata(text); +} + +function stripMessageDisplayMetadata(items: MessageContentItem[]): MessageContentItem[] { + return items + .map((item) => { + if (item.type !== "text" || typeof item.text !== "string") { + return item; + } + return { ...item, text: stripMessageDisplayMetadataText(item.text) }; + }) + .filter((item) => item.type !== "text" || Boolean(item.text?.trim())); +} + function expandTextContent(text: string): { content: MessageContentItem[]; audioAsVoice: boolean; @@ -370,15 +385,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { const senderLabel = typeof m.senderLabel === "string" && m.senderLabel.trim() ? m.senderLabel.trim() : null; - // Strip AI-injected metadata prefix blocks from user messages before display. - if (role === "user" || role === "User") { - content = content.map((item) => { - if (item.type === "text" && typeof item.text === "string") { - return { ...item, text: stripInboundMetadata(item.text) }; - } - return item; - }); - } + content = stripMessageDisplayMetadata(content); return { role, From 2d1f4f909ed60e3df3ebce866b1e0bf48249db70 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:10:37 +0100 Subject: [PATCH 005/806] fix: normalize retired gemini preview ids --- extensions/google/manifest.test.ts | 1 + extensions/google/openclaw.plugin.json | 3 +++ src/plugin-activation-boundary.test.ts | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index 663ad4edaa7..33764c0a5e2 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -98,6 +98,7 @@ describe("google manifest model catalog", () => { for (const provider of GOOGLE_CHAT_PROVIDERS) { expect(manifest.modelIdNormalization?.providers?.[provider]?.aliases).toMatchObject({ "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", }); } }); diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 24111760b7c..8369be29d40 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -11,6 +11,7 @@ "google": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", @@ -21,6 +22,7 @@ "google-gemini-cli": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", @@ -31,6 +33,7 @@ "google-vertex": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 851d3168777..0fd22d52b9f 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -39,6 +39,7 @@ const loadPluginManifestRegistryForPluginRegistry = vi.hoisted(() => google: { aliases: { "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", }, }, xai: { @@ -139,6 +140,10 @@ describe("plugin activation boundary", () => { provider: "google", model: "gemini-3.1-pro-preview", }); + expect(normalizeModelRef("google", "gemini-3-pro-preview", staticNormalize)).toEqual({ + provider: "google", + model: "gemini-3.1-pro-preview", + }); expect(normalizeModelRef("xai", "grok-4-fast-reasoning", staticNormalize)).toEqual({ provider: "xai", model: "grok-4-fast", From f6a54056588b5e97b5ae8ad368a184cfeed773be Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 8 May 2026 04:11:28 -0500 Subject: [PATCH 006/806] fix(macos): guard config writer fallback Guard macOS config writes so stale or destructive fallback payloads cannot silently remove gateway.mode, metadata, or auth and trigger gateway restore churn. Verification: - swift test --package-path apps/macos --filter OpenClawConfigFileTests - swift test --package-path apps/macos --filter AppStateRemoteConfigTests - swift test --package-path apps/macos --filter ConfigStoreTests - pnpm lint:swift - git diff --check origin/main..HEAD - Blacksmith Testbox pnpm check:changed: blocked by missing swiftlint in the Linux Testbox image after reaching apps lane --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/AppState.swift | 7 +- apps/macos/Sources/OpenClaw/ConfigStore.swift | 34 +++++- .../Sources/OpenClaw/DebugSettings.swift | 5 +- .../Sources/OpenClaw/OpenClawConfigFile.swift | 77 +++++++++++- .../AppStateRemoteConfigTests.swift | 33 +++++ .../OpenClawIPCTests/ConfigStoreTests.swift | 73 +++++++++++ .../OpenClawConfigFileTests.swift | 114 ++++++++++++++++++ 8 files changed, 335 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd99ab70a9..72e7660671c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. +- macOS/config: reject stale or destructive app fallback config writes before direct replacement and keep rejected payloads as private audit artifacts, so `gateway.mode`, metadata, and auth are not silently clobbered. Fixes #64973 and #74890. Thanks @BunsDev. - Doctor/OpenAI: stop pinning migrated `openai-codex/*` routes to the Codex runtime so mixed-provider agents keep automatic PI routing for MiniMax, Anthropic, and other non-OpenAI model switches. - Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. - Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 7c6ab0e8acd..6ab59ae1a15 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -8,6 +8,8 @@ import SwiftUI @MainActor @Observable final class AppState { + private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state") + private let isPreview: Bool private var isInitializing = true private var isApplyingRemoteTokenConfig = false @@ -696,7 +698,10 @@ final class AppState { remoteToken: self.remoteToken, remoteTokenDirty: self.remoteTokenDirty)) guard synced.changed else { return } - OpenClawConfigFile.saveDict(synced.root) + guard OpenClawConfigFile.saveDict(synced.root) else { + Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode") + return + } } func triggerVoiceEars(ttl: TimeInterval? = 5) { diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index ecc96205067..b16fef293e6 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -8,6 +8,7 @@ enum ConfigStore { var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)? var loadRemote: (@MainActor @Sendable () async -> [String: Any])? var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)? + var saveGateway: (@MainActor @Sendable ([String: Any]) async throws -> Void)? } private actor OverrideStore { @@ -66,10 +67,19 @@ enum ConfigStore { do { try await self.saveToGateway(root) } catch { - OpenClawConfigFile.saveDict( + guard self.shouldFallbackToLocalWrite(afterGatewaySaveError: error) else { + self.lastHash = nil + throw error + } + guard OpenClawConfigFile.saveDict( root, preserveExistingKeys: true, allowGatewayAuthMutation: allowGatewayAuthMutation) + else { + throw NSError(domain: "ConfigStore", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Local config write rejected to protect gateway auth/mode.", + ]) + } } } } @@ -89,8 +99,30 @@ enum ConfigStore { } } + private static func shouldFallbackToLocalWrite(afterGatewaySaveError error: Error) -> Bool { + let nsError = error as NSError + let message = "\(nsError.domain) \(nsError.localizedDescription)".lowercased() + let blockedFragments = [ + "invalid_request", + "invalid request", + "invalid config", + "config changed since last load", + "base hash", + "basehash", + "unauthorized", + "token mismatch", + "auth", + ] + return !blockedFragments.contains { message.contains($0) } + } + @MainActor private static func saveToGateway(_ root: [String: Any]) async throws { + let overrides = await self.overrideStore.overrides + if let saveGateway = overrides.saveGateway { + try await saveGateway(root) + return + } if self.lastHash == nil { _ = await self.loadFromGateway() } diff --git a/apps/macos/Sources/OpenClaw/DebugSettings.swift b/apps/macos/Sources/OpenClaw/DebugSettings.swift index 97ce692696c..11be1c4b1e7 100644 --- a/apps/macos/Sources/OpenClaw/DebugSettings.swift +++ b/apps/macos/Sources/OpenClaw/DebugSettings.swift @@ -779,7 +779,10 @@ struct DebugSettings: View { session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed root["session"] = session - OpenClawConfigFile.saveDict(root) + guard OpenClawConfigFile.saveDict(root) else { + self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode." + return + } self.sessionStoreSaveError = nil } diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 7b80a34bff7..bd3e321f780 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -52,14 +52,16 @@ enum OpenClawConfigFile { } } + @discardableResult static func saveDict( _ dict: [String: Any], preserveExistingKeys: Bool = false, allowGatewayAuthMutation: Bool = false) + -> Bool { self.withFileLock { // Nix mode disables config writes in production, but tests rely on saving temp configs. - if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return false } let url = self.url() let previousData = try? Data(contentsOf: url) let previousRoot = previousData.flatMap { self.parseConfigData($0) } @@ -81,12 +83,7 @@ enum OpenClawConfigFile { do { let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) let nextBytes = data.count - let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) let gatewayModeAfter = self.gatewayMode(output) var suspicious = self.configWriteSuspiciousReasons( existsBefore: previousData != nil, @@ -98,6 +95,44 @@ enum OpenClawConfigFile { if preservedGatewayAuth { suspicious.append("gateway-auth-preserved") } + let blocking = self.configWriteBlockingReasons(suspicious) + if !blocking.isEmpty { + let rejectedPath = self.persistRejectedConfigWrite(data: data, configURL: url) + self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)") + self.appendConfigWriteAudit([ + "result": "rejected", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": nextBytes, + "previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(), + "nextDev": NSNull(), + "previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(), + "nextIno": NSNull(), + "previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(), + "nextMode": NSNull(), + "previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(), + "nextNlink": NSNull(), + "previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(), + "nextUid": NSNull(), + "previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(), + "nextGid": NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, + "suspicious": suspicious, + "blocking": blocking, + "rejectedPath": rejectedPath ?? NSNull(), + ]) + return false + } + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) if !suspicious.isEmpty { self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") } @@ -123,9 +158,11 @@ enum OpenClawConfigFile { "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, "suspicious": suspicious, ]) self.observeConfigRead(data: data, root: output, configURL: url, valid: true) + return true } catch { self.logger.error("config save failed: \(error.localizedDescription)") self.appendConfigWriteAudit([ @@ -138,9 +175,11 @@ enum OpenClawConfigFile { "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, "suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [], "error": error.localizedDescription, ]) + return false } } } @@ -416,6 +455,12 @@ enum OpenClawConfigFile { return reasons } + private static func configWriteBlockingReasons(_ suspicious: [String]) -> [String] { + suspicious.filter { reason in + reason.hasPrefix("size-drop:") || reason == "gateway-mode-removed" + } + } + private static func configAuditLogURL() -> URL { self.stateDirURL() .appendingPathComponent("logs", isDirectory: true) @@ -594,6 +639,26 @@ enum OpenClawConfigFile { } } + private static func persistRejectedConfigWrite(data: Data, configURL: URL) -> String? { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let url = configURL.deletingLastPathComponent() + .appendingPathComponent("\(configURL.lastPathComponent).rejected.\(self.configTimestampToken(timestamp))") + let fileManager = FileManager() + let privatePermissions: NSNumber = 0o600 + if fileManager.fileExists(atPath: url.path) { + try? fileManager.setAttributes([.posixPermissions: privatePermissions], ofItemAtPath: url.path) + return url.path + } + guard fileManager.createFile( + atPath: url.path, + contents: data, + attributes: [.posixPermissions: privatePermissions]) + else { + return nil + } + return url.path + } + private static func observeConfigRead(data: Data, root: [String: Any]?, configURL: URL, valid: Bool) { let observedAt = ISO8601DateFormatter().string(from: Date()) let current = self.configFingerprint(data: data, root: root, configURL: configURL, observedAt: observedAt) diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index d96f3871809..589f383255f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -259,4 +259,37 @@ struct AppStateRemoteConfigTests { remoteTokenDirty: true)) #expect((cleared["token"] as? String) == nil) } + + @Test + func `synced gateway root preserves gateway auth across mode changes`() { + let initialRoot: [String: Any] = [ + "gateway": [ + "mode": "remote", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + "remote": [ + "transport": "direct", + "url": "wss://old-gateway.example", + ], + ], + ] + + let localRoot = AppState._testSyncedGatewayRoot( + currentRoot: initialRoot, + draft: .init( + connectionMode: .local, + remoteTransport: .ssh, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false)) + let localGateway = localRoot["gateway"] as? [String: Any] + let auth = localGateway?["auth"] as? [String: Any] + #expect(localGateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "token") + #expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift index b3ad56d71a1..586c06c4217 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import OpenClaw @@ -65,4 +66,76 @@ struct ConfigStoreTests { #expect(localHit) #expect(!remoteHit) } + + @Test func `local save does not fall back to direct write after stale gateway rejection`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + ]) + let before = try String(contentsOf: configPath, encoding: .utf8) + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + saveGateway: { _ in + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "config changed since last load; re-run config.get and retry", + ]) + })) + + var didThrow = false + do { + try await ConfigStore.save(["browser": ["enabled": false]]) + } catch { + didThrow = true + } + await ConfigStore._testClearOverrides() + + #expect(didThrow) + let after = try String(contentsOf: configPath, encoding: .utf8) + #expect(after == before) + } + } + + @Test func `local save can fall back to protected direct write when gateway is unavailable`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + saveGateway: { _ in + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "gateway not configured", + ]) + })) + try await ConfigStore.save([ + "gateway": ["mode": "local"], + "browser": ["enabled": false], + ]) + await ConfigStore._testClearOverrides() + + let data = try Data(contentsOf: configPath) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(((root?["browser"] as? [String: Any])?["enabled"] as? Bool) == false) + #expect((root?["meta"] as? [String: Any]) != nil) + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 018626be884..1b384b37954 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -336,4 +336,118 @@ struct OpenClawConfigFileTests { } } } + + @MainActor + @Test + func `save dict records preserved gateway auth in audit`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + ]) + + let saved = OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + ], + "browser": [ + "enabled": false, + ], + ]) + + #expect(saved) + let data = try Data(contentsOf: configPath) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let gateway = root?["gateway"] as? [String: Any] + let auth = gateway?["auth"] as? [String: Any] + #expect(gateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "token") + #expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret + #expect((root?["meta"] as? [String: Any]) != nil) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let last = rawAudit.split(whereSeparator: \.isNewline).map(String.init).last + let auditRoot = try JSONSerialization.jsonObject(with: Data((last ?? "{}").utf8)) as? [String: Any] + #expect(auditRoot?["result"] as? String == "success") + #expect(auditRoot?["preservedGatewayAuth"] as? Bool == true) + let suspicious = auditRoot?["suspicious"] as? [String] ?? [] + #expect(suspicious.contains("gateway-auth-preserved")) + } + } + + @MainActor + @Test + func `save dict rejects gateway mode removal and keeps previous config`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + "browser": [ + "enabled": true, + ], + ]) + let before = try String(contentsOf: configPath, encoding: .utf8) + + let saved = OpenClawConfigFile.saveDict([ + "browser": [ + "enabled": false, + ], + ]) + + #expect(!saved) + let after = try String(contentsOf: configPath, encoding: .utf8) + #expect(after == before) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let lines = rawAudit.split(whereSeparator: \.isNewline).map(String.init) + guard let last = lines.last else { + Issue.record("Missing rejected config audit line") + return + } + let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any] + #expect(auditRoot?["result"] as? String == "rejected") + let suspicious = auditRoot?["suspicious"] as? [String] ?? [] + let blocking = auditRoot?["blocking"] as? [String] ?? [] + #expect(suspicious.contains("gateway-mode-removed")) + #expect(blocking.contains("gateway-mode-removed")) + if let rejectedPath = auditRoot?["rejectedPath"] as? String { + #expect(FileManager().fileExists(atPath: rejectedPath)) + let attributes = try FileManager().attributesOfItem(atPath: rejectedPath) + let mode = attributes[.posixPermissions] as? NSNumber + #expect(mode?.intValue == 0o600) + } else { + Issue.record("Missing rejected payload path") + } + } + } } From 49e307a64d8f0635ab2388f6468bd02aeb67c927 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:11:47 +0100 Subject: [PATCH 007/806] test: clarify mantis staged video assertion --- extensions/qa-lab/src/mantis/visual-task.runtime.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts index feceed1e25d..81aac13e491 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts @@ -102,7 +102,8 @@ describe("mantis visual task runtime", () => { ]), ); expect(stagedVideoPath).not.toBe(finalVideoPath); - expect(path.basename(stagedVideoPath ?? "")).toBe(path.basename(finalVideoPath)); + expect(path.basename(stagedVideoPath ?? "")).toContain(path.basename(finalVideoPath)); + expect(path.basename(stagedVideoPath ?? "")).toMatch(/\.part$/); await expect(fs.stat(stagedVideoPath ?? "")).rejects.toThrow(); await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png"); await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); From f91da88ed76e5d6b98116b35fa2266f396e4eeea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:11:48 +0100 Subject: [PATCH 008/806] test: clarify gateway pricing timer assertion --- src/gateway/model-pricing-cache.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 8f5de853ddd..14b31269d5c 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -914,7 +914,8 @@ describe("model-pricing-cache", () => { await vi.waitFor(() => expect(abortedUrls).toHaveLength(2)); await vi.dynamicImportSettled(); - expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 24 * 60 * 60_000)).toBe(false); + const scheduledDelays = setTimeoutSpy.mock.calls.map(([, delay]) => delay); + expect(scheduledDelays).not.toContain(24 * 60 * 60_000); expect( getCachedGatewayModelPricing({ provider: "anthropic", model: "claude-opus-4-6" }), ).toBeUndefined(); From 5b002b0428fc1c565d02cda24d2a095325107f7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:12:45 +0100 Subject: [PATCH 009/806] test: clarify agent skill assertions --- src/agents/skills-install.test.ts | 8 +++++--- src/agents/skills/refresh.test.ts | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agents/skills-install.test.ts b/src/agents/skills-install.test.ts index dda90668539..32832fa2caf 100644 --- a/src/agents/skills-install.test.ts +++ b/src/agents/skills-install.test.ts @@ -163,10 +163,12 @@ describe("installSkill code safety scanning", () => { expect(result.ok).toBe(false); expect(result.message).toContain('Skill "danger-skill" installation blocked'); - expect(result.warnings?.some((warning) => warning.includes("dangerous code patterns"))).toBe( - true, + expect(result.warnings ?? []).toEqual( + expect.arrayContaining([ + expect.stringContaining("dangerous code patterns"), + expect.stringContaining("runner.js:1"), + ]), ); - expect(result.warnings?.some((warning) => warning.includes("runner.js:1"))).toBe(true); expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); }); }); diff --git a/src/agents/skills/refresh.test.ts b/src/agents/skills/refresh.test.ts index 0feabdfbf74..8669706e547 100644 --- a/src/agents/skills/refresh.test.ts +++ b/src/agents/skills/refresh.test.ts @@ -75,7 +75,8 @@ describe("ensureSkillsWatcher", () => { posix(path.join(os.homedir(), ".agents", "skills")), ]), ); - expect(targets.every((target) => !target.includes("*"))).toBe(true); + const wildcardTargets = targets.filter((target) => target.includes("*")); + expect(wildcardTargets).toEqual([]); const ignored = refreshModule.shouldIgnoreSkillsWatchPath; // Node/JS paths From aa6160c1db3b419c5d26d964ac67017e69d531c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:14:08 +0100 Subject: [PATCH 010/806] test: clarify secrets cli skipped notes --- src/cli/secrets-cli.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts index a6e8d7cf6d3..25db25d1dd8 100644 --- a/src/cli/secrets-cli.test.ts +++ b/src/cli/secrets-cli.test.ts @@ -328,9 +328,10 @@ describe("secrets CLI", () => { await createProgram().parseAsync(["secrets", "apply", "--from", planPath, "--dry-run"], { from: "user", }); - expect(runtimeLogs.some((line) => line.includes("Secrets apply dry-run note: skipped"))).toBe( - false, + const skippedExecNotes = runtimeLogs.filter((line) => + line.includes("Secrets apply dry-run note: skipped"), ); + expect(skippedExecNotes).toEqual([]); }); }); @@ -341,7 +342,10 @@ describe("secrets CLI", () => { confirm.mockResolvedValue(false); await createProgram().parseAsync(["secrets", "configure"], { from: "user" }); - expect(runtimeLogs.some((line) => line.includes("Preflight note: skipped"))).toBe(false); + const preflightSkippedExecNotes = runtimeLogs.filter((line) => + line.includes("Preflight note: skipped"), + ); + expect(preflightSkippedExecNotes).toEqual([]); }); it("forwards --allow-exec to configure preflight and apply", async () => { From 8df998e55e4c2e5afa124e6531b5a06a2e75640b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:15:07 +0100 Subject: [PATCH 011/806] test: dedupe cron cli log assertions --- src/cli/cron-cli/shared.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index 7397f68cf1a..bd90dc43baa 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -26,6 +26,11 @@ function createRuntimeLogCapture(): { logs: string[]; runtime: RuntimeEnv } { return { logs, runtime }; } +function expectLogsToInclude(logs: readonly string[], text: string): void { + const matches = logs.filter((line) => line.includes(text)); + expect(matches.length).toBeGreaterThan(0); +} + function createBaseJob(overrides: Partial): CronJob { const now = Date.now(); return { @@ -63,7 +68,7 @@ describe("printCronList", () => { // Verify output contains the job expect(logs.length).toBeGreaterThan(1); - expect(logs.some((line) => line.includes("test-job-id"))).toBe(true); + expectLogsToInclude(logs, "test-job-id"); }); it("handles job with defined sessionTarget", () => { @@ -75,7 +80,7 @@ describe("printCronList", () => { }); expect(() => printCronList([jobWithTarget], runtime)).not.toThrow(); - expect(logs.some((line) => line.includes("isolated"))).toBe(true); + expectLogsToInclude(logs, "isolated"); }); it("tolerates malformed rows in human-readable output", () => { @@ -91,7 +96,7 @@ describe("printCronList", () => { } as unknown as CronJob; expect(() => printCronList([malformedJob], runtime)).not.toThrow(); - expect(logs.some((line) => line.includes("malformed-job"))).toBe(true); + expectLogsToInclude(logs, "malformed-job"); }); it("shows stagger label for cron schedules", () => { @@ -106,7 +111,7 @@ describe("printCronList", () => { }); printCronList([job], runtime); - expect(logs.some((line) => line.includes("(stagger 5m)"))).toBe(true); + expectLogsToInclude(logs, "(stagger 5m)"); }); it("shows dash for unset agentId instead of default", () => { @@ -224,7 +229,7 @@ describe("printCronList", () => { }); printCronList([job], runtime); - expect(logs.some((line) => line.includes("(exact)"))).toBe(true); + expectLogsToInclude(logs, "(exact)"); }); }); From d7f2c3d3448621896edc3ce8cd2afadb50bba3a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:16:05 +0100 Subject: [PATCH 012/806] test: clarify daemon install warning assertion --- src/cli/daemon-cli/install.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 30e64766434..7bf325cc84c 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -312,7 +312,9 @@ describe("runDaemonInstall", () => { ); expectFirstInstallPlanCallOmitsToken(); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); - expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); + expect(actionState.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("Auto-generated")]), + ); }); it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { From b16bcda63ae5a23db5a8cf96be5c77972de10e62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:17:12 +0100 Subject: [PATCH 013/806] test: clarify gateway command list assertions --- src/gateway/server-methods/commands.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 92074f5b3d5..56cc0482984 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -278,7 +278,8 @@ describe("commands.list handler", () => { for (const scope of ["native", "text", "both"] as const) { const { payload } = callHandler({ scope }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - expect(commands.some((c) => c.source === "plugin")).toBe(true); + const sources = commands.map((command) => command.source); + expect(sources).toContain("plugin"); } }); @@ -470,6 +471,9 @@ describe("buildCommandsListResult", () => { it("is callable independently from handler", () => { const result = buildCommandsListResult({ cfg: {} as never, agentId: "main" }); expect(result.commands.length).toBeGreaterThan(0); - expect(result.commands.every((c) => typeof c.scope === "string")).toBe(true); + const invalidScopes = result.commands + .map((command) => command.scope) + .filter((scope) => typeof scope !== "string"); + expect(invalidScopes).toEqual([]); }); }); From 3b626e4e36feec61681a5492834fec32f5d90dda Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:18:16 +0100 Subject: [PATCH 014/806] test: clarify exec approval broadcast assertion --- src/gateway/server-methods/server-methods.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 59201b6b75c..c72cc9f6988 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -1161,7 +1161,7 @@ describe("exec approval handlers", () => { expect.objectContaining({ id, decision: "allow-once" }), undefined, ); - expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); + expect(broadcasts.map((entry) => entry.event)).toContain("exec.approval.resolved"); }); it("treats duplicate same-decision exec resolves as idempotent during grace", async () => { From bd72cc4aa501b09c8fb5c49540a394ec848ac6d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:19:58 +0100 Subject: [PATCH 015/806] test: clarify gateway stability event assertions --- src/gateway/gateway-stability.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gateway/gateway-stability.test.ts b/src/gateway/gateway-stability.test.ts index e53dc3a3d00..e5fad0c7784 100644 --- a/src/gateway/gateway-stability.test.ts +++ b/src/gateway/gateway-stability.test.ts @@ -138,10 +138,14 @@ describe("gateway stability lane", () => { expect(event).not.toHaveProperty("sessionId"); expect(event).not.toHaveProperty("sessionKey"); } - expect(sessionEvents.some((event) => event.outcome === "idle" && event.queueDepth === 0)).toBe( - true, + const idleDrainedEvents = sessionEvents.filter( + (event) => event.outcome === "idle" && event.queueDepth === 0, ); - expect(sessionEvents.every((event) => event.reason === STABILITY_REASON)).toBe(true); + expect(idleDrainedEvents.length).toBeGreaterThan(0); + const unexpectedReasons = sessionEvents + .map((event) => event.reason) + .filter((reason) => reason !== STABILITY_REASON); + expect(unexpectedReasons).toEqual([]); stopDiagnosticStabilityRecorder(); emitDiagnosticEvent({ From f82d842335b3545c754ad122254b3a020a340049 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:20:19 +0100 Subject: [PATCH 016/806] test: clarify plugin install log assertions --- src/cli/plugins-cli.install.test.ts | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 527322bcdf4..b12ed4a855f 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -475,8 +475,10 @@ describe("plugins cli install", () => { nextConfig: enabledCfg, }), ); - expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); + expect(runtimeLogs).toEqual(expect.arrayContaining([expect.stringContaining("slot adjusted")])); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed plugin: alpha")]), + ); }); it("passes force through as overwrite mode for marketplace installs", async () => { @@ -539,7 +541,9 @@ describe("plugins cli install", () => { }), }); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed plugin: demo")]), + ); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); @@ -618,7 +622,9 @@ describe("plugins cli install", () => { }); expect(enablePluginInConfig).not.toHaveBeenCalled(); expect(applyExclusiveSlotSelection).not.toHaveBeenCalled(); - expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("requires configuration first")]), + ); }); it("enables config-gated bundled installs when provider-backed config is explicit", async () => { @@ -656,7 +662,9 @@ describe("plugins cli install", () => { expect(enablePluginInConfig).toHaveBeenCalled(); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); - expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(false); + expect(runtimeLogs).not.toEqual( + expect.arrayContaining([expect.stringContaining("requires configuration first")]), + ); }); it("passes force through as overwrite mode for ClawHub installs", async () => { @@ -1807,7 +1815,9 @@ describe("plugins cli install", () => { path: localHookDir, }), ); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("still falls back to npm hook pack when dangerous force unsafe install is set for non-security errors", async () => { @@ -1862,7 +1872,9 @@ describe("plugins cli install", () => { spec: "@acme/demo-hooks", }), ); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("does not fall back to npm when explicit ClawHub rejects a real package", async () => { @@ -1899,7 +1911,9 @@ describe("plugins cli install", () => { }), ); expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => { From b0966f535644b1c2807974271afe57fa0f06c0f6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:20:50 +0100 Subject: [PATCH 017/806] test: clarify plugin uninstall log assertions --- src/cli/plugins-cli.uninstall.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 54bd569a420..7353c514b61 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -104,8 +104,12 @@ describe("plugins cli uninstall", () => { expect(planPluginUninstall).toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); - expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Dry run, no changes made.")]), + ); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("context engine slot")]), + ); }); it("uninstalls with --force and --keep-files without prompting", async () => { @@ -515,7 +519,9 @@ describe("plugins cli uninstall", () => { ); expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({}); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect(runtimeLogs.some((line) => line.includes("channel config (channels.alpha)"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("channel config (channels.alpha)")]), + ); expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"'); }); From 16cdf85a0538b97c28e0458b617693d387631f40 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:21:16 +0100 Subject: [PATCH 018/806] test: clarify plugin install persist warning assertion --- src/cli/plugins-install-persist.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 6c7ce2d54bf..c3147b1c840 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -388,7 +388,9 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); expect(refreshPluginRegistry).toHaveBeenCalled(); expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1); - expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Plugin registry refresh failed")]), + ); }); it("removes stale denylist entries before enabling installed plugins", async () => { From 2c0dac5851ac88d74f8436267c0d87ffe439bedb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:21:43 +0100 Subject: [PATCH 019/806] test: dedupe trajectory export event assertions --- src/trajectory/export.test.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index a6358f048c7..8132ffef77c 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -63,6 +63,10 @@ function toolResultMessage(content: Extract["co }; } +function eventTypes(events: readonly Pick[]): string[] { + return events.map((event) => event.type); +} + function writeSimpleSessionFile( sessionFile: string, params: { userEntryTimestamp?: string | number } = {}, @@ -332,7 +336,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.manifest.runtimeEventCount).toBe(1); - expect(bundle.events.some((event) => event.type === "session.started")).toBe(true); + expect(eventTypes(bundle.events)).toContain("session.started"); }); it("uses the recorded runtime pointer before current environment overrides", async () => { @@ -395,8 +399,8 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBe(recordedRuntimeFile); - expect(bundle.events.some((event) => event.type === "recorded-runtime")).toBe(true); - expect(bundle.events.some((event) => event.type === "env-runtime")).toBe(false); + expect(eventTypes(bundle.events)).toContain("recorded-runtime"); + expect(eventTypes(bundle.events)).not.toContain("env-runtime"); } finally { if (previous === undefined) { delete process.env.OPENCLAW_TRAJECTORY_DIR; @@ -446,7 +450,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBeUndefined(); - expect(bundle.events.some((event) => event.type === "outside-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("outside-runtime"); }); it("does not fall back to runtime pointer targets that are not regular files", async () => { @@ -492,7 +496,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBeUndefined(); - expect(bundle.events.some((event) => event.type === "symlink-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("symlink-runtime"); }); it("counts expanded transcript events when enforcing the total event limit", async () => { @@ -542,7 +546,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.manifest.runtimeEventCount).toBe(0); - expect(bundle.events.some((event) => event.type === "other-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("other-runtime"); }); it("redacts non-workspace paths in strings that also contain workspace paths", async () => { @@ -744,9 +748,9 @@ describe("exportTrajectoryBundle", () => { .trim() .split(/\r?\n/u) .map((line) => JSON.parse(line) as TrajectoryEvent); - expect(exportedEvents.some((event) => event.type === "tool.call")).toBe(true); - expect(exportedEvents.some((event) => event.type === "tool.result")).toBe(true); - expect(exportedEvents.some((event) => event.type === "context.compiled")).toBe(true); + expect(eventTypes(exportedEvents)).toEqual( + expect.arrayContaining(["tool.call", "tool.result", "context.compiled"]), + ); expect(JSON.stringify(exportedEvents)).toContain("$WORKSPACE_DIR/inside.txt"); expect(JSON.stringify(exportedEvents)).not.toContain("$WORKSPACE_DIR2"); @@ -767,7 +771,8 @@ describe("exportTrajectoryBundle", () => { "system-prompt.txt", "tools.json", ]); - expect(manifest.contents?.every((entry) => entry.bytes > 0)).toBe(true); + const emptyContents = (manifest.contents ?? []).filter((entry) => entry.bytes <= 0); + expect(emptyContents).toEqual([]); const metadata = JSON.parse(fs.readFileSync(path.join(outputDir, "metadata.json"), "utf8")) as { skills?: { entries?: Array<{ id?: string; invoked?: boolean }> }; From 779122d761ea99fd743f01df46e6366dacae810b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:21:53 +0100 Subject: [PATCH 020/806] test: clarify plugin install persist cache assertion --- src/cli/plugins-install-persist.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index c3147b1c840..9348e76690f 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -123,9 +123,9 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); expect(refreshPluginRegistry).toHaveBeenCalled(); - expect( - runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Plugin runtime cache invalidation failed")]), + ); }); it("removes a replaced managed install directory before refreshing the registry", async () => { From 856a0b135ea29c838de26f3f767d8663d9683aff Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:22:15 +0100 Subject: [PATCH 021/806] test: clarify plugin update restart assertions --- src/cli/plugins-cli.update.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 17725c892b1..bfd8d011e4c 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -140,9 +140,11 @@ describe("plugins cli update", () => { ); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([ + expect.stringContaining("Restart the gateway to load plugins and hooks."), + ]), + ); }); it("exits when update is called without id and without --all", async () => { @@ -253,9 +255,11 @@ describe("plugins cli update", () => { installRecords: nextConfig.plugins?.installs, reason: "source-changed", }); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([ + expect.stringContaining("Restart the gateway to load plugins and hooks."), + ]), + ); }); it("exits non-zero when a plugin update reports an error after persisting successes", async () => { From 3d70ffa596f45c2d46efe3a84bc07994f4e0ddf5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:23:01 +0100 Subject: [PATCH 022/806] test: clarify update cli completion warning assertions --- src/cli/update-cli.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a8f7d4dc75d..becf79dc7f4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -646,9 +646,11 @@ describe("update-cli", () => { await updateCliShared.tryWriteCompletionCache(root, false); const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0])); - expect(logs.some((line) => line.includes("timed out after 30s"))).toBe(true); - expect(logs.some((line) => line.includes("openclaw completion --write-state"))).toBe(true); - expect(logs.some((line) => line.includes("Error: spawnSync"))).toBe(false); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("timed out after 30s")])); + expect(logs).toEqual( + expect.arrayContaining([expect.stringContaining("openclaw completion --write-state")]), + ); + expect(logs).not.toEqual(expect.arrayContaining([expect.stringContaining("Error: spawnSync")])); }); it("respawns into the updated package root before running post-update tasks", async () => { From b5453bb1b7991fc3a0215b0520354f45e72243e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:23:02 +0100 Subject: [PATCH 023/806] test: clarify cron scheduler delay assertions --- src/cron/service/ops.test.ts | 3 ++- src/cron/service/timer.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 1cbe711621a..23e0c1c9377 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -179,7 +179,8 @@ describe("cron service ops seam coverage", () => { const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((delay): delay is number => typeof delay === "number"); - expect(delays.some((delay) => delay > 0)).toBe(true); + const positiveDelays = delays.filter((delay) => delay > 0); + expect(positiveDelays.length).toBeGreaterThan(0); timeoutSpy.mockRestore(); stop(state); diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index b9dbc54df9e..5e7ad7fcdc4 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -89,7 +89,8 @@ describe("cron service timer seam coverage", () => { const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((delay): delay is number => typeof delay === "number"); - expect(delays.some((delay) => delay > 0)).toBe(true); + const positiveDelays = delays.filter((delay) => delay > 0); + expect(positiveDelays.length).toBeGreaterThan(0); timeoutSpy.mockRestore(); }); From f46fec4f4c8f7a7e59f06b83eff2310f0a7b055b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:23:21 +0100 Subject: [PATCH 024/806] test: clarify skills cli log assertions --- src/cli/skills-cli.commands.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 2829f8a418e..27614cfe231 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -220,7 +220,9 @@ describe("skills cli commands", () => { query: "calendar", limit: undefined, }); - expect(runtimeLogs.some((line) => line.includes("calendar v1.2.3 Calendar"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("calendar v1.2.3 Calendar")]), + ); }); it("installs a skill from ClawHub into the active workspace", async () => { @@ -335,8 +337,8 @@ describe("skills cli commands", () => { slug: undefined, logger: expect.any(Object), }); - expect(runtimeLogs.some((line) => line.includes("Updated calendar: 1.2.2 -> 1.2.3"))).toBe( - true, + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Updated calendar: 1.2.2 -> 1.2.3")]), ); expect(runtimeErrors).toEqual([]); }); From 2bf3c1d387a886660d889fe4159b0acf62847086 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:23:44 +0100 Subject: [PATCH 025/806] test: clarify logs cli reconnect assertions --- src/cli/logs-cli.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index 4338f10920d..9ce7fa7738f 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -426,8 +426,12 @@ describe("logs cli", () => { const messages = noticeRecords .filter((record) => record.type === "notice") .map((record) => record.message ?? ""); - expect(messages.some((message) => message.includes("gateway disconnected"))).toBe(true); - expect(messages.some((message) => message.includes("gateway reconnected"))).toBe(true); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining("gateway disconnected")]), + ); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining("gateway reconnected")]), + ); expect(stdoutWrites.join("")).toContain('"type":"meta"'); expect(exitSpy).toHaveBeenCalledWith(1); }); From 663c9700e4c3594448b02095884d183ed4f9c14a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:24:16 +0100 Subject: [PATCH 026/806] test: clarify config cli dry run assertion --- src/cli/config-cli.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index ac34facaaf8..e8dec00d50e 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -215,8 +215,8 @@ describe("config cli integration", () => { const afterDryRun = fs.readFileSync(configPath, "utf8"); expect(afterDryRun).toBe(before); expect(runtime.errors).toEqual([]); - expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe( - true, + expect(runtime.logs).toEqual( + expect.arrayContaining([expect.stringContaining("Dry run successful: 2 update(s)")]), ); await runConfigSet({ From 7bb89f915bd419a15543479b3f5ee60669457621 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:25:28 +0100 Subject: [PATCH 027/806] test: clarify discord startup log assertions --- .../discord/src/monitor/provider.test.ts | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index f87bb2d66ec..de5b6479562 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -1105,26 +1105,26 @@ describe("monitorDiscordProvider", () => { }); await vi.waitFor(() => - expect( - vi - .mocked(runtime.log) - .mock.calls.some((call) => String(call[0]).includes("deploy-commands:done")), - ).toBe(true), + expect(vi.mocked(runtime.log).mock.calls.map((call) => String(call[0]))).toEqual( + expect.arrayContaining([expect.stringContaining("deploy-commands:done")]), + ), ); const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); - expect(messages.some((msg) => msg.includes("fetch-application-id:start"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-application-id:done"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:schedule"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:scheduled"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:done"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-bot-identity:start"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-bot-identity:done"))).toBe(true); - expect( - messages.some( - (msg) => msg.includes("gateway-debug") && msg.includes("Gateway websocket opened"), - ), - ).toBe(true); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining("fetch-application-id:start"), + expect.stringContaining("fetch-application-id:done"), + expect.stringContaining("deploy-commands:schedule"), + expect.stringContaining("deploy-commands:scheduled"), + expect.stringContaining("deploy-commands:done"), + expect.stringContaining("fetch-bot-identity:start"), + expect.stringContaining("fetch-bot-identity:done"), + ]), + ); + expect(messages).toEqual( + expect.arrayContaining([expect.stringMatching(/gateway-debug.*Gateway websocket opened/)]), + ); }); it("keeps Discord startup chatter quiet by default", async () => { @@ -1136,6 +1136,8 @@ describe("monitorDiscordProvider", () => { }); const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); - expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); + expect(messages).not.toEqual( + expect.arrayContaining([expect.stringContaining("discord startup [")]), + ); }); }); From 6a9f10eb8812dd6868d2e23031d4dab08843bfd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:25:41 +0100 Subject: [PATCH 028/806] test: clarify channel streaming labels --- src/plugin-sdk/channel-streaming.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index cf253e1b252..2be16abac56 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -166,7 +166,8 @@ describe("channel-streaming", () => { }); it("uses auto progress labels when no explicit label is configured", () => { - expect(DEFAULT_PROGRESS_DRAFT_LABELS.every((label) => label.endsWith("..."))).toBe(true); + const invalidLabels = DEFAULT_PROGRESS_DRAFT_LABELS.filter((label) => !label.endsWith("...")); + expect(invalidLabels).toEqual([]); expect(resolveChannelProgressDraftLabel({ random: () => 0 })).toBe( DEFAULT_PROGRESS_DRAFT_LABELS[0], ); From da6231a84e0b2c5591ac8c3cfd2a45354a4fd826 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:27:35 +0100 Subject: [PATCH 029/806] test: clarify discord model picker nav assertions --- extensions/discord/src/monitor/model-picker.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index c87d1e1d655..468ec1c5e1f 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -392,9 +392,8 @@ describe("Discord model picker rendering", () => { return parsed?.action === "provider"; }); expect(providerButtons).toHaveLength(Object.keys(entries).length); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( - false, - ); + const customIds = allButtons.map((component) => component.custom_id ?? ""); + expect(customIds).not.toEqual(expect.arrayContaining([expect.stringContaining(";a=nav;")])); }); it("does not render navigation buttons even when provider count exceeds one page", () => { @@ -419,9 +418,8 @@ describe("Discord model picker rendering", () => { expect(rows.length).toBeGreaterThan(0); const allButtons = rows.flatMap((row) => row.components ?? []); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( - false, - ); + const customIds = allButtons.map((component) => component.custom_id ?? ""); + expect(customIds).not.toEqual(expect.arrayContaining([expect.stringContaining(";a=nav;")])); }); it("supports classic fallback rendering with content + action rows", () => { From 8282d21d3546bd287dcb8a7c42936f9ad6ca3d83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:27:44 +0100 Subject: [PATCH 030/806] test: dedupe sessions cleanup log assertions --- src/commands/sessions-cleanup.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 64d3a580716..3682d4d6197 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -64,6 +64,11 @@ function makeRuntime(): { runtime: RuntimeEnv; logs: string[] } { }; } +function expectLogsToInclude(logs: readonly string[], text: string): void { + const matches = logs.filter((line) => line.includes(text)); + expect(matches.length).toBeGreaterThan(0); +} + describe("sessionsCleanupCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -437,11 +442,16 @@ describe("sessionsCleanupCommand", () => { runtime, ); - expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true); - expect(logs.some((line) => line.includes("Would prune unreferenced artifacts: 2"))).toBe(true); - expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true); - expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true); - expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true); + expectLogsToInclude(logs, "Planned session actions:"); + expectLogsToInclude(logs, "Would prune unreferenced artifacts: 2"); + const tableHeaderLines = logs.filter((line) => line.includes("Action") && line.includes("Key")); + expect(tableHeaderLines.length).toBeGreaterThan(0); + const freshKeepLines = logs.filter((line) => line.includes("fresh") && line.includes("keep")); + expect(freshKeepLines.length).toBeGreaterThan(0); + const stalePruneLines = logs.filter( + (line) => line.includes("stale") && line.includes("prune-stale"), + ); + expect(stalePruneLines.length).toBeGreaterThan(0); }); it("returns grouped JSON for --all-agents dry-runs", async () => { From 281318e3dac98b1e3d346d358d4d35661f2569c1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:28:09 +0100 Subject: [PATCH 031/806] test: clarify slack external menu assertion --- extensions/slack/src/monitor/slash.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 58f0d6d7989..695c3ce9daf 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -810,7 +810,7 @@ describe("Slack native command argument menus", () => { options?: Array<{ text?: { text?: string }; value?: string }>; }; const optionTexts = (optionsPayload.options ?? []).map((option) => option.text?.text ?? ""); - expect(optionTexts.some((text) => text.includes("Period 12"))).toBe(true); + expect(optionTexts).toEqual(expect.arrayContaining([expect.stringContaining("Period 12")])); }); it("tracks accepted external_select option requests", async () => { From 4fd9d0e44dbe5aa6022e03f97f204da1e909e508 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:28:36 +0100 Subject: [PATCH 032/806] test: clarify google oauth fallback assertion --- extensions/google/oauth.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 396df81e98a..4fc8e83406d 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -844,7 +844,9 @@ describe("loginGeminiCliOAuth", () => { await runProjectDiscoveryExpectingProjectId("env-project"); expect(requests.filter(({ url }) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); - expect(requests.some(({ url }) => url.includes("v1internal:onboardUser"))).toBe(false); + expect(requests.map(({ url }) => url)).not.toEqual( + expect.arrayContaining([expect.stringContaining("v1internal:onboardUser")]), + ); }); it("skips loadCodeAssist entirely when Gemini CLI is configured for personal OAuth", async () => { From c0921c2f2411c50c78a8cf1d0d0e720a7c2f29d0 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:29:01 +0100 Subject: [PATCH 033/806] test: clarify nostr profile validation assertions --- extensions/nostr/src/nostr-profile.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/nostr/src/nostr-profile.test.ts b/extensions/nostr/src/nostr-profile.test.ts index 5f1802ecad3..704a89528cf 100644 --- a/extensions/nostr/src/nostr-profile.test.ts +++ b/extensions/nostr/src/nostr-profile.test.ts @@ -256,7 +256,7 @@ describe("validateProfile", () => { const result = validateProfile(profile); expect(result.valid).toBe(false); - expect(result.errors!.some((e) => e.includes("256"))).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining("256")])); }); it("rejects about exceeding 2000 characters", () => { @@ -267,7 +267,7 @@ describe("validateProfile", () => { const result = validateProfile(profile); expect(result.valid).toBe(false); - expect(result.errors!.some((e) => e.includes("2000"))).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining("2000")])); }); it("accepts empty profile", () => { From b6a6580db3adb41afaf2a099e2ab6a34565256d5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:29:25 +0100 Subject: [PATCH 034/806] test: clarify nostr profile http assertion --- extensions/nostr/src/nostr-profile-http.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 28ece3f3a21..126f3496b89 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -430,7 +430,7 @@ describe("nostr-profile-http", () => { // The schema validation catches non-https URLs before SSRF check expect(data.error).toBe("Validation failed"); expect(data.details).toEqual(expect.any(Array)); - expect(data.details.some((d: string) => d.includes("https"))).toBe(true); + expect(data.details).toEqual(expect.arrayContaining([expect.stringContaining("https")])); }); it("does not persist if all relays fail", async () => { From 838565fe5997b03279d6d353f4c3c2fc6e25c0fd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:29:55 +0100 Subject: [PATCH 035/806] test: clarify update runner command assertions --- src/infra/update-runner.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 1f2a7514b9a..00d8bf3262e 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -323,7 +323,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("dirty"); - expect(calls.some((call) => call.includes("rebase"))).toBe(false); + expect(calls).not.toEqual(expect.arrayContaining([expect.stringContaining("rebase")])); }); it.each([ @@ -366,7 +366,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("rebase-failed"); - expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("rebase --abort")])); }); it("returns error and stops early when deps install fails", async () => { @@ -611,12 +611,16 @@ describe("runGatewayUpdate", () => { const result = await runWithCommand(runCommand, { channel: "dev" }); expect(result.status).toBe("ok"); - expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); + expect(calls).toEqual( + expect.arrayContaining([expect.stringMatching(/^npm install --prefix /)]), + ); expect(calls).toContain("pnpm install"); expect(calls).toContain("pnpm build"); expect(calls).not.toContain("pnpm lint"); expect(calls).toContain("pnpm ui:build"); - expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); + expect(pnpmEnvPaths).toEqual( + expect.arrayContaining([expect.stringContaining("openclaw-update-pnpm-")]), + ); }); it("runs dev preflight lint in constrained mode when explicitly enabled", async () => { @@ -1961,7 +1965,9 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("not-openclaw-root"); - expect(calls.some((call) => call.includes("status --porcelain"))).toBe(false); + expect(calls).not.toEqual( + expect.arrayContaining([expect.stringContaining("status --porcelain")]), + ); }); it("fails with a clear reason when openclaw.mjs is missing", async () => { From 5fbbfa97aacb3e1fa1857455f7ac870d3265e90b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:30:29 +0100 Subject: [PATCH 036/806] test: clarify doctor state integrity assertions --- src/commands/doctor-state-integrity.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 7556002ad8c..d09e08bd0b9 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -440,7 +440,9 @@ describe("doctor state integrity oauth dir checks", () => { await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); expect(fs.existsSync(transcriptPath)).toBe(true); - expect(fs.readdirSync(sessionsDir).some((name) => name.includes(".deleted."))).toBe(false); + expect(fs.readdirSync(sessionsDir)).not.toEqual( + expect.arrayContaining([expect.stringContaining(".deleted.")]), + ); expect(stateIntegrityText()).not.toContain("These .jsonl files are no longer referenced"); } finally { fs.rmSync(symlinkHome, { force: true, recursive: true }); @@ -573,7 +575,9 @@ describe("doctor state integrity oauth dir checks", () => { const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); const store = JSON.parse(fs.readFileSync(storePath, "utf8")) as Record; expect(store["agent:main:main"]?.sessionId).toBe("mixed-session"); - expect(Object.keys(store).some((key) => key.includes("heartbeat-recovered"))).toBe(false); + expect(Object.keys(store)).not.toEqual( + expect.arrayContaining([expect.stringContaining("heartbeat-recovered")]), + ); expect(confirmRuntimeRepair).not.toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Move heartbeat-owned main session"), From d150d8c0536df674d3e299ad04d73a8c3a7ad41e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:30:37 +0100 Subject: [PATCH 037/806] test: clarify port diagnostics assertions --- src/infra/ports-format.test.ts | 3 ++- src/infra/ports.test.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index c7a280efb16..665553619ee 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -76,6 +76,7 @@ describe("ports-format", () => { }); expect(lines[0]).toContain("Port 18789 is already in use"); expect(lines).toContain("- pid 123 alice: ssh -N -L 18789:127.0.0.1:18789"); - expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true); + const sshTunnelHints = lines.filter((line) => line.includes("SSH tunnel")); + expect(sshTunnelHints.length).toBeGreaterThan(0); }); }); diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 7ace7670e56..17849ed749e 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -130,7 +130,8 @@ describeUnix("inspectPortUsage", () => { try { const result = await inspectPortUsage(port); expect(result.status).toBe("busy"); - expect(result.errors?.some((err) => err.includes("ENOENT"))).toBe(true); + const enoentErrors = (result.errors ?? []).filter((err) => err.includes("ENOENT")); + expect(enoentErrors.length).toBeGreaterThan(0); } finally { await new Promise((resolve) => server.close(() => resolve())); } @@ -230,9 +231,10 @@ describe("inspectPortUsage on Windows", () => { expect(result.listeners).toHaveLength(1); expect(result.listeners[0]?.command).toBe("node.exe"); expect(result.listeners[0]?.commandLine).toContain("openclaw"); - expect(result.hints.some((hint) => hint.includes("Gateway already running locally"))).toBe( - true, + const gatewayRunningHints = result.hints.filter((hint) => + hint.includes("Gateway already running locally"), ); + expect(gatewayRunningHints.length).toBeGreaterThan(0); }); it("falls back to wmic when PowerShell cannot read the command line", async () => { @@ -265,6 +267,7 @@ describe("inspectPortUsage on Windows", () => { const result = await inspectPortUsage(18789); expect(result.listeners[0]?.commandLine).toContain("openclaw"); - expect(runCommandWithTimeoutMock.mock.calls.some(([argv]) => argv[0] === "wmic")).toBe(true); + const commandNames = runCommandWithTimeoutMock.mock.calls.map(([argv]) => argv[0]); + expect(commandNames).toContain("wmic"); }); }); From d32ff0509064755477941ed097cd3656841be881 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:30:57 +0100 Subject: [PATCH 038/806] test: clarify health snapshot probe assertions --- src/commands/health.snapshot.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 3db34d09f80..e298695effc 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -507,8 +507,8 @@ describe("getHealthSnapshot", () => { expect(telegram.probe?.ok).toBe(true); expect(telegram.probe?.bot?.username).toBe("bot"); expect(telegram.probe?.webhook?.url).toMatch(/^https:/); - expect(calls.some((c) => c.includes("/getMe"))).toBe(true); - expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("/getMe")])); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("/getWebhookInfo")])); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-health-")); const tokenFile = path.join(tmpDir, "telegram-token"); @@ -520,7 +520,9 @@ describe("getHealthSnapshot", () => { ); expect(tokenFileProbe.telegram.configured).toBe(true); expect(tokenFileProbe.telegram.probe?.ok).toBe(true); - expect(tokenFileProbe.calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); + expect(tokenFileProbe.calls).toEqual( + expect.arrayContaining([expect.stringContaining("bott-file/getMe")]), + ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } From 9ca5e4aaa924d63bba15119c2a87731787287f8e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:31:25 +0100 Subject: [PATCH 039/806] test: clarify doctor default account assertions --- ...tor-config-flow.missing-explicit-default-account.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts index e9e4c998413..477207cbe16 100644 --- a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts +++ b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts @@ -121,7 +121,9 @@ describe("collectMissingExplicitDefaultAccountWarnings", () => { const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); expect(warnings).toHaveLength(2); - expect(warnings.some((line) => line.includes("channels.telegram"))).toBe(true); - expect(warnings.some((line) => line.includes("channels.slack"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("channels.telegram")]), + ); + expect(warnings).toEqual(expect.arrayContaining([expect.stringContaining("channels.slack")])); }); }); From 84fe3c5409cba5860efdf371c04a648a22b6b431 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 8 May 2026 04:31:30 -0500 Subject: [PATCH 040/806] fix(daemon): include homebrew paths in launchagent env (#79331) --- CHANGELOG.md | 1 + src/commands/daemon-install-helpers.test.ts | 6 ++- src/daemon/service-audit.test.ts | 3 +- src/daemon/service-env.test.ts | 59 ++++++++++++++++++--- src/daemon/service-env.ts | 10 +++- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e7660671c..6e81ec31108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,7 @@ Docs: https://docs.openclaw.ai - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. - macOS/config: reject stale or destructive app fallback config writes before direct replacement and keep rejected payloads as private audit artifacts, so `gateway.mode`, metadata, and auth are not silently clobbered. Fixes #64973 and #74890. Thanks @BunsDev. +- Gateway/macOS: include Apple Silicon Homebrew bin and sbin directories in generated LaunchAgent service PATHs so `openclaw gateway restart` keeps Homebrew Node installs reachable. Fixes #79232. Thanks @BunsDev. - Doctor/OpenAI: stop pinning migrated `openai-codex/*` routes to the Codex runtime so mixed-provider agents keep automatic PI routing for MiniMax, Anthropic, and other non-OpenAI model switches. - Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. - Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index d069a9078b1..91fe202329e 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -931,7 +931,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { serviceEnvironment: { HOME: "/from-service", OPENCLAW_PORT: "3000", - PATH: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + PATH: "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", TMPDIR: "/tmp", }, }); @@ -953,7 +953,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }, }); - expect(plan.environment.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + expect(plan.environment.PATH).toBe( + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ); }); it("drops legacy inline env values when the key is now managed by .env", async () => { diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 06b0b750eb2..98e434719bd 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -124,7 +124,8 @@ describe("auditGatewayServiceConfig", () => { it("accepts canonical macOS gateway service PATH without user-bin defaults", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-service-audit-home-")); try { - const servicePath = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + const servicePath = + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; const audit = await auditGatewayServiceConfig({ env: { HOME: home }, diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index baef93dc5f5..d20e8ad89f7 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -113,7 +113,16 @@ describe("getMinimalServicePathParts - Linux user directories", () => { existsSync: allExist, }); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); + expect(result.some((entry) => entry.startsWith("/Users/testuser/"))).toBe(false); }); it("can include env-configured version manager dirs on macOS when requested", () => { @@ -145,7 +154,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { }); const fnmIndex = result.indexOf("/Users/testuser/.fnm/aliases/default/bin"); - const systemIndex = result.indexOf("/usr/local/bin"); + const systemIndex = result.indexOf("/opt/homebrew/bin"); expect(fnmIndex).toBe(-1); expect(systemIndex).toBe(0); @@ -191,7 +200,15 @@ describe("getMinimalServicePathParts - Linux user directories", () => { existsSync: noneExist, }); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); expect(result).not.toContain("/Users/testuser/.local/bin"); expect(result).not.toContain("/Users/testuser/.npm-global/bin"); expect(result).not.toContain("/Users/testuser/bin"); @@ -355,7 +372,15 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { }); expect(result).not.toContain("/Users/testuser/.nix-profile/bin"); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("places rightmost NIX_PROFILES entry before leftmost on Linux", () => { @@ -389,7 +414,15 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); expect(userIdx).toBe(-1); expect(defaultIdx).toBe(-1); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("includes single Nix profile from NIX_PROFILES on Linux", () => { @@ -450,7 +483,15 @@ describe("buildMinimalServicePath", () => { platform: "darwin", }); const parts = splitPath(result, "darwin"); - expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(parts).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("returns PATH as-is on Windows", () => { @@ -607,7 +648,9 @@ describe("buildServiceEnvironment", () => { platform: "darwin", }); - expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + expect(env.PATH).toBe( + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ); }); it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => { @@ -699,7 +742,7 @@ describe("buildServiceEnvironment", () => { }); expect(env.PATH).toBe( - "/opt/homebrew/Cellar/node/22.16.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "/opt/homebrew/Cellar/node/22.16.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", ); }); }); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index ebc4d422d3e..1d0143a09af 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -222,7 +222,15 @@ function addNixProfileBinDirs( function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { if (platform === "darwin") { - return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + return [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]; } if (platform === "linux") { return ["/usr/local/bin", "/usr/bin", "/bin"]; From 3f1e422859d00f2c41e2af6b4c9684625add873f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:32:00 +0100 Subject: [PATCH 041/806] test: clarify status output assertions --- src/commands/status.test.ts | 40 +++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index ad8fb9a00df..990d19e6eeb 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1088,21 +1088,21 @@ describe("statusCommand", () => { "Troubleshooting:", "Next steps:", ]) { - expect(logs.some((line) => line.includes(token))).toBe(true); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining(token)])); } - expect( - logs.some((line) => line.includes("legacy-plugin still uses legacy before_agent_start")), - ).toBe(true); - expect( - logs.some( - (line) => - line.includes("openclaw status --all") || - line.includes("openclaw --profile isolated status --all"), - ), - ).toBe(true); - expect(logs.some((line) => line.includes("Cache"))).toBe(true); - expect(logs.some((line) => line.includes("40% hit"))).toBe(true); - expect(logs.some((line) => line.includes("read 2.0k"))).toBe(true); + expect(logs).toEqual( + expect.arrayContaining([ + expect.stringContaining("legacy-plugin still uses legacy before_agent_start"), + ]), + ); + expect(logs).toEqual( + expect.arrayContaining([ + expect.stringMatching(/openclaw (?:--profile isolated )?status --all/), + ]), + ); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("Cache")])); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("40% hit")])); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("read 2.0k")])); }); it("shows a maintenance hint when task audit errors are present", async () => { @@ -1157,8 +1157,8 @@ describe("statusCommand", () => { }, }); const logs = await runStatusAndGetLogs(); - expect(logs.some((line) => line.includes("100% cached"))).toBe(true); - expect(logs.some((line) => line.includes("120% cached"))).toBe(false); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("100% cached")])); + expect(logs).not.toEqual(expect.arrayContaining([expect.stringContaining("120% cached")])); mocks.loadSessionStore.mockReturnValue({ "+1000": { @@ -1170,8 +1170,10 @@ describe("statusCommand", () => { }, }); const promptSideLogs = await runStatusAndGetLogs(); - expect(promptSideLogs.some((line) => line.includes("67% cached"))).toBe(true); - expect(promptSideLogs.some((line) => line.includes("40% cached"))).toBe(false); + expect(promptSideLogs).toEqual(expect.arrayContaining([expect.stringContaining("67% cached")])); + expect(promptSideLogs).not.toEqual( + expect.arrayContaining([expect.stringContaining("40% cached")]), + ); }); it("shows node-only gateway info when no local gateway service is installed", async () => { @@ -1216,7 +1218,7 @@ describe("statusCommand", () => { presence: [], }); const logs = await runStatusAndGetLogs(); - expect(logs.some((l: string) => l.includes("auth token"))).toBe(true); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("auth token")])); }); }); From 419b6e8993e4cdae729bba79edf3e48eb9f0d3f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:32:07 +0100 Subject: [PATCH 042/806] test: clarify legacy migration change assertions --- .../doctor/shared/legacy-config-migrate.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 65af434c683..ba9d3acbee6 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -19,6 +19,13 @@ function migrateLegacyConfigForTest(raw: unknown): { : { config: next as OpenClawConfig, changes }; } +function expectMigrationChangesToIncludeFragments(changes: string[], fragments: string[]): void { + const unmatchedFragments = fragments.filter((fragment) => + changes.every((change) => !change.includes(fragment)), + ); + expect({ changes, unmatchedFragments }).toMatchObject({ unmatchedFragments: [] }); +} + describe("legacy session maintenance migrate", () => { it("removes deprecated session.maintenance.rotateBytes", () => { const res = migrateLegacyConfigForTest({ @@ -666,8 +673,10 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { "http://localhost:18789", "http://127.0.0.1:18789", ]); - expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); - expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true); + expectMigrationChangesToIncludeFragments(res.changes, [ + "gateway.controlUi.allowedOrigins", + "bind=lan", + ]); }); it("seeds allowedOrigins using configured port", () => { @@ -734,7 +743,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { "http://localhost:18789", "http://127.0.0.1:18789", ]); - expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); + expectMigrationChangesToIncludeFragments(res.changes, ["gateway.controlUi.allowedOrigins"]); }); it("does not migrate loopback bind — returns null", () => { From a2ef6ff8b8cc17817e04012092c83ead5bae3c7a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:33:10 +0100 Subject: [PATCH 043/806] test: clarify doctor config warning assertions --- src/commands/doctor-config-flow.test.ts | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index d63fe7a995a..969f7305354 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1408,7 +1408,9 @@ describe("doctor config flow", () => { }, }, }); - expect(doctorWarnings.some((line) => line.includes("mutable allowlist"))).toBe(false); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("mutable allowlist")]), + ); }); it("warns when hooks transformsDir points outside the hook transforms root", async () => { @@ -1533,14 +1535,16 @@ describe("doctor config flow", () => { }, }); - expect( - doctorWarnings.some((line) => - line.includes( + expect(doctorWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( 'channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false.', ), - ), - ).toBe(true); - expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + ]), + ); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("first-time setup mode")]), + ); }); it("shows plugin-blocked guidance instead of first-time Telegram guidance when plugins are disabled globally", async () => { @@ -1556,14 +1560,16 @@ describe("doctor config flow", () => { }, }); - expect( - doctorWarnings.some((line) => - line.includes( + expect(doctorWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( "channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.", ), - ), - ).toBe(true); - expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + ]), + ); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("first-time setup mode")]), + ); }); it("warns on mutable Zalouser group entries when dangerous name matching is disabled", async () => { @@ -1597,7 +1603,9 @@ describe("doctor config flow", () => { }, }); - expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("channels.zalouser.groups")]), + ); }); it("warns when imessage group allowlist is empty even if allowFrom is set", async () => { From 45d0efad23ab5cdbe0c4570a350ab6015f2f5643 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:33:39 +0100 Subject: [PATCH 044/806] test: clarify ollama setup fetch assertions --- extensions/ollama/src/setup.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 4531b55b3b4..afc6e5a5535 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -266,12 +266,9 @@ describe("ollama setup", () => { allowSecretRefPrompt: false, }); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("127.0.0.1"))).toBe( - false, - ); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("ollama.com"))).toBe( - true, - ); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls).not.toEqual(expect.arrayContaining([expect.stringContaining("127.0.0.1")])); + expect(requestUrls).toEqual(expect.arrayContaining([expect.stringContaining("ollama.com")])); }); it("rejects the local marker during cloud-only setup", async () => { @@ -303,8 +300,8 @@ describe("ollama setup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags"); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe( - false, + expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toEqual( + expect.arrayContaining([expect.stringContaining("/api/me")]), ); }); From efd795e98a363292d282a1ae92ce7fbfe4bd253f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:34:12 +0100 Subject: [PATCH 045/806] test: clarify telegram status issue assertions --- extensions/telegram/src/status.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/telegram/src/status.test.ts b/extensions/telegram/src/status.test.ts index f212417d96a..7025b15d736 100644 --- a/extensions/telegram/src/status.test.ts +++ b/extensions/telegram/src/status.test.ts @@ -36,9 +36,14 @@ describe("collectTelegramStatusIssues", () => { }), ]), ); - expect(issues.some((issue) => issue.message.includes("privacy mode"))).toBe(true); - expect(issues.some((issue) => issue.message.includes('uses "*"'))).toBe(true); - expect(issues.some((issue) => issue.message.includes("unresolvedGroups=2"))).toBe(true); + const issueMessages = issues.map((issue) => issue.message); + expect(issueMessages).toEqual( + expect.arrayContaining([expect.stringContaining("privacy mode")]), + ); + expect(issueMessages).toEqual(expect.arrayContaining([expect.stringContaining('uses "*"')])); + expect(issueMessages).toEqual( + expect.arrayContaining([expect.stringContaining("unresolvedGroups=2")]), + ); }); it("reports unreachable groups with match metadata", () => { From 7875c1a6c1abcb63ca9050b1155239f37fbdd9b1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:34:40 +0100 Subject: [PATCH 046/806] test: clarify memory wiki session filters --- extensions/memory-wiki/src/query.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index e6a000c0296..5611e3372b6 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -754,7 +754,9 @@ describe("searchMemoryWiki", () => { "sessions/child-session.jsonl", "MEMORY.md", ]); - expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + expect(results.map((result) => result.path)).not.toEqual( + expect.arrayContaining([expect.stringContaining("sibling-session")]), + ); }); it("filters session memory hits for session-bound non-sandboxed callers", async () => { @@ -808,7 +810,9 @@ describe("searchMemoryWiki", () => { "sessions/child-session.jsonl", "MEMORY.md", ]); - expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + expect(results.map((result) => result.path)).not.toEqual( + expect.arrayContaining([expect.stringContaining("sibling-session")]), + ); }); it("requires appConfig for session-bound shared memory searches", async () => { From 1e5d0a205a9cf94e411a5890ade2e31cc37614a9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:35:07 +0100 Subject: [PATCH 047/806] test: clarify session transcript candidate assertion --- src/gateway/session-utils.fs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 2299261f633..f4239e65484 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1806,7 +1806,7 @@ describe("resolveSessionTranscriptCandidates safety", () => { const normalizedCandidates = candidates.map((value) => path.resolve(value)); const expectedFallback = path.resolve(path.dirname(storePath), "sess-safe.jsonl"); - expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false); + expect(candidates).not.toEqual(expect.arrayContaining([expect.stringContaining("etc/passwd")])); expect(normalizedCandidates).toContain(expectedFallback); }); From 8441c644343f5297f7b30e393c51eb8315a9d8ab Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:35:39 +0100 Subject: [PATCH 048/806] test: clarify gateway reload event assertions --- src/gateway/server.reload.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index bd12322df92..5ee9b91841b 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -430,8 +430,8 @@ describe("gateway hot reload", () => { }) { await expect(params.applyReload()).rejects.toThrow(params.expectedError); const degradedEvents = drainSystemEvents(params.sessionKey); - expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe( - true, + expect(degradedEvents).toEqual( + expect.arrayContaining([expect.stringContaining("[SECRETS_RELOADER_DEGRADED]")]), ); await expect(params.applyReload()).rejects.toThrow(params.expectedError); @@ -444,8 +444,8 @@ describe("gateway hot reload", () => { }) { await expect(params.applyReload()).resolves.toBeUndefined(); const recoveredEvents = drainSystemEvents(params.sessionKey); - expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe( - true, + expect(recoveredEvents).toEqual( + expect.arrayContaining([expect.stringContaining("[SECRETS_RELOADER_RECOVERED]")]), ); } From 2790549fc6b6440597587d4cd481832c6587f90b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:35:37 +0100 Subject: [PATCH 049/806] test: clarify update runner command assertions --- src/infra/update-runner.test.ts | 36 ++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 00d8bf3262e..0f3811828ab 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -381,8 +381,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("deps-install-failed"); - expect(calls.some((call) => call === "pnpm build")).toBe(false); - expect(calls.some((call) => call === "pnpm ui:build")).toBe(false); + expect(calls).not.toContain("pnpm build"); + expect(calls).not.toContain("pnpm ui:build"); }); it("returns error and stops early when build fails", async () => { @@ -398,8 +398,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("build-failed"); - expect(calls.some((call) => call === "pnpm install")).toBe(true); - expect(calls.some((call) => call === "pnpm ui:build")).toBe(false); + expect(calls).toContain("pnpm install"); + expect(calls).not.toContain("pnpm ui:build"); }); it("uses stable tag when beta tag is older than release", async () => { @@ -459,7 +459,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); expect(calls).toContain("pnpm --version"); - expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); + const npmPrefixInstallCalls = calls.filter((call) => call.startsWith("npm install --prefix ")); + expect(npmPrefixInstallCalls.length).toBeGreaterThan(0); expect(calls).toContain("npm --version"); expect(calls).toContain("pnpm install"); expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps"); @@ -1427,9 +1428,10 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("pnpm-npm-bootstrap-failed"); - expect(calls.some((call) => call === "npm run build")).toBe(false); - expect(calls.some((call) => call === "npm run lint")).toBe(false); - expect(calls.some((call) => preflightPrefixPattern.test(call))).toBe(false); + expect(calls).not.toContain("npm run build"); + expect(calls).not.toContain("npm run lint"); + const preflightCalls = calls.filter((call) => preflightPrefixPattern.test(call)); + expect(preflightCalls).toEqual([]); }); it("skips update when no git root", async () => { @@ -1449,8 +1451,10 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("not-git-install"); - expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); - expect(calls.some((call) => call.startsWith("npm i -g"))).toBe(false); + const pnpmGlobalInstallCalls = calls.filter((call) => call.startsWith("pnpm add -g")); + const npmGlobalInstallCalls = calls.filter((call) => call.startsWith("npm i -g")); + expect(pnpmGlobalInstallCalls).toEqual([]); + expect(npmGlobalInstallCalls).toEqual([]); }); async function runNpmGlobalUpdateCase(params: { @@ -1573,7 +1577,7 @@ describe("runGatewayUpdate", () => { expect(result.mode).toBe("npm"); expect(result.before?.version).toBe("1.0.0"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call === expectedInstallCommand)).toBe(true); + expect(calls).toContain(expectedInstallCommand); }); it("updates global npm installs from the GitHub main package spec", async () => { @@ -1899,8 +1903,12 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); expect(result.mode).toBe("pnpm"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true); - expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); + const npmPrefixedGlobalInstallCalls = calls.filter((call) => + call.startsWith("npm i -g --prefix "), + ); + const pnpmAddGlobalCalls = calls.filter((call) => call.startsWith("pnpm add -g")); + expect(npmPrefixedGlobalInstallCalls.length).toBeGreaterThan(0); + expect(pnpmAddGlobalCalls).toEqual([]); expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]); await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -1948,7 +1956,7 @@ describe("runGatewayUpdate", () => { expect(result.mode).toBe("bun"); expect(result.before?.version).toBe("1.0.0"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call === "bun add -g openclaw@latest")).toBe(true); + expect(calls).toContain("bun add -g openclaw@latest"); }); }); From d52aad4cf276d48789aeef85c7199fc9f6574ce1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:36:11 +0100 Subject: [PATCH 050/806] test: clarify gateway hook event assertions --- src/gateway/server.hooks.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 4860d92f946..f22fb11214a 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -172,14 +172,16 @@ describe("gateway server hooks", () => { const resWake = await postHook(port, "/hooks/wake", { text: "Ping", mode: "next-heartbeat" }); expect(resWake.status).toBe(200); const wakeEvents = await waitForSystemEvent(); - expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true); + expect(wakeEvents).toEqual(expect.arrayContaining([expect.stringContaining("Ping")])); drainSystemEvents(resolveMainKey()); mockIsolatedRunOkOnce(); const resAgent = await postHook(port, "/hooks/agent", { message: "Do it", name: "Email" }); expect(resAgent.status).toBe(200); const agentEvents = await waitForSystemEvent(); - expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); + expect(agentEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Hook Email: done")]), + ); const firstCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { job?: { payload?: { externalContentSource?: string } }; }; @@ -251,7 +253,9 @@ describe("gateway server hooks", () => { ); expect(resHeader.status).toBe(200); const headerEvents = await waitForSystemEvent(); - expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true); + expect(headerEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Header auth")]), + ); drainSystemEvents(resolveMainKey()); const resGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { @@ -321,7 +325,9 @@ describe("gateway server hooks", () => { expect(resAgent.status).toBe(200); const targetEvents = await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY); - expect(targetEvents.some((event) => event.includes("Hook Email: done"))).toBe(true); + expect(targetEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Hook Email: done")]), + ); expect(peekSystemEventEntries(resolveMainKey())).toEqual([]); drainSystemEvents(HOOKS_MAIN_SESSION_KEY); }); From 2d84fd749ee9526c7be1ba6c459b7a67693de416 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:36:40 +0100 Subject: [PATCH 051/806] test: clarify gateway cron event assertion --- src/gateway/server.cron.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 0747a071e10..d7b92d46d3f 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -447,7 +447,7 @@ describe("gateway server cron", () => { const runRes = await cronState.cron.run(routeJobId, "force"); expect(runRes).toEqual({ ok: true, ran: true }); const events = await waitForSystemEvent(); - expect(events.some((event) => event.includes("cron route check"))).toBe(true); + expect(events).toEqual(expect.arrayContaining([expect.stringContaining("cron route check")])); const wrappedAtMs = Date.now() + 1000; const wrappedRes = await directCronReq(cronState, "cron.add", { From 5b9f94baae62738951c006b7ecb2c1d49825493a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:37:15 +0100 Subject: [PATCH 052/806] test: assert update restart message suppression --- src/cli/update-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index becf79dc7f4..47b2bd67d04 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -2969,8 +2969,8 @@ describe("update-cli", () => { }, assert: () => { const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( - false, + expect(logLines).not.toEqual( + expect.arrayContaining([expect.stringContaining("Daemon restarted successfully.")]), ); }, }, From d760bf87f01f365f6ca8bf306aac17ff670ef448 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:37:43 +0100 Subject: [PATCH 053/806] test: assert config secret resolvability errors --- src/cli/config-cli.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index e8dec00d50e..a3a24e27962 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -303,9 +303,11 @@ describe("config cli integration", () => { }; expect(payload.ok).toBe(false); expect(payload.checks?.resolvability).toBe(true); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe( - true, + expect(payload.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "resolvability" })]), + ); + expect(payload.errors?.map((entry) => entry.ref ?? "")).toEqual( + expect.arrayContaining([expect.stringContaining("MISSING_TEST_SECRET")]), ); } finally { envSnapshot.restore(); From c4413e30f9a030700d21ddfd4d3970595e8d91e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:37:47 +0100 Subject: [PATCH 054/806] test: clarify heartbeat scheduler assertions --- src/infra/heartbeat-runner.scheduler.test.ts | 33 ++++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index 113382b2d37..e3728bb1f1b 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -160,16 +160,16 @@ describe("startHeartbeatRunner", () => { expect(runSpy.mock.calls.slice(1).map((call) => call[0]?.agentId)).toEqual( expect.arrayContaining(["main", "ops"]), ); - expect( - runSpy.mock.calls.some( - (call) => call[0]?.agentId === "main" && call[0]?.heartbeat?.every === "10m", - ), - ).toBe(true); - expect( - runSpy.mock.calls.some( - (call) => call[0]?.agentId === "ops" && call[0]?.heartbeat?.every === "15m", - ), - ).toBe(true); + const reloadedHeartbeatCalls = runSpy.mock.calls.map((call) => ({ + agentId: call[0]?.agentId, + every: call[0]?.heartbeat?.every, + })); + expect(reloadedHeartbeatCalls).toEqual( + expect.arrayContaining([ + { agentId: "main", every: "10m" }, + { agentId: "ops", every: "15m" }, + ]), + ); runner.stop(); }); @@ -353,12 +353,18 @@ describe("startHeartbeatRunner", () => { requestHeartbeat(wake("retry", { coalesceMs: 0 })); await vi.advanceTimersByTimeAsync(1_000); } - expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(false); + const scheduledSlotCallsBeforeInterval = callTimes.filter( + (time) => time >= firstDueMs + intervalMs, + ); + expect(scheduledSlotCallsBeforeInterval).toEqual([]); // The next interval tick at the next scheduled slot should still fire — // the retries must not push the phase out by multiple intervals. await vi.advanceTimersByTimeAsync(firstDueMs + intervalMs - Date.now() + 1); - expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(true); + const scheduledSlotCallsAfterInterval = callTimes.filter( + (time) => time >= firstDueMs + intervalMs, + ); + expect(scheduledSlotCallsAfterInterval.length).toBeGreaterThan(0); runner.stop(); }); @@ -501,7 +507,8 @@ describe("startHeartbeatRunner", () => { sessionKey: "agent:main:main", }, }); - expect(runSpy.mock.calls.some((call) => call[0]?.agentId === "finance")).toBe(false); + const financeCalls = runSpy.mock.calls.filter((call) => call[0]?.agentId === "finance"); + expect(financeCalls).toEqual([]); runner.stop(); }); From 2ef84a9fc45db908f064818ee18641e05b0a3422 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:38:12 +0100 Subject: [PATCH 055/806] test: assert acp stream logging contracts --- src/commands/agent.acp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 01a2823ff51..ca496af5dae 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -374,7 +374,7 @@ describe("agentCommand ACP runtime routing", () => { { text: "bo", delta: "bo" }, { text: "book", delta: "ok" }, ]); - expect(repeated.logLines.some((line) => line.includes("book"))).toBe(true); + expect(repeated.logLines).toEqual(expect.arrayContaining([expect.stringContaining("book")])); }); }); @@ -383,7 +383,7 @@ describe("agentCommand ACP runtime routing", () => { const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents(["NO_REPLY"]); expect(assistantEvents.map((event) => event.text).filter(Boolean)).toEqual([]); - expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); + expect(logLines).not.toEqual(expect.arrayContaining([expect.stringContaining("NO_REPLY")])); expect(logLines).toEqual([]); }); }); From 318058a24bec69e5500123e33084e84484b15dc7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:38:51 +0100 Subject: [PATCH 056/806] test: assert acp parent stream relay messages --- src/agents/acp-spawn-parent-stream.test.ts | 56 +++++++++++++++------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/agents/acp-spawn-parent-stream.test.ts b/src/agents/acp-spawn-parent-stream.test.ts index aa6ae95f0ad..2ecfa2c8089 100644 --- a/src/agents/acp-spawn-parent-stream.test.ts +++ b/src/agents/acp-spawn-parent-stream.test.ts @@ -113,9 +113,15 @@ describe("startAcpSpawnParentStreamRelay", () => { }); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("Started codex session"))).toBe(true); - expect(texts.some((text) => text.includes("codex: hello from child"))).toBe(true); - expect(texts.some((text) => text.includes("codex run completed in 2s"))).toBe(true); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: hello from child")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex run completed in 2s")]), + ); expect( enqueueSystemEventMock.mock.calls.every( (call) => (call[1] as { trusted?: boolean } | undefined)?.trusted === false, @@ -150,8 +156,8 @@ describe("startAcpSpawnParentStreamRelay", () => { }); vi.advanceTimersByTime(1_500); - expect(collectedTexts().some((text) => text.includes("has produced no output for 1s"))).toBe( - true, + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("has produced no output for 1s")]), ); emitAgentEvent({ @@ -164,8 +170,10 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(5); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("resumed output."))).toBe(true); - expect(texts.some((text) => text.includes("codex: resumed output"))).toBe(true); + expect(texts).toEqual(expect.arrayContaining([expect.stringContaining("resumed output.")])); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: resumed output")]), + ); emitAgentEvent({ runId: "run-2", @@ -175,7 +183,9 @@ describe("startAcpSpawnParentStreamRelay", () => { error: "boom", }, }); - expect(collectedTexts().some((text) => text.includes("run failed: boom"))).toBe(true); + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("run failed: boom")]), + ); relay.dispose(); }); @@ -191,8 +201,8 @@ describe("startAcpSpawnParentStreamRelay", () => { }); vi.advanceTimersByTime(1_001); - expect(collectedTexts().some((text) => text.includes("stream relay timed out after 1s"))).toBe( - true, + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("stream relay timed out after 1s")]), ); const before = enqueueSystemEventMock.mock.calls.length; @@ -218,11 +228,15 @@ describe("startAcpSpawnParentStreamRelay", () => { emitStartNotice: false, }); - expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(false); + expect(collectedTexts()).not.toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); relay.notifyStarted(); - expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(true); + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); relay.dispose(); }); @@ -286,7 +300,7 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("codex: hello world"))).toBe(true); + expect(texts).toEqual(expect.arrayContaining([expect.stringContaining("codex: hello world")])); relay.dispose(); }); @@ -311,8 +325,12 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("checking thread context"))).toBe(false); - expect(texts.some((text) => text.includes("post a tight progress reply here"))).toBe(false); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("checking thread context")]), + ); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("post a tight progress reply here")]), + ); relay.dispose(); }); @@ -345,8 +363,12 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("checking thread context"))).toBe(false); - expect(texts.some((text) => text.includes("codex: final answer ready"))).toBe(true); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("checking thread context")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: final answer ready")]), + ); relay.dispose(); }); From e978ec6ff7addcda17a5465fa36644c37b6a45f6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:39:20 +0100 Subject: [PATCH 057/806] test: assert plugin loader diagnostics --- src/plugins/loader.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 49750ee7cdc..6131ac8a9de 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -4326,8 +4326,8 @@ module.exports = { id: "throws-after-import", register() {} };`, registry, message: "api.registerHttpHandler(...) was removed", }); - expect(errors.some((entry) => entry.includes("api.registerHttpHandler(...) was removed"))).toBe( - true, + expect(errors).toEqual( + expect.arrayContaining([expect.stringContaining("api.registerHttpHandler(...) was removed")]), ); }); @@ -6609,7 +6609,9 @@ module.exports = { status: "disabled", error: "not in allowlist", }); - expect(warnings.some((message) => message.includes("plugins.allow is empty"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("plugins.allow is empty")]), + ); expect( warnings.some( (message) => From da770059aea0998fb4ac830233bde5486f97c922 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:39:31 +0100 Subject: [PATCH 058/806] test: clarify gateway status target assertion --- src/commands/gateway-status.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 783c43cbfbf..b677d189a97 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -772,7 +772,8 @@ describe("gateway-status command", () => { const parsed = JSON.parse(runtimeLogs.join("\n")) as Record; const targets = parsed.targets as Array>; - expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); + const targetKinds = targets.map((target) => target.kind); + expect(targetKinds).toContain("sshTunnel"); }); it("uses local TLS target strategy and fingerprint for local loopback probes", async () => { From bc5a4bdb4763656de4ed2bcf0acab425d9d05085 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:39:56 +0100 Subject: [PATCH 059/806] test: assert plugin manifest diagnostics --- src/plugins/manifest-registry.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 26f01a92928..0949a619f0e 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -119,7 +119,9 @@ function expectRegistryDiagnosticContains( registry: ReturnType, fragment: string, ) { - expect(registry.diagnostics.some((diag) => diag.message.includes(fragment))).toBe(true); + expect(registry.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining(fragment)]), + ); } function prepareLinkedManifestFixture(params: { id: string; mode: "symlink" | "hardlink" }): { @@ -1948,8 +1950,8 @@ describe("loadPluginManifestRegistry", () => { }); expect(registry.plugins.some((plugin) => plugin.id === "codex")).toBe(true); - expect(registry.diagnostics.some((diag) => diag.message.includes("requires OpenClaw"))).toBe( - false, + expect(registry.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("requires OpenClaw")]), ); }); From d2d47283409bb3930bdea0ff3476578f7efd6354 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:40:27 +0100 Subject: [PATCH 060/806] test: assert plugin discovery safety diagnostics --- src/plugins/discovery.test.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 96f1ee1eb40..b25f5548ff8 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -263,8 +263,8 @@ function expectCandidateSource( } function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) { - expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe( - true, + expect(diagnostics.map((entry) => entry.message)).toEqual( + expect.arrayContaining([expect.stringContaining("escapes package directory")]), ); } @@ -1671,8 +1671,8 @@ describe("discoverOpenClawPlugins", () => { const result = await discoverWithStateDir(stateDir, {}); expect(result.candidates).toHaveLength(0); - expect(result.diagnostics.some((diag) => diag.message.includes("world-writable path"))).toBe( - true, + expect(result.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining("world-writable path")]), ); }); @@ -1694,11 +1694,14 @@ describe("discoverOpenClawPlugins", () => { ); expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true); - expect( - result.diagnostics.some( - (diag) => diag.source === packDir && diag.message.includes("world-writable path"), - ), - ).toBe(false); + expect(result.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: packDir, + message: expect.stringContaining("world-writable path"), + }), + ]), + ); expect(fs.statSync(packDir).mode & 0o777).toBe(0o755); }, ); @@ -1719,8 +1722,10 @@ describe("discoverOpenClawPlugins", () => { const result = await discoverWithStateDir(stateDir, { ownershipUid: actualUid + 1 }); const shouldBlockForMismatch = actualUid !== 0; expect(result.candidates).toHaveLength(shouldBlockForMismatch ? 0 : 1); - expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe( - shouldBlockForMismatch, + expect(result.diagnostics.map((diag) => diag.message)).toEqual( + shouldBlockForMismatch + ? expect.arrayContaining([expect.stringContaining("suspicious ownership")]) + : expect.not.arrayContaining([expect.stringContaining("suspicious ownership")]), ); if (shouldBlockForMismatch) { expect(result.diagnostics).toContainEqual( From 3299e10ee9d115279699918467e0b96507453caf Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:40:57 +0100 Subject: [PATCH 061/806] test: assert plugin path scan warnings --- src/plugins/install.path.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.path.test.ts b/src/plugins/install.path.test.ts index 2a2445d101a..0399ab887e6 100644 --- a/src/plugins/install.path.test.ts +++ b/src/plugins/install.path.test.ts @@ -232,7 +232,9 @@ describe("installPluginFromPath", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain('Plugin file "payload" installation blocked'); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows plain file installs with dangerous code patterns when forced unsafe install is set", async () => { From 3cf101ff8bbfa528bc23cd997746154fe608c07b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:41:24 +0100 Subject: [PATCH 062/806] test: clarify cron regression job assertion --- src/cron/service.issue-regressions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index cacaa2cfd13..6aae9d4b1b4 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -213,7 +213,8 @@ describe("Cron issue regressions", () => { const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false }); const listed = await cron.list(); - expect(listed.some((job) => job.id === "missing-enabled-update")).toBe(true); + const listedJobIds = listed.map((job) => job.id); + expect(listedJobIds).toContain("missing-enabled-update"); const updated = await cron.update("missing-enabled-update", { schedule: { kind: "cron", expr: "0 */3 * * *", tz: "UTC" }, From a40ef6691ef6f7077885a1f9e9c7099c11b134c7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:41:51 +0100 Subject: [PATCH 063/806] test: assert plugin install scanner warnings --- src/plugins/install.test.ts | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index cab2ade8109..4094500ea6c 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -1142,7 +1142,9 @@ describe("installPluginFromArchive", () => { expect(result.error).toContain('Plugin "dangerous-plugin" installation blocked'); expect(result.error).toContain("dangerous code patterns detected"); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows package installs when dangerous scanner patterns are only in tests", async () => { @@ -1166,7 +1168,9 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("still scans declared package entrypoints when they live under test-looking paths", async () => { @@ -2068,7 +2072,9 @@ describe("installPluginFromArchive", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain('Bundle "dangerous-bundle" installation blocked'); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows bundle installs when dangerous scanner patterns are only in tests", async () => { @@ -2086,7 +2092,9 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => { @@ -2452,7 +2460,9 @@ describe("installPluginFromArchive", () => { extensions: ["index.js"], }, }); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); expect( warnings.some((w) => w.includes("blocked by plugin hook: Blocked by enterprise policy")), ).toBe(true); @@ -2594,8 +2604,12 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(false); - expect(warnings.some((w) => w.includes("hidden/node_modules path"))).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("hidden/node_modules path")]), + ); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("blocks install when scanner throws", async () => { @@ -2809,7 +2823,9 @@ describe("installPluginFromDir", () => { }); expectInstalledWithPluginId(res, extensionsDir, "matrix"); - expect(infoMessages.some((msg) => msg.includes("differs from npm package name"))).toBe(false); + expect(infoMessages).not.toEqual( + expect.arrayContaining([expect.stringContaining("differs from npm package name")]), + ); }); it.each([ @@ -3062,6 +3078,8 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("Could not locate openclaw package root"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("Could not locate openclaw package root")]), + ); }); }); From 3dfe70b8f84f047c14dd612e97bc7fa1a8025503 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:42:22 +0100 Subject: [PATCH 064/806] test: assert npm spec warning suppression --- src/plugins/install.npm-spec.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index af81bb80920..eff92925f69 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -1072,7 +1072,9 @@ describe("installPluginFromNpmSpec", () => { return; } expect(result.pluginId).toBe(pluginId); - expect(warnings.some((warning) => warning.includes("installation blocked"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("installation blocked")]), + ); expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, From 4213d8f4d97e79aaf897aaeddc4208e3af4863b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:42:42 +0100 Subject: [PATCH 065/806] test: clarify cron session reaper assertion --- src/cron/session-reaper.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index bad63ad7871..acbac6dbf7c 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -136,7 +136,10 @@ describe("sweepCronRunSessions", () => { expect(result.pruned).toBe(1); expect(fs.existsSync(runTranscript)).toBe(false); const files = fs.readdirSync(tmpDir); - expect(files.some((name) => name.startsWith(`${runSessionId}.jsonl.deleted.`))).toBe(true); + const archivedRunTranscripts = files.filter((name) => + name.startsWith(`${runSessionId}.jsonl.deleted.`), + ); + expect(archivedRunTranscripts.length).toBeGreaterThan(0); }); it("does not archive external transcript paths for pruned runs", async () => { From f94ca143642e15d9249ef9c6da37503056d8a724 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:42:56 +0100 Subject: [PATCH 066/806] test: assert pi tool policy warnings --- .../pi-embedded-runner/effective-tool-policy.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts index ad56b0ffb06..a9584a3e2d7 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts @@ -126,7 +126,9 @@ describe("applyFinalEffectiveToolPolicy", () => { warn: (message) => warnings.push(message), }); - expect(warnings.some((w) => w.includes("unknown entries"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("unknown entries")]), + ); }); it("still warns on genuinely unknown entries in the bundled pass", () => { @@ -137,7 +139,9 @@ describe("applyFinalEffectiveToolPolicy", () => { warn: (message) => warnings.push(message), }); - expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("totally-made-up-tool")]), + ); }); it("keeps bundle MCP tools in the coding profile via plugin metadata", () => { From 263469f6961792f9d1baf4c60cf061df3439c031 Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Fri, 8 May 2026 02:43:22 -0700 Subject: [PATCH 067/806] fix(cli): canonicalize infer model refs safely (#78940) * fix(cli): canonicalize infer model refs safely * docs: add changelog entry for infer model ref canonicalization --------- Co-authored-by: Mason Huang --- CHANGELOG.md | 1 + src/agents/model-selection.ts | 66 +++++++++++++++++++++++++++ src/cli/capability-cli.test.ts | 83 ++++++++++++++++++++++++++++++++++ src/cli/capability-cli.ts | 25 +++++++++- 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e81ec31108..7f4ce7451a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,7 @@ Docs: https://docs.openclaw.ai - Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16. - CLI/status: keep lean `openclaw status --json` off manifest-backed channel discovery so configured-channel checks do not repeatedly rescan plugin metadata. Fixes #79129. - Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev. +- CLI/infer: canonicalize case-only catalog model refs in `infer model run --model` so mixed-case provider/model strings resolve to the canonical catalog entry instead of failing with `Unknown model`. (#78940) Thanks @ai-hpc. - CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago. - Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments. - Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy. diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 33042429a07..f304c81b036 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,7 +14,9 @@ import { resolveAgentModelFallbacksOverride, } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { findModelInCatalog } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; export { resolveThinkingDefault } from "./model-thinking-default.js"; import { type ModelRef, @@ -236,6 +238,70 @@ export function resolveDefaultModelForAgent(params: { }); } +export async function canonicalizeCaseOnlyCatalogModelRef(params: { + raw: string | undefined; + cfg?: OpenClawConfig; + defaultProvider: string; + loadCatalog: () => Promise; + aliasIndex?: ModelAliasIndex; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; + preserveAuthProfile?: boolean; +}): Promise { + const rawModel = normalizeOptionalString(params.raw); + if (!rawModel) { + return undefined; + } + const split = splitTrailingAuthProfile(rawModel); + if (shouldKeepProfileQualifiedModelRefRaw(split.profile, params.preserveAuthProfile)) { + return rawModel; + } + if (!isCaseOnlyProviderModelRef(split.model)) { + return rawModel; + } + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: split.model, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (!resolved) { + return rawModel; + } + const entry = findModelInCatalog( + await params.loadCatalog(), + resolved.ref.provider, + resolved.ref.model, + ); + return entry ? formatCatalogModelRef(entry, split.profile) : rawModel; +} + +function hasExplicitProviderModelRef(raw: string): boolean { + const slash = raw.indexOf("/"); + return slash > 0 && slash < raw.length - 1; +} + +function isCaseOnlyProviderModelRef(raw: string): boolean { + return hasExplicitProviderModelRef(raw) && raw !== raw.toLowerCase(); +} + +function shouldKeepProfileQualifiedModelRefRaw( + profile: string | undefined, + preserveAuthProfile: boolean | undefined, +): boolean { + return Boolean(profile && preserveAuthProfile === false); +} + +function formatCatalogModelRef(entry: ModelCatalogEntry, profile: string | undefined): string { + return appendAuthProfileSuffix(`${entry.provider}/${entry.id}`, profile); +} + +function appendAuthProfileSuffix(modelRef: string, profile: string | undefined): string { + return profile ? `${modelRef}@${profile}` : modelRef; +} + function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string }): string[] { if (params.agentId) { const override = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 0bc8c1f4bce..edb2b81c94f 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -154,6 +154,9 @@ vi.mock("../config/config.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", resolveAgentDir: () => "/tmp/agent", + resolveAgentConfig: () => ({}), + resolveAgentEffectiveModelPrimary: () => undefined, + resolveAgentModelFallbacksOverride: () => [], })); vi.mock("../agents/model-catalog.js", () => ({ @@ -374,6 +377,42 @@ describe("capability cli", () => { }) as never); }); + async function runModelRunWithModel(model: string, transport: "local" | "gateway") { + await runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: [ + "capability", + "model", + "run", + "--model", + model, + "--prompt", + "hello", + ...(transport === "gateway" ? ["--gateway"] : []), + "--json", + ], + }); + } + + function expectModelRunDispatch(transport: "local" | "gateway", modelRef: string) { + if (transport === "gateway") { + const slash = modelRef.indexOf("/"); + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agent", + params: expect.objectContaining({ + provider: modelRef.slice(0, slash), + model: modelRef.slice(slash + 1), + }), + }), + ); + return; + } + expect(mocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ modelRef }), + ); + } + it("lists canonical capabilities", async () => { await runRegisteredCli({ register: registerCapabilityCli as (program: Command) => void, @@ -841,6 +880,50 @@ describe("capability cli", () => { ); }); + it.each(["local", "gateway"] as const)( + "canonicalizes case-only catalog model refs before %s dispatch", + async (transport) => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7", transport); + + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ); + expectModelRunDispatch(transport, "anthropic/claude-opus-4-7"); + }, + ); + + it("canonicalizes case-only catalog refs and preserves auth profiles before local dispatch", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7@work", "local"); + + expectModelRunDispatch("local", "anthropic/claude-opus-4-7@work"); + }); + + it("leaves auth profile refs unchanged before gateway dispatch", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7@work", "gateway"); + + expectModelRunDispatch("gateway", "Anthropic/CLAUDE-OPUS-4-7@work"); + }); + + it("preserves custom mixed-case profile refs before local dispatch when the catalog has no match", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([] as never); + + await runModelRunWithModel("custom/MyModel@work", "local"); + + expectModelRunDispatch("local", "custom/MyModel@work"); + }); + it("rejects empty model run prompts before gateway dispatch", async () => { await expect( runRegisteredCli({ diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 3f8a94270dc..ee84dd17c3a 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -10,8 +10,10 @@ import { loadAuthProfileStoreForRuntime, } from "../agents/auth-profiles.js"; import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import { canonicalizeCaseOnlyCatalogModelRef } from "../agents/model-selection.js"; import { completeWithPreparedSimpleCompletionModel, prepareSimpleCompletionModelForAgent, @@ -559,6 +561,20 @@ function resolveModelRefOverride(raw: string | undefined): { provider?: string; }; } +async function canonicalizeModelRunRef(params: { + raw: string | undefined; + cfg: OpenClawConfig; + preserveAuthProfile: boolean; +}): Promise { + return await canonicalizeCaseOnlyCatalogModelRef({ + cfg: params.cfg, + raw: params.raw, + defaultProvider: DEFAULT_PROVIDER, + loadCatalog: () => loadModelCatalog({ config: params.cfg, readOnly: true }), + preserveAuthProfile: params.preserveAuthProfile, + }); +} + function requireProviderModelOverride( raw: string | undefined, ): { provider: string; model: string } | undefined { @@ -642,6 +658,11 @@ async function runModelRun(params: { }) { const cfg = getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); + const modelRef = await canonicalizeModelRunRef({ + raw: params.model, + cfg, + preserveAuthProfile: params.transport === "local", + }); const imageFiles = await readModelRunImageFiles(params.files); const messageContent = imageFiles.length > 0 @@ -658,7 +679,7 @@ async function runModelRun(params: { const prepared = await prepareSimpleCompletionModelForAgent({ cfg, agentId, - modelRef: params.model, + modelRef, allowMissingApiKeyModes: ["aws-sdk"], skipPiDiscovery: true, }); @@ -731,7 +752,7 @@ async function runModelRun(params: { } satisfies CapabilityEnvelope; } - const { provider, model } = resolveModelRefOverride(params.model); + const { provider, model } = resolveModelRefOverride(modelRef); // Provider/model overrides require trusted-operator scope. Use the backend // shared-secret lane so local gateway smokes do not depend on paired CLI device scopes. const hasModelOverride = Boolean(provider || model); From 427542532c6154b722dfaa194ac42942d53e7001 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:43:31 +0100 Subject: [PATCH 068/806] test: assert pi error payload suppression --- .../run/payloads.errors.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts index 896ccc30c81..98487561ced 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts @@ -77,7 +77,7 @@ describe("buildEmbeddedRunPayloads", () => { expectOverloadedFallback(payloads); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text === errorJson)).toBe(false); + expect(payloads.map((payload) => payload.text)).not.toContain(errorJson); }); it("suppresses mutating tool warnings when an assistant error reply already covers the turn", () => { @@ -90,8 +90,12 @@ describe("buildEmbeddedRunPayloads", () => { expectOverloadedFallback(payloads); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text?.includes("Edit"))).toBe(false); - expect(payloads.some((payload) => payload.text?.includes("missing"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("Edit")]), + ); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("missing")]), + ); }); it("keeps mutating tool warnings when assistant error artifacts are not user-facing", () => { @@ -118,7 +122,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectOverloadedFallback(payloads); - expect(payloads.some((payload) => payload.text === errorJsonPretty)).toBe(false); + expect(payloads.map((payload) => payload.text)).not.toContain(errorJsonPretty); }); it("suppresses raw error JSON from fallback assistant text", () => { @@ -127,7 +131,9 @@ describe("buildEmbeddedRunPayloads", () => { }); expectOverloadedFallback(payloads); - expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("request_id")]), + ); }); it("surfaces OpenAI model capacity errors instead of generic empty-response copy", () => { @@ -181,7 +187,9 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads).toHaveLength(1); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("request_id")]), + ); }); it("does not suppress error-shaped JSON when the assistant did not error", () => { From d929aa6cf18ee84ea289a8de60de407bf15246fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:43:50 +0100 Subject: [PATCH 069/806] test: clarify session pruning archive assertions --- src/config/sessions/store.pruning.integration.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 431d8e90376..19a124132db 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -395,7 +395,10 @@ describe("Integration: saveSessionStore with pruning", () => { expect(persisted["agent:main:telegram:direct:6101296751"]).toBeUndefined(); await expect(fs.stat(directTranscript)).rejects.toThrow(); const files = await fs.readdir(testDir); - expect(files.some((name) => name.startsWith("direct-session.jsonl.deleted."))).toBe(true); + const archivedDirectTranscripts = files.filter((name) => + name.startsWith("direct-session.jsonl.deleted."), + ); + expect(archivedDirectTranscripts.length).toBeGreaterThan(0); }); it("sessions cleanup dry-run does not double-count artifacts already covered by disk budget", async () => { @@ -867,7 +870,10 @@ describe("Integration: saveSessionStore with pruning", () => { await expect(fs.stat(oldestTranscript)).rejects.toThrow(); await expectPathExists(newestTranscript); const files = await fs.readdir(testDir); - expect(files.some((name) => name.startsWith(`${oldestSessionId}.jsonl.deleted.`))).toBe(true); + const archivedOldestTranscripts = files.filter((name) => + name.startsWith(`${oldestSessionId}.jsonl.deleted.`), + ); + expect(archivedOldestTranscripts.length).toBeGreaterThan(0); }); it("does not archive external transcript paths when capping entries", async () => { From 9c471637d13d6369f6e9188713a36a8b9c0f0d95 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:44:24 +0100 Subject: [PATCH 070/806] test: assert matrix group history messages --- .../monitor/handler.group-history.test.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts index c3c7f1198e6..613a7b49f2b 100644 --- a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts @@ -199,8 +199,12 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }>; expect(history).toHaveLength(2); - expect(history.map((h) => h.body).some((b) => b.includes("msg A"))).toBe(true); - expect(history.map((h) => h.body).some((b) => b.includes("msg B"))).toBe(true); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg A")]), + ); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg B")]), + ); } // @agent_b trigger D — A/B/C consumed; history is empty @@ -393,7 +397,9 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { expect(finalizeInboundContext).toHaveBeenCalledOnce(); const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("[matrix image attachment]"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("[matrix image attachment]")]), + ); }); it("includes skipped poll updates in next trigger history", async () => { @@ -458,7 +464,9 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { expect(getRelations).toHaveBeenCalledOnce(); const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("Lunch?"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("Lunch?")]), + ); }); }); @@ -511,8 +519,12 @@ describe("matrix group chat history — scenario 2: race condition safety", () = expect(finalizeInboundContext).toHaveBeenCalledTimes(2); const ctxForC = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctxForC["InboundHistory"] as Array<{ body: string }>; - expect(history.some((h) => h.body.includes("msg B"))).toBe(true); - expect(history.every((h) => !h.body.includes("msg A"))).toBe(true); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg B")]), + ); + expect(history.map((h) => h.body)).not.toEqual( + expect.arrayContaining([expect.stringContaining("msg A")]), + ); }); it("watermark does not advance when final reply delivery fails (retry sees same history)", async () => { @@ -546,7 +558,9 @@ describe("matrix group chat history — scenario 2: race condition safety", () = { const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((h) => h.body.includes("pending msg"))).toBe(true); + expect(history?.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("pending msg")]), + ); } }); @@ -624,7 +638,9 @@ describe("matrix group chat history — scenario 2: race condition safety", () = const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("plain before trigger"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("plain before trigger")]), + ); }); it("preserves arrival order when a plain message starts before a later trigger", async () => { From 82aef467b33484a6b5376a2564482e2588ec0ff2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:45:10 +0100 Subject: [PATCH 071/806] test: assert matrix sas notice messages --- .../matrix/src/matrix/monitor/events.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 7916ef80b9c..24e8250b9e8 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -593,8 +593,10 @@ describe("registerMatrixMonitorEvents verification routing", () => { await flushTasks(); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); - expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + expect(bodies).toEqual(expect.arrayContaining([expect.stringContaining("SAS emoji:")])); + expect(bodies).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); }); it("rehydrates an in-progress DM verification before resolving SAS notices", async () => { @@ -961,7 +963,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { await vi.waitFor(() => { const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + expect(bodies).toEqual(expect.arrayContaining([expect.stringContaining("SAS emoji:")])); }); } finally { vi.useRealTimers(); @@ -1215,11 +1217,13 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await flushTasks(); - expect( - getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 6158 1986 3513")), - ).toBe(true); + expect(getSentNoticeBodies(sendMessage)).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + expect(bodies).not.toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 1111 2222 3333")]), + ); }); it("preserves strict-room SAS fallback when active DM inspection cannot resolve a room", async () => { @@ -1321,11 +1325,13 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await flushTasks(); - expect( - getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 6158 1986 3513")), - ).toBe(true); + expect(getSentNoticeBodies(sendMessage)).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + expect(bodies).not.toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 1111 2222 3333")]), + ); }); it("does not emit SAS notices for cancelled verification events", async () => { From 3de1de8bb8710146a31725126bfe64c861e197bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:45:21 +0100 Subject: [PATCH 072/806] test: clarify doctor state archive assertions --- src/commands/doctor-state-integrity.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index d09e08bd0b9..f934bad29e7 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -379,7 +379,10 @@ describe("doctor state integrity oauth dir checks", () => { }), ); const files = fs.readdirSync(sessionsDir); - expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true); + const archivedOrphanTranscripts = files.filter((name) => + name.startsWith("orphan-session.jsonl.deleted."), + ); + expect(archivedOrphanTranscripts.length).toBeGreaterThan(0); }); it("does not auto-archive orphan transcripts from non-interactive repair mode", async () => { @@ -401,7 +404,10 @@ describe("doctor state integrity oauth dir checks", () => { ); const files = fs.readdirSync(sessionsDir); expect(files).toContain("orphan-session.jsonl"); - expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(false); + const archivedOrphanTranscripts = files.filter((name) => + name.startsWith("orphan-session.jsonl.deleted."), + ); + expect(archivedOrphanTranscripts).toEqual([]); }); it.skipIf(process.platform === "win32")( From 66ffac40e7f22cd357584ac91a7311b154773a37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:46:32 +0100 Subject: [PATCH 073/806] test: clarify backup archive assertions --- src/infra/backup-create.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 8a8dfd6ccda..05186565a74 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -175,18 +175,18 @@ describe("createBackupArchive", () => { }); const entries = await listArchiveEntries(result.archivePath); - expect( - entries.some((entry) => entry.endsWith("/state/extensions/demo/openclaw.plugin.json")), - ).toBe(true); - expect(entries.some((entry) => entry.endsWith("/state/extensions/demo/src/index.js"))).toBe( - true, + const entrySuffixes = entries.map((entry) => entry.replace(/^.*\/state\//, "/state/")); + expect(entrySuffixes).toEqual( + expect.arrayContaining([ + "/state/extensions/demo/openclaw.plugin.json", + "/state/extensions/demo/src/index.js", + "/state/node_modules/root-dep/index.js", + ]), ); - expect( - entries.some((entry) => entry.endsWith("/state/node_modules/root-dep/index.js")), - ).toBe(true); - expect( - entries.some((entry) => entry.includes("/state/extensions/demo/node_modules/")), - ).toBe(false); + const pluginNodeModuleEntries = entries.filter((entry) => + entry.includes("/state/extensions/demo/node_modules/"), + ); + expect(pluginNodeModuleEntries).toEqual([]); const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; await expect( From 52b7e8598b838afa94ef922729e67d8ce837e17c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:46:47 +0100 Subject: [PATCH 074/806] test: assert active memory debug logs --- extensions/active-memory/index.test.ts | 40 +++++++++++++++----------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index d729e018b46..7a17dbc5596 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -117,6 +117,12 @@ describe("active-memory plugin", () => { | undefined; return entries?.find((entry) => entry.pluginId === "active-memory")?.lines ?? []; }; + const expectLinesToContain = (lines: string[], text: string) => { + expect(lines).toEqual(expect.arrayContaining([expect.stringContaining(text)])); + }; + const expectLinesNotToContain = (lines: string[], text: string) => { + expect(lines).not.toEqual(expect.arrayContaining([expect.stringContaining(text)])); + }; const writeTranscriptJsonl = async (sessionFile: string, records: unknown[], suffix = "\n") => { await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile( @@ -2122,7 +2128,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); }); it("keeps timeout status when the timeout transcript path does not exist", async () => { @@ -2152,7 +2158,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); }); it("does not inject embedded timeout boilerplate from partial transcripts", async () => { @@ -2197,8 +2203,8 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); - expect(lines.some((line) => line.includes("LLM request timed out"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); + expectLinesNotToContain(lines, "LLM request timed out"); }); it("returns partial transcript text when an aborted subagent rejects before the race timeout wins", async () => { @@ -2521,7 +2527,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false); + expectLinesNotToContain(infoLines, " cached "); }); it("does not share cached recall results across session-id-only contexts", async () => { @@ -2554,7 +2560,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false); + expectLinesNotToContain(infoLines, " cached "); }); it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => { @@ -2589,7 +2595,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + expectLinesToContain(infoLines, "status=timeout"); expect( infoLines.some( (line: string) => @@ -2632,7 +2638,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(false); + expectLinesNotToContain(infoLines, "status=timeout"); }); it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => { @@ -2669,7 +2675,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + expectLinesToContain(infoLines, "status=timeout"); // Hard deadline: wall-clock time must be near timeoutMs, not 30s. expect(wallClockMs).toBeLessThan(CONFIGURED_TIMEOUT_MS + HARD_DEADLINE_MARGIN_MS); }); @@ -2710,8 +2716,8 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("done status=empty"))).toBe(true); - expect(infoLines.some((line: string) => line.includes("done status=timeout"))).toBe(false); + expectLinesToContain(infoLines, "done status=empty"); + expectLinesNotToContain(infoLines, "done status=timeout"); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), expect.stringContaining("🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"), @@ -2802,8 +2808,8 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("done status=empty"))).toBe(true); - expect(infoLines.some((line: string) => line.includes("done status=timeout"))).toBe(false); + expectLinesToContain(infoLines, "done status=empty"); + expectLinesNotToContain(infoLines, "done status=timeout"); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), expect.stringContaining( @@ -2862,7 +2868,7 @@ describe("active-memory plugin", () => { const warnLines = vi .mocked(api.logger.warn) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(warnLines.some((line: string) => line.includes("before_prompt_build"))).toBe(true); + expectLinesToContain(warnLines, "before_prompt_build"); }); it("honors configured timeoutMs values above the former 60 000 ms ceiling", async () => { @@ -3733,8 +3739,8 @@ describe("active-memory plugin", () => { const lines = (store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0] ?.lines ?? []; - expect(lines.some((line) => line.includes("\u001b"))).toBe(false); - expect(lines.some((line) => line.includes("\r"))).toBe(false); + expectLinesNotToContain(lines, "\u001b"); + expectLinesNotToContain(lines, "\r"); }); it("caps the active-memory cache size and evicts the oldest entries", () => { @@ -3829,7 +3835,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("circuit breaker open"))).toBe(true); + expectLinesToContain(infoLines, "circuit breaker open"); }); it("resets circuit breaker after a successful recall", async () => { From 06d34c5e5f11e1173a7e1d925d595ff026ecfcde Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:47:31 +0100 Subject: [PATCH 075/806] test: assert sandbox mutation helper script --- src/agents/sandbox/fs-bridge.backend.e2e.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/fs-bridge.backend.e2e.test.ts b/src/agents/sandbox/fs-bridge.backend.e2e.test.ts index f458223b176..8dde9c6356d 100644 --- a/src/agents/sandbox/fs-bridge.backend.e2e.test.ts +++ b/src/agents/sandbox/fs-bridge.backend.e2e.test.ts @@ -116,7 +116,9 @@ describe("sandbox fs bridge local backend e2e", () => { await expect( fs.readFile(path.join(workspaceDir, "nested", "hello.txt"), "utf8"), ).resolves.toBe("from-backend"); - expect(scripts.some((script) => script.includes("operation = sys.argv[1]"))).toBe(true); + expect(scripts).toEqual( + expect.arrayContaining([expect.stringContaining("operation = sys.argv[1]")]), + ); } finally { await fs.rm(stateDir, { recursive: true, force: true }); } From d21a9cf4f0c6cb29f85b2ba9a91c636dcd4023bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:47:51 +0100 Subject: [PATCH 076/806] test: clarify table wrapping assertion --- src/terminal/table.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index dae19b0faaf..8926da88958 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -255,7 +255,8 @@ describe("wrapNoteMessage", () => { const lines = wrapped.split("\n"); expect(lines.length).toBeGreaterThan(1); expect(lines[0]?.startsWith("- ")).toBe(true); - expect(lines.slice(1).every((line) => line.startsWith(" "))).toBe(true); + const unindentedContinuationLines = lines.slice(1).filter((line) => !line.startsWith(" ")); + expect(unindentedContinuationLines).toEqual([]); }); it("preserves long Windows paths without inserting spaces/newlines", () => { From 0ddfaff5a488fb209d425e800f7d406b5e9d9529 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:49:01 +0100 Subject: [PATCH 077/806] test: clarify plugin state probe assertions --- src/plugin-state/plugin-state-store.e2e.test.ts | 3 ++- src/plugin-state/plugin-state-store.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plugin-state/plugin-state-store.e2e.test.ts b/src/plugin-state/plugin-state-store.e2e.test.ts index d5dcb1d9a1d..80b14d8c026 100644 --- a/src/plugin-state/plugin-state-store.e2e.test.ts +++ b/src/plugin-state/plugin-state-store.e2e.test.ts @@ -264,7 +264,8 @@ describe("failure safety", () => { expect(result.ok).toBe(true); expect(result.dbPath).toContain("state.sqlite"); expect(result.steps.length).toBeGreaterThanOrEqual(4); - expect(result.steps.every((s) => s.ok)).toBe(true); + const failedSteps = result.steps.filter((step) => !step.ok); + expect(failedSteps).toEqual([]); // The probe's temporary stored value must not leak into the result. const serialised = JSON.stringify(result); diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index 301058a55a9..a613fc93908 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -465,7 +465,8 @@ describe("plugin state keyed store", () => { await withOpenClawTestState({ label: "plugin-state-probe" }, async () => { const result = probePluginStateStore(); expect(result.ok).toBe(true); - expect(result.steps.every((step) => step.ok)).toBe(true); + const failedSteps = result.steps.filter((step) => !step.ok); + expect(failedSteps).toEqual([]); expect(JSON.stringify(result)).not.toContain("probe-value"); }); }); From 2175a0fa662bf509342abd9e7c41a76cf448ca9b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:49:04 +0100 Subject: [PATCH 078/806] test: assert crestodian rescue audit entry --- src/crestodian/rescue-channel.live.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/crestodian/rescue-channel.live.test.ts b/src/crestodian/rescue-channel.live.test.ts index b81ec4e0c20..b0a10b9f665 100644 --- a/src/crestodian/rescue-channel.live.test.ts +++ b/src/crestodian/rescue-channel.live.test.ts @@ -101,8 +101,8 @@ describeLive("Crestodian live rescue channel smoke", () => { expect(config.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" }); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); const auditLines = (await fs.readFile(auditPath, "utf8")).trim().split("\n"); - expect(auditLines.some((line) => line.includes('"operation":"config.setDefaultModel"'))).toBe( - true, + expect(auditLines).toEqual( + expect.arrayContaining([expect.stringContaining('"operation":"config.setDefaultModel"')]), ); }); }); From 0da9f7e88d6f68aa09599d65925012d1f9a6d986 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:50:26 +0100 Subject: [PATCH 079/806] test: clarify delivery recovery retry assertion --- src/infra/outbound/delivery-queue.recovery.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index 24955b7ea99..fe1c4b178d9 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -655,7 +655,8 @@ describe("delivery-queue recovery", () => { const remaining = await loadPendingDeliveries(tmpDir()); expect(remaining).toHaveLength(3); - expect(remaining.every((entry) => entry.retryCount === 1)).toBe(true); + const entriesWithUnexpectedRetryCount = remaining.filter((entry) => entry.retryCount !== 1); + expect(entriesWithUnexpectedRetryCount).toEqual([]); expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next startup")); }); From 0ae3c84790445eff863da84ac358451148c4f4f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:52:57 +0100 Subject: [PATCH 080/806] test: clarify command e2e assertions --- ...t-sandbox-docker-browser-prune.e2e.test.ts | 28 +++++++++---------- src/commands/onboard-channels.e2e.test.ts | 3 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts index 982cac36e53..cad0c7f536d 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts @@ -44,18 +44,17 @@ describe("doctor command", () => { await doctorCommand(createDoctorRuntime(), { nonInteractive: true }); - expect( - terminalNoteMock.mock.calls.some(([message, title]) => { - if (title !== "Sandbox" || typeof message !== "string") { - return false; - } - const normalized = message.replace(/\s+/g, " ").trim(); - return ( - normalized.includes('agents.list (id "work") sandbox docker') && - normalized.includes('scope resolves to "shared"') - ); - }), - ).toBe(true); + const matchingSandboxNotes = terminalNoteMock.mock.calls.filter(([message, title]) => { + if (title !== "Sandbox" || typeof message !== "string") { + return false; + } + const normalized = message.replace(/\s+/g, " ").trim(); + return ( + normalized.includes('agents.list (id "work") sandbox docker') && + normalized.includes('scope resolves to "shared"') + ); + }); + expect(matchingSandboxNotes.length).toBeGreaterThan(0); }, 30_000); it("does not warn when only the active workspace is present", async () => { @@ -82,9 +81,8 @@ describe("doctor command", () => { await doctorCommand(createDoctorRuntime(), { nonInteractive: true }); - expect(terminalNoteMock.mock.calls.some(([_, title]) => title === "Extra workspace")).toBe( - false, - ); + const noteTitles = terminalNoteMock.mock.calls.map(([_, title]) => title); + expect(noteTitles).not.toContain("Extra workspace"); homedirSpy.mockRestore(); existsSpy.mockRestore(); diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index fc7f1f2bf07..36ce0c9e84b 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -748,7 +748,8 @@ describe("setupChannels", () => { expect(entries.find((entry) => entry.value === "external-chat")?.label).toBe( "Healthy Chat", ); - expect(entries.some((entry) => entry.value === "broken-channel")).toBe(false); + const entryValues = entries.map((entry) => entry.value); + expect(entryValues).not.toContain("broken-channel"); return "__done__"; } return "__done__"; From 8159efadf7b00a4c6ee350b935ea1a67bcf40126 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:53:07 +0100 Subject: [PATCH 081/806] test: assert memory watcher concrete paths --- .../memory-core/src/memory/manager.watcher-config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 1c909ede938..de1ce6855fe 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -173,7 +173,7 @@ describe("memory watcher config", () => { extraDir, ]), ); - expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true); + expect(watchedPaths).not.toContainEqual(expect.stringContaining("*")); expect(options.ignoreInitial).toBe(true); expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 }); @@ -225,7 +225,7 @@ describe("memory watcher config", () => { expect(watchedPaths).toEqual( expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]), ); - expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true); + expect(watchedPaths).not.toContainEqual(expect.stringContaining("*")); const ignored = options.ignored as WatchIgnoredFn | undefined; expect(ignored).toBeTypeOf("function"); From 1e6a674cfa93e0e51c9f852809271ad36c6c1b21 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:54:16 +0100 Subject: [PATCH 082/806] test: assert teams bot framework audience --- extensions/msteams/src/sdk.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 484dffb116b..7bed1443298 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -282,7 +282,7 @@ describe("createBotFrameworkJwtValidator", () => { await expect(validator.validate("Bearer botfw-token")).resolves.toBe(true); const opts = jwtState.verifyCalls[0]?.options as Record; - expect((opts.audience as string[]).includes("https://api.botframework.com")).toBe(true); + expect(opts.audience).toContain("https://api.botframework.com"); }); it("accepts global audience tokens when azp matches the configured app id", async () => { From 0faa729eecab72df1708054cc790795dd77c7a6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:54:55 +0100 Subject: [PATCH 083/806] test: clarify wizard note assertions --- src/wizard/setup.test.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 127bea1d6a8..d543a398735 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -619,7 +619,8 @@ describe("runSetupWizard", () => { const calls = getWizardNoteCalls(note); expect(calls.length).toBeGreaterThan(0); - expect(calls.some((call) => call?.[1] === "Web search")).toBe(true); + const noteTitles = calls.map((call) => call?.[1]); + expect(noteTitles).toContain("Web search"); } finally { if (prevBraveKey === undefined) { delete process.env.BRAVE_API_KEY; @@ -843,13 +844,13 @@ describe("runSetupWizard", () => { ); const calls = getWizardNoteCalls(note); - expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true); - expect( - calls.some((call) => { - const body = call?.[0]; - return typeof body === "string" && body.includes("legacy-plugin"); - }), - ).toBe(true); + const noteTitles = calls.map((call) => call?.[1]); + expect(noteTitles).toContain("Plugin compatibility"); + const noteBodies = calls + .map((call) => call?.[0]) + .filter((body): body is string => typeof body === "string"); + const legacyPluginNotes = noteBodies.filter((body) => body.includes("legacy-plugin")); + expect(legacyPluginNotes.length).toBeGreaterThan(0); }); it("resolves gateway.auth.password SecretRef for local setup probe", async () => { @@ -983,14 +984,13 @@ describe("runSetupWizard", () => { } const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect( - calls.some( - (call) => - call?.[1] === "QuickStart" && - typeof call?.[0] === "string" && - call[0].includes("Gateway port: 18791"), - ), - ).toBe(true); + const matchingQuickStartNotes = calls.filter( + (call) => + call?.[1] === "QuickStart" && + typeof call?.[0] === "string" && + call[0].includes("Gateway port: 18791"), + ); + expect(matchingQuickStartNotes.length).toBeGreaterThan(0); }); it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { From 09e471f32e25370ebc483224de377fe0f60f6528 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:55:22 +0100 Subject: [PATCH 084/806] test: assert bundled sdk import guards --- src/channels/plugins/bundled.shape-guard.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 01b8cee6b0f..b84640e3520 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -784,8 +784,8 @@ describe("bundled channel entry shape guards", () => { it("keeps plugin-sdk channel-core free of chat metadata bootstrap imports", () => { const source = fs.readFileSync(path.resolve("src/plugin-sdk/channel-core.ts"), "utf8"); - expect(source.includes("../channels/chat-meta.js")).toBe(false); - expect(source.includes("getChatChannelMeta")).toBe(false); + expect(source).not.toContain("../channels/chat-meta.js"); + expect(source).not.toContain("getChatChannelMeta"); }); it("keeps bundled hot runtime barrels off the broad core SDK surface", () => { @@ -816,7 +816,7 @@ describe("bundled channel entry shape guards", () => { it("keeps extension-shared off the broad runtime barrel", () => { const source = fs.readFileSync(path.resolve("src/plugin-sdk/extension-shared.ts"), "utf8"); - expect(source.includes('from "./runtime.js"')).toBe(false); + expect(source).not.toContain('from "./runtime.js"'); }); it("keeps bundled doctor surfaces off the broad runtime barrel", () => { From cf30d620f4e96608b1f34375731fe9a2348f75d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:56:09 +0100 Subject: [PATCH 085/806] test: clarify acp runtime mode assertion --- src/acp/control-plane/manager.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index ab8c14ecca0..6f02d1c979c 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -2433,9 +2433,10 @@ describe("AcpSessionManager", () => { }), ); expect(options.runtimeMode).toBe("plan"); - expect(extractRuntimeOptionsFromUpserts().some((entry) => entry?.runtimeMode === "plan")).toBe( - true, + const persistedRuntimeModes = extractRuntimeOptionsFromUpserts().map( + (entry) => entry?.runtimeMode, ); + expect(persistedRuntimeModes).toContain("plan"); }); it("reapplies persisted controls on next turn after runtime option updates", async () => { From e132e3a5391c2ac0782e8080a710b02a9de7b238 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:57:27 +0100 Subject: [PATCH 086/806] test: clarify daemon path assertion --- src/daemon/service-env.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index d20e8ad89f7..3a709063304 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -122,7 +122,8 @@ describe("getMinimalServicePathParts - Linux user directories", () => { "/usr/sbin", "/sbin", ]); - expect(result.some((entry) => entry.startsWith("/Users/testuser/"))).toBe(false); + const userPathEntries = result.filter((entry) => entry.startsWith("/Users/testuser/")); + expect(userPathEntries).toEqual([]); }); it("can include env-configured version manager dirs on macOS when requested", () => { From 0dc6d3de9fa4e9e85ab2c6593ab62702f7cff447 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:58:18 +0100 Subject: [PATCH 087/806] test: assert cron protocol fixtures --- src/cron/cron-protocol-conformance.test.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index ba069b17013..000d3be929d 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -69,9 +69,7 @@ describe("cron protocol conformance", () => { for (const relPath of UI_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); for (const mode of modes) { - expect(content.includes(`"${mode}"`), `${relPath} missing delivery mode ${mode}`).toBe( - true, - ); + expect(content, `${relPath} missing delivery mode ${mode}`).toContain(`"${mode}"`); } } @@ -80,7 +78,7 @@ describe("cron protocol conformance", () => { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); for (const mode of modes) { const pattern = new RegExp(`\\bcase\\s+${mode}\\b`); - expect(pattern.test(content), `${relPath} missing case ${mode}`).toBe(true); + expect(content, `${relPath} missing case ${mode}`).toMatch(pattern); } } }); @@ -88,15 +86,15 @@ describe("cron protocol conformance", () => { it("cron status shape matches gateway fields in UI + Swift", async () => { const cwd = process.cwd(); const uiTypes = await fs.readFile(path.join(cwd, "ui/src/ui/types.ts"), "utf-8"); - expect(uiTypes.includes("export type CronStatus")).toBe(true); - expect(uiTypes.includes("jobs:")).toBe(true); - expect(uiTypes.includes("jobCount")).toBe(false); + expect(uiTypes).toContain("export type CronStatus"); + expect(uiTypes).toContain("jobs:"); + expect(uiTypes).not.toContain("jobCount"); const [swiftRelPath] = await resolveSwiftFiles(cwd, SWIFT_STATUS_CANDIDATES); const swiftPath = path.join(cwd, swiftRelPath); const swift = await fs.readFile(swiftPath, "utf-8"); - expect(swift.includes("struct CronSchedulerStatus")).toBe(true); - expect(swift.includes("let jobs:")).toBe(true); + expect(swift).toContain("struct CronSchedulerStatus"); + expect(swift).toContain("let jobs:"); }); it("cron job state schema keeps the full failover reason set", () => { From dd271968df0a1f384fd40172edbaad712fc2dd85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:58:46 +0100 Subject: [PATCH 088/806] test: clarify docker setup line assertions --- src/docker-setup.e2e.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index d317f4ed0f4..5f69cbeda40 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -274,9 +274,10 @@ describe("scripts/docker/setup.sh", () => { expect(gatewayStartIdx).toBeGreaterThanOrEqual(0); const prestartLines = lines.slice(0, gatewayStartIdx); - expect(prestartLines.some((line) => /\bcompose\b.*\brun\b.*\bopenclaw-cli\b/.test(line))).toBe( - false, + const prestartCliRunLines = prestartLines.filter((line) => + /\bcompose\b.*\brun\b.*\bopenclaw-cli\b/.test(line), ); + expect(prestartCliRunLines).toEqual([]); }); it("forces BuildKit for local and sandbox docker builds", async () => { @@ -297,7 +298,10 @@ describe("scripts/docker/setup.sh", () => { line.startsWith("build "), ); expect(buildLines.length).toBeGreaterThanOrEqual(2); - expect(buildLines.every((line) => line.includes("DOCKER_BUILDKIT=1"))).toBe(true); + const buildLinesWithoutBuildKit = buildLines.filter( + (line) => !line.includes("DOCKER_BUILDKIT=1"), + ); + expect(buildLinesWithoutBuildKit).toEqual([]); }); it("precreates config identity dir for CLI device auth writes", async () => { From c55fa0ace7757b4a1694c1d34879321516577f0f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:59:27 +0100 Subject: [PATCH 089/806] test: assert fallback cooldown suspension --- src/agents/model-fallback.probe.test.ts | 29 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index ed93a4303ab..1cb40d35e31 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -73,6 +73,7 @@ let setLoggerOverride: LoggerModule["setLoggerOverride"]; const makeCfg = makeModelFallbackCfg; let cleanupLogCapture: (() => void) | undefined; +const OPENAI_PROBE_CANDIDATE = { provider: "openai", model: "gpt-4.1-mini" } as const; async function loadModelFallbackProbeModules() { const authProfilesStoreModule = await import("./auth-profiles/store.js"); @@ -209,7 +210,7 @@ describe("runWithModelFallback – probe logic", () => { mockedGetSoonestCooldownExpiry.mockReturnValue(params.soonest); mockedResolveProfilesUnavailableReason.mockReturnValue(params.reason); return modelFallbackTesting.resolveCooldownDecision({ - candidate: { provider: "openai", model: "gpt-4.1-mini" }, + candidate: OPENAI_PROBE_CANDIDATE, isPrimary: params.isPrimary ?? true, requestedModel: params.requestedModel ?? true, hasFallbackCandidates: params.hasFallbackCandidates ?? true, @@ -226,6 +227,17 @@ describe("runWithModelFallback – probe logic", () => { }); } + function expectOpenAiProbeSuspension( + decision: ReturnType, + reason: "rate_limit" | "billing", + ) { + expect(decision).toEqual({ + type: "suspend_lanes", + reason, + leaderCandidate: OPENAI_PROBE_CANDIDATE, + }); + } + async function expectPrimarySkippedAfterLongCooldown(reason: "billing" | "rate_limit") { const cfg = makeCfg(); const expiresIn30Min = NOW + 30 * 60 * 1000; @@ -323,13 +335,14 @@ describe("runWithModelFallback – probe logic", () => { ).toEqual({ type: "attempt", reason: "rate_limit", markProbe: true }); _probeThrottleInternals.lastProbeAttempt.set("recent-openai", NOW - 10_000); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "rate_limit", soonest: NOW + 30 * 1000, throttleKey: "recent-openai", }), - ).toMatchObject({ type: "skip", reason: "rate_limit" }); + "rate_limit", + ); }); it("logs primary metadata on probe success and failure fallback decisions", async () => { @@ -633,13 +646,14 @@ describe("runWithModelFallback – probe logic", () => { const agentBKey = _probeThrottleInternals.resolveProbeThrottleKey("openai", "/tmp/agent-b"); _probeThrottleInternals.lastProbeAttempt.set(agentAKey, NOW - 10_000); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "rate_limit", soonest: NOW + 30 * 1000, throttleKey: agentAKey, }), - ).toMatchObject({ type: "skip", reason: "rate_limit" }); + "rate_limit", + ); expect( resolveOpenAiCooldownDecision({ reason: "rate_limit", @@ -666,11 +680,12 @@ describe("runWithModelFallback – probe logic", () => { soonest: NOW + 60 * 1000, }), ).toEqual({ type: "attempt", reason: "billing", markProbe: true }); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "billing", soonest: NOW + 30 * 60 * 1000, }), - ).toMatchObject({ type: "skip", reason: "billing" }); + "billing", + ); }); }); From d11fb8515222f6273d78e2787ba5a0032b67e72e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:00:17 +0100 Subject: [PATCH 090/806] test: clarify auto reply chunk assertion --- src/auto-reply/chunk.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index e6437fbce0c..2fd983bcc25 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -338,7 +338,8 @@ describe("chunkMarkdownText", () => { const text = `${prefix}\n\n${fence}\n\n${suffix}`; const chunks = chunkMarkdownText(text, 40); - expect(chunks.some((chunk) => chunk.trimEnd() === fence)).toBe(true); + const intactFenceChunks = chunks.filter((chunk) => chunk.trimEnd() === fence); + expect(intactFenceChunks.length).toBeGreaterThan(0); expectFencesBalanced(chunks); }, }, From 751d47188ccea673d542f19bc5acd0ed479ecdbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:01:43 +0100 Subject: [PATCH 091/806] test: clarify bundled command assertion --- src/plugins/bundle-commands.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index d29ad1e2469..b159df20a73 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -157,7 +157,8 @@ describe("loadEnabledClaudeBundleCommands", () => { promptTemplate: "Review the code. $ARGUMENTS", }, ]); - expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false); + const rawNames = commands.map((entry) => entry.rawName); + expect(rawNames).not.toContain("disabled"); } finally { env.restore(); } From 039269c738c025edf90406fc41ebdaf98d017819 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:02:50 +0100 Subject: [PATCH 092/806] test: align startup runtime policy assertions --- src/plugins/channel-plugin-ids.test.ts | 108 +++++++++++++++++++------ 1 file changed, 82 insertions(+), 26 deletions(-) diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index ad6725b7ee6..f8ff986565b 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -1391,12 +1391,32 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); - it("includes required agent harness owner plugins when the default runtime is forced", () => { + it("ignores legacy default agent runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeId: "codex", enabledPluginIds: ["codex"], }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }); + + it("includes required agent harness owner plugins for model runtime policy", () => { + expectStartupPluginIdsCase({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + plugins: { + entries: { + codex: { enabled: true }, + }, + }, + } as OpenClawConfig, expected: ["demo-channel", "browser", "codex", "memory-core"], }); }); @@ -1411,57 +1431,94 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); - it("includes required agent harness owner plugins when an agent override forces the runtime", () => { + it("ignores legacy per-agent runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeIds: ["codex"], enabledPluginIds: ["codex"], }), - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "memory-core"], }); }); - it("includes required agent harness owner plugins when env forces the runtime", () => { + it("ignores env runtime overrides during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ enabledPluginIds: ["codex"], }), env: { OPENCLAW_AGENT_RUNTIME: "codex" }, - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "memory-core"], }); }); - it("includes required CLI backend owner plugins when the default runtime is forced", () => { + it("ignores legacy CLI backend runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeId: "demo-cli", enabledPluginIds: ["demo-provider-plugin"], }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }); + + it("includes required CLI backend owner plugins for provider runtime policy", () => { + expectStartupPluginIdsCase({ + config: { + models: { + providers: { + "demo-provider": { + baseUrl: "https://example.com", + models: [], + agentRuntime: { id: "demo-cli" }, + }, + }, + }, + plugins: { + entries: { + "demo-provider-plugin": { enabled: true }, + }, + }, + } as OpenClawConfig, expected: ["demo-channel", "browser", "demo-provider-plugin", "memory-core"], }); }); - it.each([ - ["claude-cli", "anthropic"], - ["codex-cli", "openai"], - ["google-gemini-cli", "google"], - ] as const)("includes the bundled %s CLI backend owner at startup", (runtime, pluginId) => { - expectStartupPluginIdsCase({ - config: createStartupConfig({ - agentRuntimeId: runtime, - }), - expected: ["demo-channel", "browser", pluginId, "memory-core"], - }); - }); - - it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { + it("includes required CLI backend owner plugins for model runtime policy", () => { expectStartupPluginIdsCase({ config: { agents: { defaults: { - agentRuntime: { - id: "demo-cli", - fallback: "none", + models: { + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + } as OpenClawConfig, + expected: ["demo-channel", "browser", "anthropic", "memory-core"], + }); + }); + + it.each(["claude-cli", "codex-cli", "google-gemini-cli"] as const)( + "ignores legacy bundled %s runtime at startup", + (runtime) => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + agentRuntimeId: runtime, + }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }, + ); + + it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { + expectStartupPluginIdsCase({ + config: { + models: { + providers: { + "demo-provider": { + baseUrl: "https://example.com", + models: [], + agentRuntime: { id: "demo-cli" }, }, }, }, @@ -1482,9 +1539,8 @@ describe("resolveGatewayStartupPluginIds", () => { config: { agents: { defaults: { - agentRuntime: { - id: "codex", - fallback: "none", + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, }, }, }, From eaaef2dbf871e79a3c8bb5dd31b561bf6d3aa10d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:04:15 +0100 Subject: [PATCH 093/806] test: clarify plugin sdk assertions --- src/plugin-sdk/facade-runtime.test.ts | 3 ++- src/plugin-sdk/provider-model-shared.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index ed239ea3904..db8cfd590aa 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -242,7 +242,8 @@ describe("plugin-sdk facade runtime", () => { expect(loaded.marker).toBe("post-load-ok"); expect(reentryMarkers.length).toBeGreaterThan(0); - expect(reentryMarkers.every((marker) => marker === "post-load-ok")).toBe(true); + const unexpectedReentryMarkers = reentryMarkers.filter((marker) => marker !== "post-load-ok"); + expect(unexpectedReentryMarkers).toEqual([]); expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]); expect(loader).toHaveBeenCalledTimes(1); }); diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 3e4323d7709..1c0623cb843 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -270,6 +270,9 @@ describe("resolveClaudeThinkingProfile", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect(profile.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + const fixedBudgetLevels = profile.levels.filter( + (level) => level.id === "xhigh" || level.id === "max", + ); + expect(fixedBudgetLevels).toEqual([]); }); }); From 12487509c8498d372eb92b495314318abf93d061 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:05:56 +0100 Subject: [PATCH 094/806] test: clarify config cli error assertions --- src/cli/config-cli.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index eb68e6b2be5..4cd920117d2 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1972,10 +1972,11 @@ describe("config cli", () => { errors?: Array<{ kind: string; message: string; ref?: string }>; }; expect(payload.ok).toBe(false); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect( - payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), - ).toBe(true); + const errorKinds = (payload.errors ?? []).map((entry) => entry.kind); + expect(errorKinds).toContain("resolvability"); + const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? ""); + const discordTokenRefs = errorRefs.filter((ref) => ref.includes("default:DISCORD_BOT_TOKEN")); + expect(discordTokenRefs.length).toBeGreaterThan(0); }); it("keeps distinct resolvability failures when messages are identical but refs differ", async () => { @@ -2048,11 +2049,11 @@ describe("config cli", () => { errors?: Array<{ kind: string; message: string; ref?: string }>; }; expect(payload.ok).toBe(false); - expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect( - payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), - ).toBe(true); + const errorKinds = (payload.errors ?? []).map((entry) => entry.kind); + expect(errorKinds).toEqual(expect.arrayContaining(["schema", "resolvability"])); + const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? ""); + const discordTokenRefs = errorRefs.filter((ref) => ref.includes("default:DISCORD_BOT_TOKEN")); + expect(discordTokenRefs.length).toBeGreaterThan(0); }); it("fails dry-run when provider updates make existing refs unresolvable", async () => { From 5099e4712ec3059aa425ced15c6ea31c426407de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:07:39 +0100 Subject: [PATCH 095/806] test: clarify daemon launchd signal assertion --- src/daemon/launchd.integration.e2e.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index b8a1c7d8f24..ef4bc98dd4e 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -295,6 +295,7 @@ describeLaunchdIntegration("launchd integration", () => { const events = await fs.readFile(eventsPath, "utf8"); const lines = events.trim().split(/\r?\n/).filter(Boolean); expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1); - expect(lines.some((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line))).toBe(false); + const signalLines = lines.filter((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line)); + expect(signalLines).toEqual([]); }, 60_000); }); From 378cfe2da21b837b12ed1ed7dc9c64364cab260e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:08:58 +0100 Subject: [PATCH 096/806] test: clarify cli media error assertion --- src/cli/program.nodes-media.e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index cea3413356e..8d7c963de3d 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -74,7 +74,10 @@ describe("cli program (nodes media)", () => { runtime.error.mockClear(); await expect(parseProgram.parseAsync(args, { from: "user" })).rejects.toThrow(/exit/i); - expect(runtime.error.mock.calls.some(([msg]) => expectedError.test(String(msg)))).toBe(true); + const matchingErrors = runtime.error.mock.calls + .map(([msg]) => String(msg)) + .filter((msg) => expectedError.test(msg)); + expect(matchingErrors.length).toBeGreaterThan(0); } async function runAndExpectUrlPayloadMediaFile(params: { From 665d823237eb738a1f766f7b296695ff085758f0 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:10:10 +0100 Subject: [PATCH 097/806] fix: restore rolling progress labels --- src/plugin-sdk/channel-streaming.test.ts | 8 ++++---- src/plugin-sdk/channel-streaming.ts | 24 ++++++++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 2be16abac56..dfb86348f31 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -211,16 +211,16 @@ describe("channel-streaming", () => { lines: [" tool: read ", "patch applied", "tests done"], formatLine: (line) => `\`${line}\``, }), - ).toBe("Shelling\n• `patch applied`\n• `tests done`"); + ).toBe("• `patch applied`\n• `tests done`"); expect( formatChannelProgressDraftText({ entry, lines: ["🛠️ Exec", "plain update"], }), - ).toBe("Shelling\n🛠️ Exec\n• plain update"); + ).toBe("🛠️ Exec\n• plain update"); }); - it("preserves progress labels above rolling lines", () => { + it("renders progress labels as rolling lines", () => { const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } }; expect( @@ -228,7 +228,7 @@ describe("channel-streaming", () => { entry, lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"], }), - ).toBe("Shelling\n🛠️ Exec\n📖 Read\n🩹 Patch"); + ).toBe("🛠️ Exec\n📖 Read\n🩹 Patch"); }); it("renders structured progress lines with compact details", () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index a18c583940c..e8d01ae3394 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -792,21 +792,25 @@ export function formatChannelProgressDraftText(params: { const maxLines = resolveChannelProgressDraftMaxLines(params.entry); const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; - const progressLines = params.lines + const rawLines: Array = label + ? [{ draftLabel: label }, ...params.lines] + : params.lines; + const lines = rawLines .map((line) => { - const rawText = typeof line === "string" ? line : getProgressDraftLineText(line); + const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line; + const rawText = isLabelLine + ? line.draftLabel + : typeof line === "string" + ? line + : getProgressDraftLineText(line); const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS); - return text ? { text, isLabelLine: false } : undefined; + return text ? { text, isLabelLine } : undefined; }) .filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line)) .slice(-maxLines) - .map(({ text }) => { - const formatted = formatLine(text); - return shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; + .map(({ text, isLabelLine }) => { + const formatted = isLabelLine ? text : formatLine(text); + return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; }); - const labelLine = label - ? compactChannelProgressDraftLine(label, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS) - : ""; - const lines = [...(labelLine ? [labelLine] : []), ...progressLines]; return lines.filter((line): line is string => Boolean(line)).join("\n"); } From 3a5d39688ce823ebf0d75ab31752379b29231cec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:10:40 +0100 Subject: [PATCH 098/806] test: clarify bootstrap file assertions --- src/agents/bootstrap-files.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 3ea591cac30..7b12dfb1587 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -89,8 +89,9 @@ async function createHeartbeatAgentsWorkspace() { } function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) { - expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(false); - expect(files.some((file) => file.name === "AGENTS.md")).toBe(true); + const fileNames = files.map((file) => file.name); + expect(fileNames).not.toContain("HEARTBEAT.md"); + expect(fileNames).toContain("AGENTS.md"); } describe("resolveBootstrapFilesForRun", () => { @@ -103,7 +104,8 @@ describe("resolveBootstrapFilesForRun", () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); - expect(files.some((file) => file.path === path.join(workspaceDir, "EXTRA.md"))).toBe(true); + const filePaths = files.map((file) => file.path); + expect(filePaths).toContain(path.join(workspaceDir, "EXTRA.md")); }); it("drops malformed hook files with missing/invalid paths", async () => { @@ -166,9 +168,10 @@ describe("resolveBootstrapContextForRun", () => { const result = await resolveBootstrapContextForRun({ workspaceDir }); - expect(result.bootstrapFiles.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); - expect(result.contextFiles.some((file) => file.path.endsWith("BOOTSTRAP.md"))).toBe(true); - expect(result.contextFiles.some((file) => file.path.endsWith("AGENTS.md"))).toBe(true); + const bootstrapFileNames = result.bootstrapFiles.map((file) => file.name); + expect(bootstrapFileNames).toContain("BOOTSTRAP.md"); + const contextFileNames = result.contextFiles.map((file) => path.basename(file.path)); + expect(contextFileNames).toEqual(expect.arrayContaining(["BOOTSTRAP.md", "AGENTS.md"])); }); it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => { @@ -183,7 +186,8 @@ describe("resolveBootstrapContextForRun", () => { }); expect(files.length).toBeGreaterThan(0); - expect(files.every((file) => file.name === "HEARTBEAT.md")).toBe(true); + const nonHeartbeatFiles = files.filter((file) => file.name !== "HEARTBEAT.md"); + expect(nonHeartbeatFiles).toEqual([]); }); it("keeps bootstrap context empty in lightweight cron mode", async () => { @@ -258,7 +262,8 @@ describe("resolveBootstrapContextForRun", () => { }, }); - expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(true); + const fileNames = files.map((file) => file.name); + expect(fileNames).toContain("HEARTBEAT.md"); }); }); From e1e9cd82c18d5b37ad5502d4f4e014a9f0c466a6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:11:29 +0100 Subject: [PATCH 099/806] test: add codex media session id --- extensions/codex/media-understanding-provider.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/codex/media-understanding-provider.test.ts b/extensions/codex/media-understanding-provider.test.ts index 8939d9f3e71..af051c4a630 100644 --- a/extensions/codex/media-understanding-provider.test.ts +++ b/extensions/codex/media-understanding-provider.test.ts @@ -26,6 +26,7 @@ function threadStartResult() { return { thread: { id: "thread-1", + sessionId: "session-1", forkedFromId: null, preview: "", ephemeral: true, From 150b869cf8c99abafc11f5d343e4fb8a92d04d8a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:12:25 +0100 Subject: [PATCH 100/806] fix: set tts conversion output formats --- extensions/tts-local-cli/speech-provider.test.ts | 13 ++++++++++++- extensions/tts-local-cli/speech-provider.ts | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts index 75b09ee8f1c..b0d9ca23f26 100644 --- a/extensions/tts-local-cli/speech-provider.test.ts +++ b/extensions/tts-local-cli/speech-provider.test.ts @@ -80,7 +80,18 @@ describe("buildCliSpeechProvider", () => { if (typeof outputPath !== "string") { throw new Error("missing ffmpeg output path"); } - writeFileSync(outputPath, Buffer.from(`converted:${path.extname(outputPath)}`)); + const forcedFormatIndex = args.lastIndexOf("-f"); + const forcedFormat = + forcedFormatIndex >= 0 && typeof args[forcedFormatIndex + 1] === "string" + ? args[forcedFormatIndex + 1] + : undefined; + const extension = + forcedFormat === "s16le" + ? ".pcm" + : forcedFormat + ? `.${forcedFormat}` + : path.extname(outputPath); + writeFileSync(outputPath, Buffer.from(`converted:${extension}`)); }); }); diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index aece764806c..283057ce748 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -275,11 +275,11 @@ async function convertAudio( const outputPath = path.join(outputDir, outputFileName); const args = ["-y", "-i", inputPath]; if (target === "opus") { - args.push("-c:a", "libopus", "-b:a", "64k"); + args.push("-c:a", "libopus", "-b:a", "64k", "-f", "opus"); } else if (target === "wav") { - args.push("-c:a", "pcm_s16le"); + args.push("-c:a", "pcm_s16le", "-f", "wav"); } else { - args.push("-c:a", "libmp3lame", "-b:a", "128k"); + args.push("-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3"); } await writeExternalFileWithinRoot({ rootDir: outputDir, From e8d63b8bd07eefacbc1ba70ad229cb95d241f47a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:13:32 +0100 Subject: [PATCH 101/806] test: clarify update plan tool assertions --- src/agents/openclaw-tools.update-plan.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index e6d8c8bddc3..31dd3b8c9c3 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -11,6 +11,10 @@ function expectUpdatePlanEnabled(params: UpdatePlanGatingParams, expected: boole expect(isUpdatePlanToolEnabledForOpenClawTools(params)).toBe(expected); } +function toolNames(tools: ReturnType): string[] { + return tools.map((tool) => tool.name); +} + function openAiGpt5Params( config: OpenClawConfig, overrides: Partial = {}, @@ -48,8 +52,8 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(defaultTools.some((tool) => tool.name === "update_plan")).toBe(false); - expect(emptyAllowlistTools.some((tool) => tool.name === "update_plan")).toBe(false); + expect(toolNames(defaultTools)).not.toContain("update_plan"); + expect(toolNames(emptyAllowlistTools)).not.toContain("update_plan"); }); it("wraps constructed tools with before-tool-call hooks by default", () => { @@ -95,7 +99,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("registers update_plan when a config allowlist group includes it", () => { @@ -106,7 +110,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("registers update_plan when a runtime allowlist group includes it", () => { @@ -118,7 +122,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("respects deny policy while constructing update_plan for grouped allowlists", () => { @@ -131,7 +135,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(false); + expect(toolNames(tools)).not.toContain("update_plan"); }); it("auto-enables update_plan for unconfigured GPT-5 openai runs", () => { From a31f4c57e5554a696fcf631bd77a97bbd04775ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:17:54 +0100 Subject: [PATCH 102/806] fix: normalize Gemini auth config patches --- .../provider-auth-choice-helpers.test.ts | 53 ++++++++++++ src/plugins/provider-auth-choice-helpers.ts | 80 ++++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index e58e6061bf5..9d840a5b6a1 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -101,6 +101,59 @@ describe("applyProviderAuthConfigPatch", () => { }, }); }); + + it("normalizes retired Google Gemini model refs from provider config patches", () => { + const patch = { + agents: { + defaults: { + model: { + primary: "google/gemini-3-pro-preview", + fallbacks: ["google/gemini-3-pro-preview", "openai/gpt-5.5"], + }, + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + "google/gemini-3.1-pro-preview": { + params: { maxTokens: 12_000 }, + }, + }, + }, + }, + }; + + const next = applyProviderAuthConfigPatch({}, patch); + + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + fallbacks: ["google/gemini-3.1-pro-preview", "openai/gpt-5.5"], + }); + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high", maxTokens: 12_000 }, + }, + }); + }); + + it("normalizes retired Google Gemini keys when replacing provider model maps", () => { + const patch = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": {}, + }, + }, + }, + }; + + const next = applyProviderAuthConfigPatch(base, patch, { replaceDefaultModels: true }); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": {}, + }); + }); }); describe("applyDefaultModel", () => { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 02e8ba27591..84d5681781c 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -89,12 +89,86 @@ function mergeConfigPatch(base: T, patch: unknown): T { return next as T; } +function normalizeAgentModelConfigForWrite(value: unknown): unknown { + if (typeof value === "string") { + return normalizeAgentModelRefForConfig(value); + } + if (!isPlainRecord(value)) { + return value; + } + + const next: Record = { ...value }; + if (typeof next.primary === "string") { + next.primary = normalizeAgentModelRefForConfig(next.primary); + } + if (Array.isArray(next.fallbacks)) { + next.fallbacks = next.fallbacks.map((fallback) => + typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback, + ); + } + return next; +} + +function mergeModelEntryConfig(existing: unknown, incoming: unknown): unknown { + if (!isPlainRecord(existing) || !isPlainRecord(incoming)) { + return incoming; + } + + const existingParams = isPlainRecord(existing.params) ? existing.params : undefined; + const incomingParams = isPlainRecord(incoming.params) ? incoming.params : undefined; + return { + ...existing, + ...incoming, + ...(existingParams || incomingParams + ? { params: { ...existingParams, ...incomingParams } } + : undefined), + }; +} + +function normalizeAgentModelMapForWrite(value: unknown): unknown { + if (!isPlainRecord(value)) { + return value; + } + + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = normalizeAgentModelRefForConfig(key); + next[normalizedKey] = mergeModelEntryConfig(next[normalizedKey], entry); + } + return next; +} + +function normalizeConfigModelRefsForWrite(cfg: OpenClawConfig): OpenClawConfig { + const defaults = cfg.agents?.defaults; + if (!defaults) { + return cfg; + } + + const nextDefaults: NonNullable["defaults"]> = { + ...defaults, + }; + if (defaults.model !== undefined) { + nextDefaults.model = normalizeAgentModelConfigForWrite(defaults.model) as typeof defaults.model; + } + if (defaults.models !== undefined) { + nextDefaults.models = normalizeAgentModelMapForWrite(defaults.models) as typeof defaults.models; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: nextDefaults, + }, + }; +} + export function applyProviderAuthConfigPatch( cfg: OpenClawConfig, patch: unknown, options?: { replaceDefaultModels?: boolean }, ): OpenClawConfig { - const merged = mergeConfigPatch(cfg, patch); + const merged = normalizeConfigModelRefsForWrite(mergeConfigPatch(cfg, patch)); if (!options?.replaceDefaultModels || !isPlainRecord(patch)) { return merged; } @@ -105,7 +179,7 @@ export function applyProviderAuthConfigPatch( return merged; } - return { + return normalizeConfigModelRefsForWrite({ ...merged, agents: { ...merged.agents, @@ -117,7 +191,7 @@ export function applyProviderAuthConfigPatch( >["models"], }, }, - }; + }); } export function applyDefaultModel( From b38c78fe63300b03f520bc7937216c711c517824 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:19:17 +0100 Subject: [PATCH 103/806] test: clarify plugin loader channel assertions --- src/plugins/loader.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6131ac8a9de..b59e8be4066 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -283,7 +283,7 @@ function setupBundledTelegramPlugin() { function expectTelegramLoaded(registry: ReturnType) { const telegram = registry.plugins.find((entry) => entry.id === "telegram"); expect(telegram?.status).toBe("loaded"); - expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true); + expect(registry.channels.map((entry) => entry.plugin.id)).toContain("telegram"); } function loadRegistryFromSinglePlugin(params: { @@ -4588,9 +4588,7 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(registry.plugins.find((entry) => entry.id === "nested-default-channel")?.status).toBe( "loaded", ); - expect(registry.channels.some((entry) => entry.plugin.id === "nested-default-channel")).toBe( - true, - ); + expect(registry.channels.map((entry) => entry.plugin.id)).toContain("nested-default-channel"); }); it("does not treat manifest channel ids as scoped plugin id matches", () => { From 767dbe469e90b8db0527090481b3126fbfd1d34d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:20:24 +0100 Subject: [PATCH 104/806] test: clarify subscribe media assertions --- ...edded-pi-session.subscribeembeddedpisession.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 7067d704d75..cdb23c295ff 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -469,7 +469,10 @@ describe("subscribeEmbeddedPiSession", () => { text: "Generated 1 image.", }), ); - expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + const earlyMediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.length); + expect(earlyMediaPayloads).toEqual([]); emitAssistantTextDelta(emit, "MEDIA:/tmp/generated.png"); emit({ @@ -611,7 +614,10 @@ describe("subscribeEmbeddedPiSession", () => { text: firstChunk.trim(), }), ); - expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + const earlyMediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.length); + expect(earlyMediaPayloads).toEqual([]); emitAssistantTextDelta(emit, `MEDIA:${mediaUrl}`); emit({ From 8b57d0fe9ef612881fd62e4d5a0074448bb03dbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:21:56 +0100 Subject: [PATCH 105/806] test: clarify update cli exit assertions --- src/cli/update-cli.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 47b2bd67d04..4a3a33b3870 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1429,7 +1429,7 @@ describe("update-cli", () => { await updateCommand({ dryRun: true }); }, assert: () => { - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); expect(runGatewayUpdate).not.toHaveBeenCalled(); }, }, @@ -3298,9 +3298,11 @@ describe("update-cli", () => { .mocked(defaultRuntime.error) .mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required.")); expect(downgradeMessageSeen).toBe(shouldExit); - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( - shouldExit, - ); + if (shouldExit) { + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + } else { + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + } expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(false); expect( vi From 036c4321015216da9cadc6e2c205bd554ac23fa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:22:57 +0100 Subject: [PATCH 106/806] test: clarify transcript repair assertion --- src/agents/session-transcript-repair.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index d3635036d6f..7589554093b 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -209,7 +209,7 @@ describe("sanitizeToolUseResultPairing", () => { ]); const out = sanitizeToolUseResultPairing(input); - expect(out.some((m) => m.role === "toolResult")).toBe(false); + expect(out.filter((m) => m.role === "toolResult")).toEqual([]); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); From acb3b09e2a09e21943d2ff6d05e63111456be7a0 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:23:47 +0100 Subject: [PATCH 107/806] fix: keep progress draft labels visible --- CHANGELOG.md | 3 +- docs/channels/discord.md | 2 +- docs/concepts/progress-drafts.md | 7 +++-- .../monitor/message-handler.process.test.ts | 4 +-- .../src/reply-stream-controller.test.ts | 4 ++- .../dispatch.preview-fallback.test.ts | 12 ++++---- src/plugin-sdk/channel-streaming.test.ts | 20 ++++++++++--- src/plugin-sdk/channel-streaming.ts | 28 ++++++++----------- 8 files changed, 47 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4ce7451a3..3b962492290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. -- Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) +- Channels/streaming: render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. @@ -185,6 +185,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. +- Channels/streaming: keep progress draft labels visible above the last `streaming.progress.maxLines` progress rows instead of counting the label against the rolling line limit. Thanks @shakkernerd. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index aec9b12eaba..41c9fd2fe56 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -662,7 +662,7 @@ Default slash command settings: - OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label is a rolling line, so it scrolls away like the rest once enough work appears. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key. + OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label stays visible while `streaming.progress.maxLines` limits the rolling progress lines below it. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key. Set `channels.discord.streaming.mode` to `off` to disable Discord preview edits. If Discord block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming. diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index ccfbf271fc9..0144879d273 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -57,9 +57,10 @@ A progress draft has two parts: | Progress lines | Compact run updates using the same tool icons and detail formatter as verbose output. | The label appears after the agent starts meaningful work and either remains busy -for five seconds or emits a second work event. It is part of the rolling progress -line list, so the starter status scrolls away once enough concrete work appears. -Plain text-only replies do not show a progress draft. Progress lines are added +for five seconds or emits a second work event. It stays visible while the agent +is still working; `streaming.progress.maxLines` limits only the rolling progress +lines below the label. In other words, progress drafts render as `label + last N +progress lines`. Plain text-only replies do not show a progress draft. Progress lines are added only when the agent emits useful work updates, for example `🛠️ Bash: run tests`, `🔎 Web Search: for "discord edit message"`, or `✍️ Write: to /tmp/file`. By default they use the same compact explain mode as `/verbose`; set diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3f738cc3229..2b39901a1b6 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1656,7 +1656,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done"); }); - it("keeps Discord progress labels as rolling lines", async () => { + it("keeps Discord progress labels visible above rolling lines", async () => { const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { @@ -1680,7 +1680,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 3d4ebe9b4fb..316e0f059ea 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,7 +320,9 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( + "Working\n- tool: exec", + ); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 689ee830b07..b06fa2b554d 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -354,9 +354,8 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ const label = params.entry?.streaming?.progress?.label; const maxLines = params.entry?.streaming?.progress?.maxLines ?? 8; const formatLine = params.formatLine ?? ((line: string) => line); - const lines = [ - label === false ? undefined : (label ?? "Thinking"), - ...params.lines.map((line) => { + const progressLines = params.lines + .map((line) => { const text = typeof line === "string" ? line @@ -367,10 +366,12 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ : line.text; const formatted = formatLine(text); return /^\p{Extended_Pictographic}/u.test(text) ? formatted : `• ${formatted}`; - }), - ] + }) .filter((line): line is string => Boolean(line)) .slice(-maxLines); + const lines = [label === false ? undefined : (label ?? "Thinking"), ...progressLines].filter( + (line): line is string => Boolean(line), + ); return lines.join("\n"); }, formatChannelProgressDraftLine: (params: { @@ -827,6 +828,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.update).toHaveBeenLastCalledWith( [ + "Shelling", "• step 1", "• step 2", "• step 3", diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index dfb86348f31..6df1a737ab9 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -211,16 +211,28 @@ describe("channel-streaming", () => { lines: [" tool: read ", "patch applied", "tests done"], formatLine: (line) => `\`${line}\``, }), - ).toBe("• `patch applied`\n• `tests done`"); + ).toBe("Shelling\n• `patch applied`\n• `tests done`"); expect( formatChannelProgressDraftText({ entry, lines: ["🛠️ Exec", "plain update"], }), - ).toBe("🛠️ Exec\n• plain update"); + ).toBe("Shelling\n🛠️ Exec\n• plain update"); }); - it("renders progress labels as rolling lines", () => { + it("keeps progress labels outside the rolling line limit", () => { + const entry = { streaming: { progress: { label: "Working", maxLines: 1 } } }; + + expect( + formatChannelProgressDraftText({ + entry, + lines: ["tool: search", "tool: exec"], + bullet: "-", + }), + ).toBe("Working\n- tool: exec"); + }); + + it("keeps progress labels visible with bounded rolling lines", () => { const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } }; expect( @@ -228,7 +240,7 @@ describe("channel-streaming", () => { entry, lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"], }), - ).toBe("🛠️ Exec\n📖 Read\n🩹 Patch"); + ).toBe("Shelling\n🛠️ Exec\n📖 Read\n🩹 Patch"); }); it("renders structured progress lines with compact details", () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index e8d01ae3394..28bfa8cdfc6 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -792,25 +792,21 @@ export function formatChannelProgressDraftText(params: { const maxLines = resolveChannelProgressDraftMaxLines(params.entry); const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; - const rawLines: Array = label - ? [{ draftLabel: label }, ...params.lines] - : params.lines; - const lines = rawLines + const labelLine = label + ? compactChannelProgressDraftLine(label, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS) + : ""; + const progressLines = params.lines .map((line) => { - const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line; - const rawText = isLabelLine - ? line.draftLabel - : typeof line === "string" - ? line - : getProgressDraftLineText(line); + const rawText = typeof line === "string" ? line : getProgressDraftLineText(line); const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS); - return text ? { text, isLabelLine } : undefined; + return text || undefined; }) - .filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line)) + .filter((line): line is string => Boolean(line)) .slice(-maxLines) - .map(({ text, isLabelLine }) => { - const formatted = isLabelLine ? text : formatLine(text); - return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; + .map((text) => { + const formatted = formatLine(text); + return shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; }); - return lines.filter((line): line is string => Boolean(line)).join("\n"); + const lines = labelLine ? [labelLine, ...progressLines] : progressLines; + return lines.join("\n"); } From 3c6dd9fcb208d0e3188a063dcfabe2bfe6bf7bf6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:23:58 +0100 Subject: [PATCH 108/806] test: clarify final tag payload assertion --- ...on.filters-final-suppresses-output-without-start-tag.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts index 2ca639d30e7..eb68426e9c6 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts @@ -101,7 +101,7 @@ describe("subscribeEmbeddedPiSession", () => { expect(streamedText).toBe("Title\nLine one\nLine two"); expect(streamedText).not.toContain("<"); expect(streamedText).not.toContain("final>"); - expect(payloads.some((payload) => payload.replace)).toBe(false); + expect(payloads.filter((payload) => payload.replace)).toEqual([]); }); it("preserves final content when enforced final tags are split across streamed deltas", () => { From 2aa6d6ba14c97d64851aa0def85c54fa83e3b6ce Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:25:14 +0100 Subject: [PATCH 109/806] test: assert discord voice staging output --- extensions/discord/src/voice-message.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index feee9c0175d..006e2c60161 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -50,6 +50,18 @@ describe("ensureOggOpus", () => { runFfprobeMock.mockReset(); runFfmpegMock.mockReset(); }); + + function expectStagedFfmpegOutput(ffmpegOutputPath: string | undefined, finalPath: string) { + expect(ffmpegOutputPath).toEqual(expect.any(String)); + if (typeof ffmpegOutputPath !== "string") { + throw new Error("missing ffmpeg output path"); + } + expect(ffmpegOutputPath).not.toBe(finalPath); + const stagedBase = path.basename(ffmpegOutputPath); + expect(stagedBase.startsWith(".fs-safe-output-")).toBe(true); + expect(stagedBase.endsWith(`-${path.basename(finalPath)}.part`)).toBe(true); + } + it("rejects URL/protocol input paths", async () => { await expect(ensureOggOpus("https://example.com/audio.ogg")).rejects.toThrow( /local file path/i, @@ -90,8 +102,7 @@ describe("ensureOggOpus", () => { expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg"]), ); const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); - expect(ffmpegOutputPath).not.toBe(result.path); - expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + expectStagedFfmpegOutput(ffmpegOutputPath, result.path); await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); @@ -113,8 +124,7 @@ describe("ensureOggOpus", () => { expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3"]), ); const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); - expect(ffmpegOutputPath).not.toBe(result.path); - expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + expectStagedFfmpegOutput(ffmpegOutputPath, result.path); await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); }); From c2b2a4cdf4a9874a298779441e02cf6f0a88f940 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:26:01 +0100 Subject: [PATCH 110/806] test: clarify read only channel plugin assertions --- src/channels/plugins/read-only.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 7fc2bced245..73531190179 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -22,6 +22,10 @@ const moduleLoaderParams = vi.hoisted( }>, ); +function pluginIds(plugins: ReturnType): string[] { + return plugins.map((entry) => entry.id); +} + vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -473,7 +477,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -610,7 +614,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "alpha-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("alpha-chat"); const betaPlugin = plugins.find((entry) => entry.id === "beta-chat"); expect(betaPlugin?.meta.id).toBe("beta-chat"); expect( @@ -792,7 +796,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === unsafeChannelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(unsafeChannelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -907,7 +911,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -927,7 +931,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === channelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(channelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -953,7 +957,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === channelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(channelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -1096,8 +1100,8 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "spoofed-chat")).toBe(false); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("spoofed-chat"); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(true); expect(fs.existsSync(fullMarker)).toBe(false); }); From 97d7dd9add3b17036fddf135c3ea69bba9bd8799 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:27:09 +0100 Subject: [PATCH 111/806] test: clarify sessions tool call assertions --- src/agents/openclaw-tools.sessions.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index b6942e7961d..1e5fd27fa1a 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1316,17 +1316,16 @@ describe("sessions tools", () => { delivery: { status: "skipped", mode: "announce" }, }); expect(calls.filter((call) => call.method === "agent")).toHaveLength(1); - expect( - calls.some( - (call) => - call.method === "agent" && - typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" && - (call.params as { extraSystemPrompt?: string }).extraSystemPrompt?.includes( - "Agent-to-agent reply step", - ), - ), - ).toBe(false); - expect(calls.some((call) => call.method === "send")).toBe(false); + const replyPromptAgentCalls = calls.filter( + (call) => + call.method === "agent" && + typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" && + (call.params as { extraSystemPrompt?: string }).extraSystemPrompt?.includes( + "Agent-to-agent reply step", + ), + ); + expect(replyPromptAgentCalls).toEqual([]); + expect(calls.filter((call) => call.method === "send")).toEqual([]); }); it("sessions_send preserves threadId when announce target is hydrated via sessions.list", async () => { From fecddcabd7a9acb859e18e4e54e86a68bb794f2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:28:29 +0100 Subject: [PATCH 112/806] test: clarify sessions send gateway assertion --- src/agents/tools/sessions-send-tool.a2a.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index e8ed4dad95f..53de64f9b60 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -139,7 +139,7 @@ describe("runSessionsSendA2AFlow announce delivery", () => { roundOneReply: "Worker completed successfully", }); - expect(gatewayCalls.some((call) => call.method === "sessions.list")).toBe(true); + requireGatewayCall("sessions.list"); const sendCall = requireGatewayCall("send"); expect(sendCall.params).toMatchObject({ channel: "discord", From c2927e6d8755e82021a95100fbe5b8a0018a0bed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:30:00 +0100 Subject: [PATCH 113/806] test: clarify script preflight flag assertion --- src/agents/bash-tools.exec.script-preflight.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index 8969f75ea64..8086ca6e080 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -490,7 +490,9 @@ describeNonWin("exec script preflight", () => { }), ).resolves.toBeUndefined(); expect(scriptOpenFlags.length).toBeGreaterThan(0); - expect(scriptOpenFlags.some((flags) => (flags & fsConstants.O_NONBLOCK) !== 0)).toBe(true); + expect(scriptOpenFlags.filter((flags) => (flags & fsConstants.O_NONBLOCK) !== 0)).not.toEqual( + [], + ); }); }); From c71dfb6f527279b4b440c0e2ff22e63fc9f017f1 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 8 May 2026 16:22:23 +1000 Subject: [PATCH 114/806] test: cover download parent symlink race --- ...-core.waits-next-download-saves-it.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index e1f583fef1c..1c506baa865 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -208,6 +208,55 @@ describe("pw-tools-core", () => { }); }); + it.runIf(process.platform !== "win32")( + "does not write outside the output root when a download parent is swapped after save", + async () => { + await withTempDir(async (tempDir) => { + const rootDir = path.join(tempDir, "downloads"); + const targetParent = path.join(rootDir, "race"); + const outsideDir = path.join(tempDir, "outside"); + const targetPath = path.join(targetParent, "file.bin"); + await fs.mkdir(targetParent, { recursive: true }); + await fs.mkdir(outsideDir); + + const harness = createDownloadEventHarness(); + let parentSwappedBeforeFinalize = false; + const saveAs = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "race-content", "utf8"); + const beforeSwap = await fs.lstat(targetParent); + expect(beforeSwap.isDirectory()).toBe(true); + expect(beforeSwap.isSymbolicLink()).toBe(false); + await fs.rm(targetParent, { recursive: true, force: true }); + await fs.symlink(outsideDir, targetParent); + const afterSwap = await fs.lstat(targetParent); + expect(afterSwap.isSymbolicLink()).toBe(true); + parentSwappedBeforeFinalize = true; + }); + + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + path: targetPath, + rootDir, + timeoutMs: 1000, + }); + + await Promise.resolve(); + harness.expectArmed(); + harness.trigger({ + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs, + }); + + await expect(p).rejects.toThrow(/path alias|outside workspace|directory changed/i); + expect(parentSwappedBeforeFinalize).toBe(true); + expect(saveAs).toHaveBeenCalledOnce(); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + }); + }, + ); + it("marks explicit download waiters as owning the next download until cleanup", async () => { const harness = createDownloadEventHarness(); const state = sessionMocks.ensurePageState(); From 48c24c86c97a1796457ec3c4a211ab58a5ac5475 Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 06:35:47 +0000 Subject: [PATCH 115/806] test: cover download parent symlink race --- .../browser/pw-tools-core.waits-next-download-saves-it.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 1c506baa865..80ff45f8041 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -216,6 +216,7 @@ describe("pw-tools-core", () => { const targetParent = path.join(rootDir, "race"); const outsideDir = path.join(tempDir, "outside"); const targetPath = path.join(targetParent, "file.bin"); + const outsideTargetPath = path.join(outsideDir, "file.bin"); await fs.mkdir(targetParent, { recursive: true }); await fs.mkdir(outsideDir); @@ -252,6 +253,7 @@ describe("pw-tools-core", () => { await expect(p).rejects.toThrow(/path alias|outside workspace|directory changed/i); expect(parentSwappedBeforeFinalize).toBe(true); expect(saveAs).toHaveBeenCalledOnce(); + await expect(fs.access(outsideTargetPath)).rejects.toThrow(); await expect(fs.readdir(outsideDir)).resolves.toEqual([]); }); }, From ee495603d198ece728306ef0c20959bf880589fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:31:38 +0100 Subject: [PATCH 116/806] test: clarify coding tool name assertions --- ...tools.create-openclaw-coding-tools.test.ts | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index abfc97f2e1d..5896a697a87 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -94,6 +94,10 @@ function applyRuntimeToolsAllow(tools: T[], toolsAll type OpenClawCodingTool = ReturnType[number]; +function toolNameList(tools: readonly { name: string }[]): string[] { + return tools.map((tool) => tool.name); +} + function requireTool(tools: OpenClawCodingTool[], name: string): OpenClawCodingTool { const tool = tools.find((candidate) => candidate.name === name); if (!tool) { @@ -354,23 +358,23 @@ describe("createOpenClawCodingTools", () => { it("enforces apply_patch availability and canonical names across model/provider constraints", () => { const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true }); - expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "process")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(defaultTools)).toContain("exec"); + expect(toolNameList(defaultTools)).toContain("process"); + expect(toolNameList(defaultTools)).not.toContain("apply_patch"); const openAiTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai", modelId: "gpt-5.4", }); - expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(openAiTools)).toContain("apply_patch"); const codexTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai-codex", modelId: "gpt-5.4", }); - expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(codexTools)).toContain("apply_patch"); const disabledConfig: OpenClawConfig = { tools: { @@ -384,14 +388,14 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(disabledOpenAiTools)).not.toContain("apply_patch"); const anthropicTools = createOpenClawCodingTools({ config: disabledConfig, modelProvider: "anthropic", modelId: "claude-opus-4-6", }); - expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(anthropicTools)).not.toContain("apply_patch"); const allowModelsConfig: OpenClawConfig = { tools: { @@ -405,14 +409,14 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(allowed)).toContain("apply_patch"); const denied = createOpenClawCodingTools({ config: allowModelsConfig, modelProvider: "openai", modelId: "gpt-5.4-mini", }); - expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(denied)).not.toContain("apply_patch"); const oauthTools = createOpenClawCodingTools({ config: testConfig, @@ -666,7 +670,7 @@ describe("createOpenClawCodingTools", () => { }, } as OpenClawConfig, }); - expect(subagentAllowOnly.some((tool) => tool.name === "browser")).toBe(false); + expect(toolNameList(subagentAllowOnly)).not.toContain("browser"); const profileStageAlsoAllow = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", @@ -675,20 +679,20 @@ describe("createOpenClawCodingTools", () => { tools: { profile: "coding", alsoAllow: ["browser"] }, } as OpenClawConfig, }); - expect(profileStageAlsoAllow.some((tool) => tool.name === "browser")).toBe(true); + expect(toolNameList(profileStageAlsoAllow)).toContain("browser"); }); it("can keep message available when a cron route needs it under the coding profile", () => { const codingTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, }); - expect(codingTools.some((tool) => tool.name === "message")).toBe(false); + expect(toolNameList(codingTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, forceMessageTool: true, }); - expect(cronTools.some((tool) => tool.name === "message")).toBe(true); + expect(toolNameList(cronTools)).toContain("message"); }); it("keeps heartbeat response available for heartbeat runs under the coding profile", () => { @@ -699,7 +703,7 @@ describe("createOpenClawCodingTools", () => { forceHeartbeatTool: true, }); - expect(codingTools.some((tool) => tool.name === "heartbeat_respond")).toBe(true); + expect(toolNameList(codingTools)).toContain("heartbeat_respond"); }); it("enables heartbeat response when visible replies are message-tool-only", () => { @@ -711,7 +715,7 @@ describe("createOpenClawCodingTools", () => { trigger: "heartbeat", }); - expect(tools.some((tool) => tool.name === "heartbeat_respond")).toBe(true); + expect(toolNameList(tools)).toContain("heartbeat_respond"); }); it("can keep message available when a cron route needs it under a provider coding profile", () => { @@ -720,7 +724,7 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(providerProfileTools.some((tool) => tool.name === "message")).toBe(false); + expect(toolNameList(providerProfileTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { byProvider: { openai: { profile: "coding" } } } }, @@ -728,7 +732,7 @@ describe("createOpenClawCodingTools", () => { modelId: "gpt-5.4", forceMessageTool: true, }); - expect(cronTools.some((tool) => tool.name === "message")).toBe(true); + expect(toolNameList(cronTools)).toContain("message"); }); it.each(providerAliasCases)( @@ -812,7 +816,7 @@ describe("createOpenClawCodingTools", () => { senderIsOwner: true, }); - expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false); + expect(toolNameList(xaiTools)).not.toContain("web_search"); for (const tool of xaiTools) { const violations = findUnsupportedSchemaKeywords( tool.parameters, @@ -889,9 +893,9 @@ describe("createOpenClawCodingTools", () => { }, }); const tools = createOpenClawCodingTools({ sandbox }); - expect(tools.some((tool) => tool.name === "exec")).toBe(true); - expect(tools.some((tool) => tool.name === "read")).toBe(false); - expect(tools.some((tool) => tool.name === "browser")).toBe(false); + expect(toolNameList(tools)).toContain("exec"); + expect(toolNameList(tools)).not.toContain("read"); + expect(toolNameList(tools)).not.toContain("browser"); }); it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { @@ -907,9 +911,9 @@ describe("createOpenClawCodingTools", () => { }, }); const tools = createOpenClawCodingTools({ sandbox }); - expect(tools.some((tool) => tool.name === "read")).toBe(true); - expect(tools.some((tool) => tool.name === "write")).toBe(false); - expect(tools.some((tool) => tool.name === "edit")).toBe(false); + expect(toolNameList(tools)).toContain("read"); + expect(toolNameList(tools)).not.toContain("write"); + expect(toolNameList(tools)).not.toContain("edit"); }); it("accepts canonical parameters for read/write/edit", async () => { From baffa57c004e361435da81e5e583151e349b2d43 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:32:51 +0100 Subject: [PATCH 117/806] revert: restore progress draft behavior --- CHANGELOG.md | 3 +- docs/channels/discord.md | 2 +- docs/concepts/progress-drafts.md | 7 ++--- .../monitor/message-handler.process.test.ts | 4 +-- .../src/reply-stream-controller.test.ts | 4 +-- .../dispatch.preview-fallback.test.ts | 12 ++++---- src/plugin-sdk/channel-streaming.test.ts | 20 +++---------- src/plugin-sdk/channel-streaming.ts | 28 +++++++++++-------- 8 files changed, 33 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b962492290..7f4ce7451a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. -- Channels/streaming: render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) +- Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. @@ -185,7 +185,6 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. -- Channels/streaming: keep progress draft labels visible above the last `streaming.progress.maxLines` progress rows instead of counting the label against the rolling line limit. Thanks @shakkernerd. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 41c9fd2fe56..aec9b12eaba 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -662,7 +662,7 @@ Default slash command settings: - OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label stays visible while `streaming.progress.maxLines` limits the rolling progress lines below it. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key. + OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label is a rolling line, so it scrolls away like the rest once enough work appears. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key. Set `channels.discord.streaming.mode` to `off` to disable Discord preview edits. If Discord block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming. diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index 0144879d273..ccfbf271fc9 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -57,10 +57,9 @@ A progress draft has two parts: | Progress lines | Compact run updates using the same tool icons and detail formatter as verbose output. | The label appears after the agent starts meaningful work and either remains busy -for five seconds or emits a second work event. It stays visible while the agent -is still working; `streaming.progress.maxLines` limits only the rolling progress -lines below the label. In other words, progress drafts render as `label + last N -progress lines`. Plain text-only replies do not show a progress draft. Progress lines are added +for five seconds or emits a second work event. It is part of the rolling progress +line list, so the starter status scrolls away once enough concrete work appears. +Plain text-only replies do not show a progress draft. Progress lines are added only when the agent emits useful work updates, for example `🛠️ Bash: run tests`, `🔎 Web Search: for "discord edit message"`, or `✍️ Write: to /tmp/file`. By default they use the same compact explain mode as `/verbose`; set diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 2b39901a1b6..3f738cc3229 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1656,7 +1656,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done"); }); - it("keeps Discord progress labels visible above rolling lines", async () => { + it("keeps Discord progress labels as rolling lines", async () => { const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { @@ -1680,7 +1680,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 316e0f059ea..3d4ebe9b4fb 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,9 +320,7 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( - "Working\n- tool: exec", - ); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index b06fa2b554d..689ee830b07 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -354,8 +354,9 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ const label = params.entry?.streaming?.progress?.label; const maxLines = params.entry?.streaming?.progress?.maxLines ?? 8; const formatLine = params.formatLine ?? ((line: string) => line); - const progressLines = params.lines - .map((line) => { + const lines = [ + label === false ? undefined : (label ?? "Thinking"), + ...params.lines.map((line) => { const text = typeof line === "string" ? line @@ -366,12 +367,10 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ : line.text; const formatted = formatLine(text); return /^\p{Extended_Pictographic}/u.test(text) ? formatted : `• ${formatted}`; - }) + }), + ] .filter((line): line is string => Boolean(line)) .slice(-maxLines); - const lines = [label === false ? undefined : (label ?? "Thinking"), ...progressLines].filter( - (line): line is string => Boolean(line), - ); return lines.join("\n"); }, formatChannelProgressDraftLine: (params: { @@ -828,7 +827,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.update).toHaveBeenLastCalledWith( [ - "Shelling", "• step 1", "• step 2", "• step 3", diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 6df1a737ab9..dfb86348f31 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -211,28 +211,16 @@ describe("channel-streaming", () => { lines: [" tool: read ", "patch applied", "tests done"], formatLine: (line) => `\`${line}\``, }), - ).toBe("Shelling\n• `patch applied`\n• `tests done`"); + ).toBe("• `patch applied`\n• `tests done`"); expect( formatChannelProgressDraftText({ entry, lines: ["🛠️ Exec", "plain update"], }), - ).toBe("Shelling\n🛠️ Exec\n• plain update"); + ).toBe("🛠️ Exec\n• plain update"); }); - it("keeps progress labels outside the rolling line limit", () => { - const entry = { streaming: { progress: { label: "Working", maxLines: 1 } } }; - - expect( - formatChannelProgressDraftText({ - entry, - lines: ["tool: search", "tool: exec"], - bullet: "-", - }), - ).toBe("Working\n- tool: exec"); - }); - - it("keeps progress labels visible with bounded rolling lines", () => { + it("renders progress labels as rolling lines", () => { const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } }; expect( @@ -240,7 +228,7 @@ describe("channel-streaming", () => { entry, lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"], }), - ).toBe("Shelling\n🛠️ Exec\n📖 Read\n🩹 Patch"); + ).toBe("🛠️ Exec\n📖 Read\n🩹 Patch"); }); it("renders structured progress lines with compact details", () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index 28bfa8cdfc6..e8d01ae3394 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -792,21 +792,25 @@ export function formatChannelProgressDraftText(params: { const maxLines = resolveChannelProgressDraftMaxLines(params.entry); const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; - const labelLine = label - ? compactChannelProgressDraftLine(label, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS) - : ""; - const progressLines = params.lines + const rawLines: Array = label + ? [{ draftLabel: label }, ...params.lines] + : params.lines; + const lines = rawLines .map((line) => { - const rawText = typeof line === "string" ? line : getProgressDraftLineText(line); + const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line; + const rawText = isLabelLine + ? line.draftLabel + : typeof line === "string" + ? line + : getProgressDraftLineText(line); const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS); - return text || undefined; + return text ? { text, isLabelLine } : undefined; }) - .filter((line): line is string => Boolean(line)) + .filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line)) .slice(-maxLines) - .map((text) => { - const formatted = formatLine(text); - return shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; + .map(({ text, isLabelLine }) => { + const formatted = isLabelLine ? text : formatLine(text); + return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; }); - const lines = labelLine ? [labelLine, ...progressLines] : progressLines; - return lines.join("\n"); + return lines.filter((line): line is string => Boolean(line)).join("\n"); } From 85587e17d748d7ef870ccb0fab54364d7b3ae356 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:33:50 +0100 Subject: [PATCH 118/806] test: clarify coding tool content assertions --- src/agents/pi-tools.create-openclaw-coding-tools.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 5896a697a87..4b31d89364b 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -855,7 +855,7 @@ describe("createOpenClawCodingTools", () => { const imageText = imageTextBlocks?.map((block) => block.text ?? "").join("\n") ?? ""; expect(imageText).toContain("Read image file [image/png]"); if ((imageBlocks?.length ?? 0) > 0) { - expect(imageBlocks?.every((block) => block.mimeType === "image/png")).toBe(true); + expect(imageBlocks?.filter((block) => block.mimeType !== "image/png")).toEqual([]); } else { expect(imageText).toContain("[Image omitted:"); } @@ -868,7 +868,7 @@ describe("createOpenClawCodingTools", () => { path: textPath, }); - expect(textResult?.content?.some((block) => block.type === "image")).toBe(false); + expect(textResult?.content?.filter((block) => block.type === "image")).toEqual([]); const textBlocks = textResult?.content?.filter((block) => block.type === "text") as | Array<{ text?: string }> | undefined; From 0c5f604fd6d9f00b8878fd89bd83cf1219287779 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:35:10 +0100 Subject: [PATCH 119/806] test: clarify websocket stream assertions --- src/agents/openai-ws-stream.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index f42007add08..c65f872059a 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -1266,8 +1266,7 @@ describe("buildAssistantMessageFromResponse", () => { it("includes both text and tool calls when both present", () => { const response = makeResponseObject("resp_4", "Running...", "exec"); const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content.some((c) => c.type === "text")).toBe(true); - expect(msg.content.some((c) => c.type === "toolCall")).toBe(true); + expect(msg.content.map((c) => c.type)).toEqual(["text", "toolCall"]); expect(msg.stopReason).toBe("toolUse"); }); @@ -1459,7 +1458,7 @@ describe("buildAssistantMessageFromResponse", () => { }; expect(msg.phase).toBeUndefined(); - expect(msg.content.some((part) => part.type === "text")).toBe(false); + expect(msg.content.filter((part) => part.type === "text")).toEqual([]); expect(msg.content).toMatchObject([{ type: "toolCall", name: "exec" }]); expect(msg.stopReason).toBe("toolUse"); }); @@ -2183,7 +2182,7 @@ describe("createOpenAIWebSocketStreamFn", () => { } | undefined; expect(doneEvent?.message.phase).toBeUndefined(); - expect(doneEvent?.message.content?.some((part) => part.type === "text")).toBe(false); + expect(doneEvent?.message.content?.filter((part) => part.type === "text")).toEqual([]); expect(doneEvent?.message.stopReason).toBe("toolUse"); }); @@ -2694,7 +2693,9 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); // The failed manager is closed before the replacement session manager is installed. - expect(MockManager.instances.some((instance) => instance.closeCallCount >= 1)).toBe(true); + expect( + MockManager.instances.filter((instance) => instance.closeCallCount >= 1).length, + ).toBeGreaterThanOrEqual(1); } finally { MockManager.globalConnectShouldFail = false; } @@ -2751,7 +2752,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); expect(events.filter((event) => event.type === "start")).toHaveLength(1); - expect(events.some((event) => event.type === "error")).toBe(false); + expect(events.filter((event) => event.type === "error")).toEqual([]); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); }); @@ -2785,7 +2786,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); expect(events.filter((event) => event.type === "start")).toHaveLength(1); - expect(events.some((event) => event.type === "error")).toBe(false); + expect(events.filter((event) => event.type === "error")).toEqual([]); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); }); @@ -3026,8 +3027,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(sent2.previous_response_id).toBe("resp_turn1"); // Input should only contain tool results, not the full history const inputTypes = (sent2.input ?? []).map((i) => i.type); - expect(inputTypes.every((t) => t === "function_call_output")).toBe(true); - expect(inputTypes).toHaveLength(1); + expect(inputTypes).toEqual(["function_call_output"]); }); it("sends only a follow-up user message when the full context is a strict extension", async () => { From fddec6d8cd899ef59fbde7c4d38dfbe5d3ec3ee3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:36:13 +0100 Subject: [PATCH 120/806] test: clarify abort listener cleanup assertion --- src/agents/pi-tools.before-tool-call.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 63a4aad8fd2..6ce5f87a3c6 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -941,7 +941,7 @@ describe("before_tool_call requireApproval handling", () => { }); expect(result.blocked).toBe(false); - expect(removeListenerSpy.mock.calls.some(([type]) => type === "abort")).toBe(true); + expect(removeListenerSpy.mock.calls.map(([type]) => type)).toContain("abort"); }); it("calls onResolution with allow-once on approval", async () => { From 4aa2fe45de0a2eb693cd3709a56089ac5ab2a78e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:37:23 +0100 Subject: [PATCH 121/806] test: clarify native hook relay retention assertion --- src/agents/harness/native-hook-relay.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index acbb2f44c20..a259b584504 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -384,7 +384,7 @@ describe("native hook relay registry", () => { const invocations = __testing.getNativeHookRelayInvocationsForTests(); expect(invocations).toHaveLength(200); - expect(invocations.some((invocation) => invocation.toolUseId === "call-0")).toBe(false); + expect(invocations.map((invocation) => invocation.toolUseId)).not.toContain("call-0"); expect(invocations.at(-1)).toEqual(expect.objectContaining({ toolUseId: "call-209" })); }); From 4624a1642f573bd4b4312ddbdc9ef5827618f27d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:38:16 +0100 Subject: [PATCH 122/806] test: clarify bootstrap warning assertions --- .../pi-embedded-helpers.buildbootstrapcontextfiles.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index c9e20be1cd7..8a52376a1a5 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -219,9 +219,9 @@ describe("buildBootstrapContextFiles", () => { expect(result).toHaveLength(1); expect(result[0]?.path).toBe("/tmp/AGENTS.md"); expect(warnings).toHaveLength(3); - expect(warnings.every((warning) => warning.includes('missing or invalid "path" field'))).toBe( - true, - ); + expect( + warnings.filter((warning) => !warning.includes('missing or invalid "path" field')), + ).toEqual([]); }); }); From 44268a134cb8893a3cd21e06aad922177c5f7448 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:39:18 +0100 Subject: [PATCH 123/806] test: clarify harness diagnostic assertions --- src/agents/harness/v2.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/harness/v2.test.ts b/src/agents/harness/v2.test.ts index d5086a017a1..3170ce4cf62 100644 --- a/src/agents/harness/v2.test.ts +++ b/src/agents/harness/v2.test.ts @@ -181,7 +181,7 @@ describe("AgentHarness V2 compatibility adapter", () => { "harness.run.started", "harness.run.completed", ]); - expect(diagnostics.events.every(({ metadata }) => metadata.trusted)).toBe(true); + expect(diagnostics.events.filter(({ metadata }) => !metadata.trusted)).toEqual([]); expect(diagnostics.events[1]?.event).toMatchObject({ type: "harness.run.completed", runId: "run-1", @@ -238,7 +238,7 @@ describe("AgentHarness V2 compatibility adapter", () => { "harness.run.started", "harness.run.error", ]); - expect(diagnostics.events.every(({ metadata }) => metadata.trusted)).toBe(true); + expect(diagnostics.events.filter(({ metadata }) => !metadata.trusted)).toEqual([]); expect(diagnostics.events[1]?.event).toMatchObject({ type: "harness.run.error", phase: "send", From 60b6b492e41e7a9927c20543798561e49d4bf4de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:40:49 +0100 Subject: [PATCH 124/806] test: clarify openai transport assertions --- src/agents/openai-transport-stream.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index f517f29edbe..522363b0353 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -998,9 +998,10 @@ describe("openai transport stream", () => { }; expect(params.instructions).toBe("Stable prefix\nDynamic suffix"); - expect(params.input?.some((item) => item.role === "system" || item.role === "developer")).toBe( - false, - ); + expect(params.input).toEqual(expect.any(Array)); + expect( + params.input?.filter((item) => item.role === "system" || item.role === "developer"), + ).toEqual([]); expect(params.prompt_cache_key).toBe("session-123"); expect(params.store).toBe(false); expect(params).not.toHaveProperty("metadata"); @@ -1299,7 +1300,7 @@ describe("openai transport stream", () => { }>; }; - expect(params.input?.some((item) => item.type === "reasoning")).toBe(true); + expect(params.input?.filter((item) => item.type === "reasoning")).toHaveLength(1); const assistantMessage = params.input?.find( (item) => item.type === "message" && item.role === "assistant", ); @@ -3386,9 +3387,9 @@ describe("openai transport stream", () => { await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream); expect(output.stopReason).toBe("stop"); - expect(output.content.some((block) => (block as { type?: string }).type === "toolCall")).toBe( - false, - ); + expect( + output.content.filter((block) => (block as { type?: string }).type === "toolCall"), + ).toEqual([]); }); it("handles reasoning_details from OpenRouter/Qwen3 in completions stream", async () => { From 5760d7f38ffc82d87826b9bf91a6dcdd48a4a4de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:41:55 +0100 Subject: [PATCH 125/806] test: clarify sandbox browser env assertion --- src/agents/sandbox/browser.create.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 116e8819c5d..efbe274ff29 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -244,9 +244,9 @@ describe("ensureSandboxBrowser create args", () => { const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); const envEntries = collectDockerFlagValues(createArgs ?? [], "-e"); - expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="))).toBe( - false, - ); + expect( + envEntries.filter((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD=")), + ).toEqual([]); expect(result?.noVncUrl).toBeUndefined(); }); From 90ba0f96904a2dd89eb2a9e99d3fa85ad7d52d44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:42:57 +0100 Subject: [PATCH 126/806] test: clarify maintenance task assertions --- .../pi-embedded-runner/context-engine-maintenance.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index c9849ef2645..079c387e562 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -598,7 +598,7 @@ describe("runContextEngineMaintenance", () => { (task) => task.taskKind === TURN_MAINTENANCE_TASK_KIND, ); expect(completedTasks).toHaveLength(2); - expect(completedTasks.every((task) => task.status === "succeeded")).toBe(true); + expect(completedTasks.filter((task) => task.status !== "succeeded")).toEqual([]); await foregroundTurn; } finally { @@ -672,7 +672,7 @@ describe("runContextEngineMaintenance", () => { (task) => task.taskKind === TURN_MAINTENANCE_TASK_KIND, ); expect(tasks).toHaveLength(2); - expect(tasks.every((task) => task.status === "succeeded")).toBe(true); + expect(tasks.filter((task) => task.status !== "succeeded")).toEqual([]); } finally { vi.useRealTimers(); } @@ -740,7 +740,9 @@ describe("runContextEngineMaintenance", () => { status: "cancelled", notifyPolicy: "silent", }); - expect(tasks.some((task) => task.runId?.startsWith("turn-maint:"))).toBe(true); + expect(tasks.map((task) => task.runId)).toContainEqual( + expect.stringMatching(/^turn-maint:/), + ); } finally { vi.useRealTimers(); } From d0402671c680474abdcc4f7f729f6ad39cf98687 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:41:57 +0100 Subject: [PATCH 127/806] fix: make orphan attachment pruning deterministic --- src/agents/subagent-registry-helpers.ts | 47 ++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index d4b384140e4..f8adb4e27b0 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import fsSync, { promises as fs } from "node:fs"; import path from "node:path"; import { getRuntimeConfig } from "../config/config.js"; import { @@ -178,6 +178,13 @@ export function resolveSubagentRunOrphanReason(params: { } } +function isResolvedChildPath(params: { childPath: string; rootPath: string }) { + const rootWithSep = params.rootPath.endsWith(path.sep) + ? params.rootPath + : `${params.rootPath}${path.sep}`; + return params.childPath.startsWith(rootWithSep); +} + export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise { if (!entry.attachmentsDir || !entry.attachmentsRootDir) { return; @@ -205,8 +212,7 @@ export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promis const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir); const dirBase = dirReal; - const rootWithSep = rootBase.endsWith(path.sep) ? rootBase : `${rootBase}${path.sep}`; - if (!dirBase.startsWith(rootWithSep)) { + if (!isResolvedChildPath({ childPath: dirBase, rootPath: rootBase })) { return; } await fs.rm(dirBase, { recursive: true, force: true }); @@ -215,6 +221,39 @@ export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promis } } +function safeRemoveAttachmentsDirSync(entry: SubagentRunRecord): void { + if (!entry.attachmentsDir || !entry.attachmentsRootDir) { + return; + } + + const resolveReal = (targetPath: string): string | null => { + try { + return fsSync.realpathSync.native(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return null; + } + throw err; + } + }; + + try { + const rootReal = resolveReal(entry.attachmentsRootDir); + const dirReal = resolveReal(entry.attachmentsDir); + if (!dirReal) { + return; + } + + const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir); + if (!isResolvedChildPath({ childPath: dirReal, rootPath: rootBase })) { + return; + } + fsSync.rmSync(dirReal, { recursive: true, force: true }); + } catch { + // best effort + } +} + export function reconcileOrphanedRun(params: { runId: string; entry: SubagentRunRecord; @@ -258,7 +297,7 @@ export function reconcileOrphanedRun(params: { const shouldDeleteAttachments = params.entry.cleanup === "delete" || !params.entry.retainAttachmentsOnKeep; if (shouldDeleteAttachments) { - void safeRemoveAttachmentsDir(params.entry); + safeRemoveAttachmentsDirSync(params.entry); } const removed = params.runs.delete(params.runId); params.resumedRuns.delete(params.runId); From 0ff4ff4667c6c74449e8e8dbc03fa62852e920b0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 15:47:18 +0530 Subject: [PATCH 128/806] fix(qa-lab): harden mock telegram prompt routing --- .../src/providers/mock-openai/server.test.ts | 93 +++++++++++++++++++ .../src/providers/mock-openai/server.ts | 11 ++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 26c3b06e2e5..c1a699a90c2 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -1853,6 +1853,99 @@ describe("qa mock openai server", () => { }); }); + it("lets the latest exact marker prompt beat stale Telegram session_status history", async () => { + const server = await startQaMockOpenAiServer({ + host: "127.0.0.1", + port: 0, + }); + cleanups.push(async () => { + await server.stop(); + }); + + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: false, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram current session_status QA check. Call session_status with sessionKey set to current.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram reply-chain marker QA. Reply exactly: QA-TELEGRAM-REPLY-CHAIN-OK", + }, + ], + }, + ], + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + output: [ + { + content: [{ text: "QA-TELEGRAM-REPLY-CHAIN-OK" }], + }, + ], + }); + }); + + it("does not repeat stale Telegram session_status for later ordinary prompts", async () => { + const server = await startQaMockOpenAiServer({ + host: "127.0.0.1", + port: 0, + }); + cleanups.push(async () => { + await server.stop(); + }); + + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: false, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram current session_status QA check. Call session_status with sessionKey set to current.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "@sut Telegram QA mention routing check. Reply with a short acknowledgement.", + }, + ], + }, + ], + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(JSON.stringify(payload)).not.toContain("QA-TELEGRAM-CURRENT-SESSION"); + }); + it("uses exact marker directives from request context when the latest user text is generic", async () => { const server = await startQaMockOpenAiServer({ host: "127.0.0.1", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index c775b3127c0..c27bffdc0c2 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -1421,7 +1421,16 @@ async function buildResponsesPayload( exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK", ); } - if (QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(allInputText)) { + if (/\bmarker\b/i.test(prompt) && exactReplyDirective) { + return buildAssistantEvents(exactReplyDirective); + } + if (/\bmarker\b/i.test(prompt) && exactMarkerDirective) { + return buildAssistantEvents(exactMarkerDirective); + } + const isTelegramCurrentSessionStatusTurn = + QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(prompt) || + (Boolean(toolOutput) && QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(allInputText)); + if (isTelegramCurrentSessionStatusTurn) { if (!toolOutput && hasDeclaredTool(body, "session_status")) { return buildToolCallEventsWithArgs("session_status", { sessionKey: "current" }); } From ec54642581a14ba679429a77c84c4cd95c3fdab3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 15:47:23 +0530 Subject: [PATCH 129/806] test(qa-lab): expand telegram e2e defaults --- .github/workflows/openclaw-release-checks.yml | 2 +- .../telegram/telegram-live.runtime.test.ts | 85 ++++++++++++++- .../telegram/telegram-live.runtime.ts | 101 ++++++++++++++++-- 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 8e243186504..e7df3eb8c0d 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -599,7 +599,7 @@ jobs: published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }} published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }} telegram_mode: mock-openai - telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating + telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index af531c34560..da26cafa899 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -330,9 +330,12 @@ describe("telegram live qa runtime", () => { "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", "telegram-current-session-status-tool", "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", "telegram-stream-final-single-message", "telegram-long-final-reuses-preview", "telegram-long-final-three-chunks", @@ -343,24 +346,55 @@ describe("telegram live qa runtime", () => { "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", "telegram-current-session-status-tool", "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", "telegram-stream-final-single-message", "telegram-long-final-reuses-preview", "telegram-long-final-three-chunks", "telegram-mention-gating", ]); + expect( + scenarios.find((scenario) => scenario.id === "telegram-status-command")?.buildRun("sut_bot") + .input, + ).toBe("/status@sut_bot"); + expect( + scenarios.find((scenario) => scenario.id === "telegram-status-command")?.buildRun("sut_bot") + .expectedTextIncludes, + ).toEqual(["OpenClaw", "Model:", "Session:", "Activation:"]); + expect( + scenarios + .find((scenario) => scenario.id === "telegram-other-bot-command-gating") + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectReply: false, + input: "/status@OpenClawQaOtherBot", + }); expect( scenarios .find((scenario) => scenario.id === "telegram-current-session-status-tool") - ?.buildRun("sut_bot").expectedTextIncludes, - ).toEqual(["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"]); + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectedTextIncludes: ["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"], + replyToLatestSutMessage: true, + }); expect( scenarios .find((scenario) => scenario.id === "telegram-mentioned-message-reply") ?.buildRun("sut_bot").replyToLatestSutMessage, ).toBe(true); + expect( + scenarios + .find((scenario) => scenario.id === "telegram-reply-chain-exact-marker") + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectedJoinedSutTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedSutMessageCount: 1, + replyToLatestSutMessage: true, + }); expect( scenarios .find((scenario) => scenario.id === "telegram-stream-final-single-message") @@ -393,17 +427,60 @@ describe("telegram live qa runtime", () => { }); }); - it("keeps bot-to-bot plain mentions out of the default Telegram live set", () => { - expect(__testing.findScenario().map((scenario) => scenario.id)).toEqual([ + it("keeps mock-scripted Telegram checks out of the default live-frontier set", () => { + expect( + __testing.findScenario(undefined, "live-frontier").map((scenario) => scenario.id), + ).toEqual([ "telegram-help-command", "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", + "telegram-mentioned-message-reply", "telegram-mention-gating", ]); }); + it("adds deterministic model-scripted checks to the default mock-openai set", () => { + expect(__testing.findScenario(undefined, "mock-openai").map((scenario) => scenario.id)).toEqual( + [ + "telegram-help-command", + "telegram-commands-command", + "telegram-tools-compact-command", + "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", + "telegram-context-command", + "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", + "telegram-stream-final-single-message", + "telegram-long-final-reuses-preview", + "telegram-mention-gating", + ], + ); + }); + + it("lists default status and regression refs in the Telegram scenario catalog", () => { + const catalog = __testing.listTelegramQaScenarioCatalog("mock-openai"); + expect(catalog.find((scenario) => scenario.id === "telegram-status-command")).toMatchObject({ + defaultEnabled: true, + regressionRefs: ["openclaw/openclaw#74698"], + }); + expect( + catalog.find((scenario) => scenario.id === "telegram-current-session-status-tool"), + ).toMatchObject({ + defaultEnabled: false, + }); + expect( + catalog.find((scenario) => scenario.id === "telegram-stream-final-single-message"), + ).toMatchObject({ + defaultEnabled: true, + regressionRefs: ["openclaw/openclaw#39905"], + }); + }); + it("tracks Telegram live coverage against the shared transport contract", () => { expect(__testing.TELEGRAM_QA_STANDARD_SCENARIO_IDS).toEqual([ "canary", diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 8e17e1e36b9..93d10769d61 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -13,6 +13,7 @@ import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js"; import { defaultQaModelForMode, normalizeQaProviderMode, + type QaProviderMode, type QaProviderModeInput, } from "../../run-config.js"; import { @@ -46,11 +47,14 @@ type TelegramQaScenarioId = | "telegram-commands-command" | "telegram-tools-compact-command" | "telegram-whoami-command" + | "telegram-status-command" + | "telegram-other-bot-command-gating" | "telegram-context-command" | "telegram-current-session-status-tool" | "telegram-stream-final-single-message" | "telegram-long-final-three-chunks" | "telegram-long-final-reuses-preview" + | "telegram-reply-chain-exact-marker" | "telegram-mentioned-message-reply" | "telegram-mention-gating"; @@ -69,6 +73,9 @@ type TelegramQaScenarioRun = { type TelegramQaScenarioDefinition = LiveTransportScenarioDefinition & { buildRun: (sutUsername: string) => TelegramQaScenarioRun; defaultEnabled?: boolean; + defaultProviderModes?: readonly QaProviderMode[]; + regressionRefs?: readonly string[]; + rationale: string; }; type TelegramObservedMessage = { @@ -231,6 +238,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-help-command", standardId: "help-command", title: "Telegram help command reply", + rationale: "Canary-grade native command reply path.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -241,6 +249,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-commands-command", title: "Telegram commands list reply", + rationale: "Native command catalog must render in Telegram group replies.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -251,6 +260,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-tools-compact-command", title: "Telegram tools compact reply", + rationale: "Tool catalog rendering catches command dispatch plus model-tool inventory drift.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -261,6 +271,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-whoami-command", title: "Telegram whoami reply", + rationale: "Identity command proves Telegram channel context is attached to native commands.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -268,9 +279,32 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ expectedTextIncludes: ["🧭 Identity", "Channel: telegram"], }), }, + { + id: "telegram-status-command", + title: "Telegram status command reply", + rationale: "Recent Telegram group regressions broke /status while normal chat still worked.", + regressionRefs: ["openclaw/openclaw#74698"], + timeoutMs: 45_000, + buildRun: (sutUsername) => ({ + expectReply: true, + input: `/status@${sutUsername}`, + expectedTextIncludes: ["OpenClaw", "Model:", "Session:", "Activation:"], + }), + }, + { + id: "telegram-other-bot-command-gating", + title: "Telegram command addressed to another bot is ignored", + rationale: "Bot-to-bot groups must not let commands addressed to another bot wake the SUT.", + timeoutMs: 8_000, + buildRun: () => ({ + expectReply: false, + input: "/status@OpenClawQaOtherBot", + }), + }, { id: "telegram-context-command", title: "Telegram context reply", + rationale: "Context command exercises native command routing into Telegram-specific help text.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -282,29 +316,49 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-current-session-status-tool", title: "Telegram current session_status tool call", defaultEnabled: false, + rationale: + "Opt-in threaded probe for current Telegram group session resolution through model tools.", timeoutMs: 60_000, buildRun: (sutUsername) => ({ expectReply: true, input: `@${sutUsername} Telegram current session_status QA check. Call session_status with sessionKey set to current, then reply with the exact QA marker and resolved session key.`, expectedTextIncludes: ["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"], + replyToLatestSutMessage: true, }), }, { id: "telegram-mentioned-message-reply", title: "Telegram mentioned message gets a reply", - defaultEnabled: false, + rationale: "Bot-to-bot group mention routing must produce a threaded SUT reply.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ - allowAnySutReply: true, expectReply: true, input: `@${sutUsername} Telegram QA mention routing check. Reply with a short acknowledgement.`, replyToLatestSutMessage: true, }), }, + { + id: "telegram-reply-chain-exact-marker", + title: "Telegram reply-chain exact marker", + defaultProviderModes: ["mock-openai"], + rationale: "Mock-backed reply-chain check proves quoted bot-to-bot follow-ups keep threading.", + timeoutMs: 45_000, + buildRun: (sutUsername) => ({ + expectReply: true, + input: `@${sutUsername} Telegram reply-chain marker QA. Reply exactly: QA-TELEGRAM-REPLY-CHAIN-OK`, + expectedTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedJoinedSutTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedSutMessageCount: 1, + replyToLatestSutMessage: true, + settleMs: 4_000, + }), + }, { id: "telegram-stream-final-single-message", title: "Telegram streamed final stays one message", - defaultEnabled: false, + defaultProviderModes: ["mock-openai"], + rationale: "Regression guard for duplicate final replies from Telegram streaming paths.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 45_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -320,7 +374,9 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-long-final-reuses-preview", title: "Telegram long final reuses the preview message", - defaultEnabled: false, + defaultProviderModes: ["mock-openai"], + rationale: "Regression guard for long streamed finals leaving stale preview messages behind.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 60_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -337,6 +393,8 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-long-final-three-chunks", title: "Telegram three-chunk final keeps only final chunks", defaultEnabled: false, + rationale: "Opt-in stress probe for Telegram long final chunk accounting.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 60_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -356,6 +414,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-mention-gating", standardId: "mention-gating", title: "Telegram group message without mention does not trigger", + rationale: "Required group mention gate should suppress ordinary group chatter.", timeoutMs: 8_000, buildRun: () => { const token = `TELEGRAM_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`; @@ -1020,11 +1079,26 @@ function buildObservedMessagesArtifact(params: { }); } -function findScenario(ids?: string[]) { +function shouldRunTelegramScenarioByDefault( + scenario: TelegramQaScenarioDefinition, + providerMode: QaProviderMode, +) { + if (scenario.defaultEnabled === false) { + return false; + } + return !scenario.defaultProviderModes || scenario.defaultProviderModes.includes(providerMode); +} + +function findScenario( + ids?: string[], + providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE, +) { const scenarios = ids && ids.length > 0 ? TELEGRAM_QA_SCENARIOS - : TELEGRAM_QA_SCENARIOS.filter((scenario) => scenario.defaultEnabled !== false); + : TELEGRAM_QA_SCENARIOS.filter((scenario) => + shouldRunTelegramScenarioByDefault(scenario, providerMode), + ); return selectLiveTransportScenarios({ ids, laneLabel: "Telegram", @@ -1032,6 +1106,18 @@ function findScenario(ids?: string[]) { }); } +export function listTelegramQaScenarioCatalog( + providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE, +) { + return TELEGRAM_QA_SCENARIOS.map((scenario) => ({ + id: scenario.id, + title: scenario.title, + defaultEnabled: shouldRunTelegramScenarioByDefault(scenario, providerMode), + rationale: scenario.rationale, + regressionRefs: [...(scenario.regressionRefs ?? [])], + })); +} + function matchesTelegramScenarioReply(params: { groupId: string; allowAnySutReply?: boolean; @@ -1340,7 +1426,7 @@ export async function runTelegramQaLive(params: { const primaryModel = params.primaryModel?.trim() || defaultQaModelForMode(providerMode); const alternateModel = params.alternateModel?.trim() || defaultQaModelForMode(providerMode, true); const sutAccountId = params.sutAccountId?.trim() || "sut"; - const scenarios = findScenario(params.scenarioIds); + const scenarios = findScenario(params.scenarioIds, providerMode); const progressEnabled = shouldLogTelegramQaLiveProgress(); writeTelegramQaProgress( progressEnabled, @@ -1754,6 +1840,7 @@ export const __testing = { assertTelegramScenarioReply, classifyCanaryReply, findScenario, + listTelegramQaScenarioCatalog, matchesTelegramScenarioReply, normalizeTelegramObservedMessage, parseTelegramQaProgressBooleanEnv, From 5cd4996205c6a333926daeb2d85e57412fe77026 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 15:47:29 +0530 Subject: [PATCH 130/806] feat(qa-lab): list telegram live scenarios --- extensions/qa-lab/src/cli.runtime.test.ts | 29 +++++++++++++++++++ extensions/qa-lab/src/cli.test.ts | 13 ++++++++- .../shared/live-transport-cli.runtime.test.ts | 2 ++ .../shared/live-transport-cli.runtime.ts | 1 + .../shared/live-transport-cli.ts | 10 +++++++ .../live-transports/telegram/cli.runtime.ts | 13 ++++++++- .../src/live-transports/telegram/cli.ts | 1 + 7 files changed, 67 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index 1a1af099647..45ee7b56a71 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -8,6 +8,7 @@ const { runQaSuiteFromRuntime, runQaCharacterEval, runQaMultipass, + listTelegramQaScenarioCatalog, runTelegramQaLive, startQaLabServer, writeQaDockerHarnessFiles, @@ -19,6 +20,7 @@ const { runQaSuiteFromRuntime: vi.fn(), runQaCharacterEval: vi.fn(), runQaMultipass: vi.fn(), + listTelegramQaScenarioCatalog: vi.fn(), runTelegramQaLive: vi.fn(), startQaLabServer: vi.fn(), writeQaDockerHarnessFiles: vi.fn(), @@ -45,6 +47,7 @@ vi.mock("./multipass.runtime.js", () => ({ })); vi.mock("./live-transports/telegram/telegram-live.runtime.js", () => ({ + listTelegramQaScenarioCatalog, runTelegramQaLive, })); @@ -111,6 +114,7 @@ describe("qa cli runtime", () => { runQaCharacterEval.mockReset(); runQaManualLane.mockReset(); runQaMultipass.mockReset(); + listTelegramQaScenarioCatalog.mockReset(); runTelegramQaLive.mockReset(); startQaLabServer.mockReset(); writeQaDockerHarnessFiles.mockReset(); @@ -153,6 +157,15 @@ describe("qa cli runtime", () => { observedMessagesPath: "/tmp/telegram/observed.json", scenarios: [], }); + listTelegramQaScenarioCatalog.mockReturnValue([ + { + id: "telegram-status-command", + title: "Telegram status command reply", + defaultEnabled: true, + rationale: "status rationale", + regressionRefs: ["openclaw/openclaw#74698"], + }, + ]); startQaLabServer.mockResolvedValue({ baseUrl: "http://127.0.0.1:58000", runSelfCheck: vi.fn().mockResolvedValue({ @@ -297,6 +310,22 @@ describe("qa cli runtime", () => { ); }); + it("prints telegram scenario catalog without starting the live lane", async () => { + await runQaTelegramCommand({ + repoRoot: "/tmp/openclaw-repo", + providerMode: "mock-openai", + listScenarios: true, + }); + + expect(listTelegramQaScenarioCatalog).toHaveBeenCalledWith("mock-openai"); + expect(runTelegramQaLive).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + "telegram-status-command\tdefault\tTelegram status command reply\tstatus rationale refs=openclaw/openclaw#74698", + ), + ); + }); + it("sets a failing exit code when telegram scenarios fail", async () => { const priorExitCode = process.exitCode; process.exitCode = undefined; diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index 760f803d55b..a28dafee5f0 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -384,7 +384,7 @@ describe("qa cli registration", () => { const optionNames = telegram?.options.map((option) => option.long) ?? []; expect(optionNames).toEqual( - expect.arrayContaining(["--credential-source", "--credential-role"]), + expect.arrayContaining(["--credential-source", "--credential-role", "--list-scenarios"]), ); }); @@ -434,12 +434,23 @@ describe("qa cli registration", () => { fastMode: false, allowFailures: false, scenarioIds: [], + listScenarios: false, sutAccountId: "sut", credentialSource: undefined, credentialRole: undefined, }); }); + it("forwards --list-scenarios for telegram runs", async () => { + await program.parseAsync(["node", "openclaw", "qa", "telegram", "--list-scenarios"]); + + expect(runQaTelegramCommand).toHaveBeenCalledWith( + expect.objectContaining({ + listScenarios: true, + }), + ); + }); + it("forwards --allow-failures for telegram runs", async () => { await program.parseAsync(["node", "openclaw", "qa", "telegram", "--allow-failures"]); diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts index f2bcf0fd072..16a65c2480e 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts @@ -10,12 +10,14 @@ describe("resolveLiveTransportQaRunOptions", () => { providerMode: "live-frontier", primaryModel: " ", alternateModel: "", + listScenarios: true, }), ).toMatchObject({ repoRoot: path.resolve("/tmp/openclaw-repo"), providerMode: "live-frontier", primaryModel: undefined, alternateModel: undefined, + listScenarios: true, }); }); }); diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts index ed8eed64e73..348f33119b8 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts @@ -31,6 +31,7 @@ export function resolveLiveTransportQaRunOptions( fastMode: opts.fastMode, allowFailures: opts.allowFailures, scenarioIds: opts.scenarioIds, + listScenarios: opts.listScenarios, sutAccountId: opts.sutAccountId, credentialSource: opts.credentialSource?.trim(), credentialRole: opts.credentialRole?.trim(), diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts index a0d9737dcdc..bdde85766f6 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts @@ -12,6 +12,7 @@ export type LiveTransportQaCommandOptions = { fastMode?: boolean; allowFailures?: boolean; scenarioIds?: string[]; + listScenarios?: boolean; sutAccountId?: string; credentialSource?: string; credentialRole?: string; @@ -24,6 +25,7 @@ type LiveTransportQaCommanderOptions = { model?: string; altModel?: string; scenario?: string[]; + listScenarios?: boolean; fast?: boolean; allowFailures?: boolean; sutAccount?: string; @@ -61,6 +63,7 @@ function mapLiveTransportQaCommanderOptions( fastMode: opts.fast, allowFailures: opts.allowFailures, scenarioIds: opts.scenario, + listScenarios: opts.listScenarios, sutAccountId: opts.sutAccount, credentialSource: opts.credentialSource, credentialRole: opts.credentialRole, @@ -72,6 +75,7 @@ function registerLiveTransportQaCli(params: { commandName: string; credentialOptions?: LiveTransportQaCredentialCliOptions; description: string; + listScenariosHelp?: string; outputDirHelp: string; scenarioHelp: string; sutAccountHelp: string; @@ -94,6 +98,10 @@ function registerLiveTransportQaCli(params: { ) .option("--sut-account ", params.sutAccountHelp, "sut"); + if (params.listScenariosHelp) { + command.option("--list-scenarios", params.listScenariosHelp, false); + } + if (params.credentialOptions) { command.option( "--credential-source ", @@ -114,6 +122,7 @@ export function createLiveTransportQaCliRegistration(params: { commandName: string; credentialOptions?: LiveTransportQaCredentialCliOptions; description: string; + listScenariosHelp?: string; outputDirHelp: string; scenarioHelp: string; sutAccountHelp: string; @@ -127,6 +136,7 @@ export function createLiveTransportQaCliRegistration(params: { commandName: params.commandName, credentialOptions: params.credentialOptions, description: params.description, + listScenariosHelp: params.listScenariosHelp, outputDirHelp: params.outputDirHelp, scenarioHelp: params.scenarioHelp, sutAccountHelp: params.sutAccountHelp, diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts index c07e4ea8d04..a18f9d3a18b 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts @@ -3,10 +3,21 @@ import { printLiveTransportQaArtifacts, resolveLiveTransportQaRunOptions, } from "../shared/live-transport-cli.runtime.js"; -import { runTelegramQaLive } from "./telegram-live.runtime.js"; +import { listTelegramQaScenarioCatalog, runTelegramQaLive } from "./telegram-live.runtime.js"; export async function runQaTelegramCommand(opts: LiveTransportQaCommandOptions) { const runOptions = resolveLiveTransportQaRunOptions(opts); + if (runOptions.listScenarios) { + for (const scenario of listTelegramQaScenarioCatalog(runOptions.providerMode)) { + const defaultLabel = scenario.defaultEnabled ? "default" : "optional"; + const refs = + scenario.regressionRefs.length > 0 ? ` refs=${scenario.regressionRefs.join(",")}` : ""; + process.stdout.write( + `${scenario.id}\t${defaultLabel}\t${scenario.title}\t${scenario.rationale}${refs}\n`, + ); + } + return; + } const result = await runTelegramQaLive(runOptions); printLiveTransportQaArtifacts("Telegram QA", { report: result.reportPath, diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.ts b/extensions/qa-lab/src/live-transports/telegram/cli.ts index b0f2c0de177..d7638a25945 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.ts @@ -25,6 +25,7 @@ export const telegramQaCliRegistration: LiveTransportQaCliRegistration = "Credential role for convex auth: maintainer or ci (default: ci in CI, maintainer otherwise)", }, description: "Run the manual Telegram live QA lane against a private bot-to-bot group harness", + listScenariosHelp: "Print available Telegram scenario ids and exit", outputDirHelp: "Telegram QA artifact directory", scenarioHelp: "Run only the named Telegram QA scenario (repeatable)", sutAccountHelp: "Temporary Telegram account id inside the QA gateway config", From 5e27993cbec9bcfa23bebb082f4dda2cd46b63fe Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 15:47:35 +0530 Subject: [PATCH 131/806] docs(qa): document telegram e2e defaults --- docs/concepts/qa-e2e-automation.md | 9 ++++++++- docs/help/testing.md | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 56757fde9ee..3565c7f50be 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -278,7 +278,7 @@ Optional: - `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1` keeps message bodies in observed-message artifacts (default redacts). -Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts:44`): +Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts`): - `telegram-canary` - `telegram-mention-gating` @@ -287,10 +287,17 @@ Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime - `telegram-commands-command` - `telegram-tools-compact-command` - `telegram-whoami-command` +- `telegram-status-command` +- `telegram-other-bot-command-gating` - `telegram-context-command` +- `telegram-current-session-status-tool` +- `telegram-reply-chain-exact-marker` +- `telegram-stream-final-single-message` - `telegram-long-final-reuses-preview` - `telegram-long-final-three-chunks` +The implicit default set always covers canary, mention gating, native command replies, command addressing, and bot-to-bot group replies. `mock-openai` defaults also include deterministic reply-chain and final-message streaming checks. `telegram-current-session-status-tool` remains opt-in because it is only stable when threaded directly after canary, not after arbitrary native command replies. Use `pnpm openclaw qa telegram --list-scenarios --provider-mode mock-openai` to print the current default/optional split with regression refs. + Output artifacts: - `telegram-qa-report.md` diff --git a/docs/help/testing.md b/docs/help/testing.md index 9c5df8fe205..6c43e7a004f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -311,6 +311,7 @@ gh workflow run package-acceptance.yml --ref main \ - Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env. - Requires `OPENCLAW_QA_TELEGRAM_GROUP_ID`, `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`, and `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`. The group id must be the numeric Telegram chat id. - Supports `--credential-source convex` for shared pooled credentials. Use env mode by default, or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex` to opt into pooled leases. + - Defaults cover canary, mention gating, command addressing, `/status`, bot-to-bot mentioned replies, and core native command replies. `mock-openai` defaults also cover deterministic reply-chain and Telegram final-message streaming regressions. Use `--list-scenarios` for optional probes such as `session_status`. - Exits non-zero when any scenario fails. Use `--allow-failures` when you want artifacts without a failing exit code. - Requires two distinct bots in the same private group, with the SUT bot exposing a Telegram username. From 07a850a5fb6004d07dba66ef7731a23b0c5b2e53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:44:51 +0100 Subject: [PATCH 132/806] test: clarify websocket error assertions --- src/agents/openai-ws-connection.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index 5f164e3d0e2..a4cb33efda5 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -775,7 +775,7 @@ describe("OpenAIWebSocketManager", () => { lastSocket().simulateError(new Error("SSL handshake failed")); await p; - expect(errors.some((e) => e.message === "SSL handshake failed")).toBe(true); + expect(errors.map((error) => error.message)).toContain("SSL handshake failed"); }); it("handles multiple successive socket errors without crashing", async () => { @@ -790,8 +790,9 @@ describe("OpenAIWebSocketManager", () => { await p; expect(errors.length).toBeGreaterThanOrEqual(2); - expect(errors.some((e) => e.message === "first error")).toBe(true); - expect(errors.some((e) => e.message === "second error")).toBe(true); + expect(errors.map((error) => error.message)).toEqual( + expect.arrayContaining(["first error", "second error"]), + ); }); }); From 607f0b4a9dd555e526330cf92c9daf0f4ad858b6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:46:22 +0100 Subject: [PATCH 133/806] test: clear remaining agent assertion scans --- .../run/attempt.spawn-workspace.context-engine.test.ts | 2 +- .../pi-embedded-runner/run/idle-timeout-breaker.test.ts | 4 ++-- src/agents/pi-embedded-runner/run/images.test.ts | 2 +- src/agents/pi-embedded-runner/transcript-rewrite.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index e3db6aa8ab9..8e2fda36bc8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -631,7 +631,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { .trim() .split("\n") .map((line) => JSON.parse(line) as TrajectoryEvent); - expect(trajectoryEvents.some((event) => event.type === "prompt.submitted")).toBe(false); + expect(trajectoryEvents.filter((event) => event.type === "prompt.submitted")).toEqual([]); expect(trajectoryEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts index d168c3c481b..4d4ee31c0c6 100644 --- a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts +++ b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts @@ -79,7 +79,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { ], { cap: 0 }, ); - expect(steps.every((s) => !s.tripped)).toBe(true); + expect(steps.filter((step) => step.tripped)).toEqual([]); expect(steps.at(-1)?.consecutive).toBe(7); }); @@ -94,7 +94,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { outputTokens: 220, })), ); - expect(steps.every((s) => !s.tripped)).toBe(true); + expect(steps.filter((step) => step.tripped)).toEqual([]); expect(steps.at(-1)?.consecutive).toBe(0); }); diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index 0d81b9dc42e..fc7545fda3c 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -80,7 +80,7 @@ describe("detectImageReferences", () => { 1, ); - expect(refs.some((r) => r.type === "path")).toBe(true); + expect(refs.map((ref) => ref.type)).toContain("path"); }); it("does not leak parser state between calls", () => { diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts index 90005a9a5f5..2bb36abcf77 100644 --- a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts +++ b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts @@ -215,7 +215,7 @@ describe("rewriteTranscriptEntriesInSessionManager", () => { "rewritten summary entry", ); expect(sessionManager.getLabel(rewrittenSummaryEntry.id)).toBe("bookmark"); - expect(sessionManager.getBranch().some((entry) => entry.type === "label")).toBe(true); + expect(sessionManager.getBranch().map((entry) => entry.type)).toContain("label"); }); it("remaps compaction keep markers when rewritten entries change ids", () => { From b332f06e30d1305149c41c8ab01877c0fa3aa67e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:47:49 +0100 Subject: [PATCH 134/806] test: clarify google meet setup assertions --- extensions/google-meet/index.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index d0250e3ffbf..a1d5afad07e 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1865,9 +1865,9 @@ describe("google-meet plugin", () => { }), ]), ); - expect(result.details.checks?.some((check) => check.id === "chrome-local-audio-device")).toBe( - false, - ); + expect( + result.details.checks?.filter((check) => check.id === "chrome-local-audio-device"), + ).toEqual([]); expect(runCommandWithTimeout).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, "platform", { value: originalPlatform }); From 774e8a7054a67f70514461d95efd5739ba2fc8d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:49:00 +0100 Subject: [PATCH 135/806] test: clarify memory core assertions --- extensions/memory-core/src/dreaming-narrative.test.ts | 2 +- extensions/memory-core/src/dreaming-repair.test.ts | 4 ++-- .../memory-core/src/memory/manager-embedding-policy.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index bf662fe53b8..9708e5b434b 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -979,7 +979,7 @@ describe("generateAndAppendDreamNarrative", () => { expect(updatedStore).toHaveProperty("agent:main:kept-session"); expect(updatedStore).toHaveProperty("agent:main:telegram:group:dreaming-narrative-room"); const sessionFiles = await fs.readdir(sessionsDir); - expect(sessionFiles.some((name) => name.startsWith("orphan.jsonl.deleted."))).toBe(true); + expect(sessionFiles).toContainEqual(expect.stringMatching(/^orphan\.jsonl\.deleted\./)); expect(sessionFiles).toContain("still-live.jsonl"); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("dreaming cleanup scrubbed")); }); diff --git a/extensions/memory-core/src/dreaming-repair.test.ts b/extensions/memory-core/src/dreaming-repair.test.ts index cd1217316b1..8db7135f59e 100644 --- a/extensions/memory-core/src/dreaming-repair.test.ts +++ b/extensions/memory-core/src/dreaming-repair.test.ts @@ -122,7 +122,7 @@ describe("dreaming artifact repair", () => { ).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary"); const archivedEntries = await fs.readdir(repair.archiveDir!); - expect(archivedEntries.some((entry) => entry.startsWith("session-corpus."))).toBe(true); - expect(archivedEntries.some((entry) => entry.startsWith("session-ingestion.json."))).toBe(true); + expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-corpus\./)); + expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-ingestion\.json\./)); }); }); diff --git a/extensions/memory-core/src/memory/manager-embedding-policy.test.ts b/extensions/memory-core/src/memory/manager-embedding-policy.test.ts index 1332cc5eabf..d7f090e6fb6 100644 --- a/extensions/memory-core/src/memory/manager-embedding-policy.test.ts +++ b/extensions/memory-core/src/memory/manager-embedding-policy.test.ts @@ -23,7 +23,7 @@ describe("memory embedding policy", () => { const batches = buildMemoryEmbeddingBatches([chunk(line), chunk(line)], 8000); expect(batches).toHaveLength(2); - expect(batches.every((batch) => batch.length === 1)).toBe(true); + expect(batches.map((batch) => batch.length)).toEqual([1, 1]); }); it("keeps small files in a single embedding batch", () => { From 7ebcce6a3d68d0e24b147c995c008d836466fadb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:50:46 +0100 Subject: [PATCH 136/806] test: clarify qmd manager assertions --- .../memory-core/src/memory/qmd-manager.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 9fc25f39a20..d319e81b009 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2468,8 +2468,8 @@ describe("QmdMemoryManager", () => { isMcporterCommand(call[0]), ); expect(mcporterCalls.length).toBeGreaterThan(0); - expect(mcporterCalls.some((call: unknown[]) => (call[1] as string[])[0] === "daemon")).toBe( - false, + expect(mcporterCalls.map((call: unknown[]) => (call[1] as string[])[0])).not.toContain( + "daemon", ); expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("cold-start")); @@ -2970,7 +2970,7 @@ describe("QmdMemoryManager", () => { await manager.search("hello again", { sessionKey: "agent:main:slack:dm:u123" }); expect(selectors.length).toBeGreaterThanOrEqual(2); - expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); + expect(selectors.filter((selector) => selector !== "qmd.query")).toEqual([]); expect(logWarnMock).not.toHaveBeenCalledWith( expect.stringContaining("falling back to v1 tool names"), ); @@ -3039,7 +3039,7 @@ describe("QmdMemoryManager", () => { expect(runMcporterSpy).toHaveBeenCalled(); expect(selectors.length).toBeGreaterThanOrEqual(1); - expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); + expect(selectors.filter((selector) => selector !== "qmd.query")).toEqual([]); expect(logWarnMock).not.toHaveBeenCalledWith( expect.stringContaining("falling back to v1 tool names"), ); @@ -3396,8 +3396,9 @@ describe("QmdMemoryManager", () => { }); expect(results).toHaveLength(4); - expect(results.some((entry) => entry.source === "memory")).toBe(true); - expect(results.some((entry) => entry.source === "sessions")).toBe(true); + expect(results.map((entry) => entry.source)).toEqual( + expect.arrayContaining(["memory", "sessions"]), + ); await manager.close(); }); From 05fd67f8222df58a6f1efd5ce6277a0865ce26ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:51:58 +0100 Subject: [PATCH 137/806] test: clarify nvidia provider assertions --- extensions/nvidia/index.test.ts | 2 +- extensions/nvidia/provider-catalog.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/nvidia/index.test.ts b/extensions/nvidia/index.test.ts index 75a49b465f4..6d73f429115 100644 --- a/extensions/nvidia/index.test.ts +++ b/extensions/nvidia/index.test.ts @@ -129,7 +129,7 @@ describe("nvidia provider hooks", () => { "minimaxai/minimax-m2.5", "z-ai/glm5", ]); - expect(entries?.every((entry) => entry.provider === "nvidia")).toBe(true); + expect(entries?.filter((entry) => entry.provider !== "nvidia")).toEqual([]); }); it("opts into literal provider-prefix preservation", async () => { diff --git a/extensions/nvidia/provider-catalog.test.ts b/extensions/nvidia/provider-catalog.test.ts index 79139b400a3..eb0ad971fff 100644 --- a/extensions/nvidia/provider-catalog.test.ts +++ b/extensions/nvidia/provider-catalog.test.ts @@ -14,8 +14,8 @@ describe("nvidia provider catalog", () => { "minimaxai/minimax-m2.5", "z-ai/glm5", ]); - expect(provider.models.every((model) => model.compat?.requiresStringContent === true)).toBe( - true, + expect(provider.models.filter((model) => model.compat?.requiresStringContent !== true)).toEqual( + [], ); }); }); From 9905f2d13a019702b5ba72d0065b49bf8dc6491d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:53:22 +0100 Subject: [PATCH 138/806] test: clarify memory and slack assertions --- extensions/memory-core/src/memory/index.test.ts | 8 ++++++-- extensions/slack/src/action-runtime.test.ts | 2 +- extensions/slack/src/directory-contract.test.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index a838006a4ff..1c17c22fd3e 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -377,10 +377,14 @@ describe("memory index", () => { expect(embedBatchInputCalls).toBeGreaterThan(0); const imageResults = await manager.search("image"); - expect(imageResults.some((result) => result.path.endsWith("diagram.png"))).toBe(true); + expect(imageResults.map((result) => result.path)).toContainEqual( + expect.stringMatching(/diagram\.png$/), + ); const audioResults = await manager.search("audio"); - expect(audioResults.some((result) => result.path.endsWith("meeting.wav"))).toBe(true); + expect(audioResults.map((result) => result.path)).toContainEqual( + expect.stringMatching(/meeting\.wav$/), + ); }); it("finds keyword matches via hybrid search when query embedding is zero", async () => { diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index ff7a8423cea..e232efea943 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -290,7 +290,7 @@ describe("handleSlackAction", () => { text: expect.stringContaining("/tmp/openclaw-media/report.pdf"), }), ); - expect(result.content.some((entry) => entry.type === "image")).toBe(false); + expect(result.content.filter((entry) => entry.type === "image")).toEqual([]); expect(result.details).toEqual( expect.objectContaining({ ok: true, diff --git a/extensions/slack/src/directory-contract.test.ts b/extensions/slack/src/directory-contract.test.ts index 3e0a73888b9..6e8359db289 100644 --- a/extensions/slack/src/directory-contract.test.ts +++ b/extensions/slack/src/directory-contract.test.ts @@ -93,7 +93,7 @@ describe("Slack directory contract", () => { limit: 2, }); expect(peers).toHaveLength(2); - expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true); + expect(peers.filter((entry) => !entry.id.startsWith("user:u"))).toEqual([]); }); it("resolves current Slack account identity from live auth", async () => { From 054d0163ddc2ada2b42d167a0d6180958dee52a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:55:45 +0100 Subject: [PATCH 139/806] test: clarify codex app-server assertions --- .../auth-profile-runtime-contract.test.ts | 2 +- .../src/app-server/plugin-thread-config.test.ts | 2 +- .../app-server/run-attempt.context-engine.test.ts | 2 +- .../codex/src/app-server/run-attempt.test.ts | 15 +++++++-------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts index 6269bc432f9..672a34e605b 100644 --- a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts @@ -112,7 +112,7 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | " seenAuthProfileIds, seenAgentDirs, async waitForMethod(method: string) { - await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), { + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), { interval: 1, }); }, diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 77fbb82c345..46ea221b8f8 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -394,7 +394,7 @@ describe("Codex plugin thread config", () => { expect(request.mock.calls.filter(([method]) => method === "app/list").length).toBeGreaterThan( 0, ); - expect(appListParams.some((params) => params.forceRefetch)).toBe(true); + expect(appListParams.map((params) => params.forceRefetch)).toContain(true); }); it("surfaces critical post-install refresh failures and keeps plugin apps disabled", async () => { diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index b7e3d8cce08..f637da74130 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -147,7 +147,7 @@ function createStartedThreadHarness( return { requests, async waitForMethod(method: string) { - await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), { + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), { interval: 1, }); }, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index e2c6d8f8ee4..80609a3a4f1 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1878,7 +1878,7 @@ describe("runCodexAppServerAttempt", () => { }, ]), ); - expect(agentEvents.some((event) => event.stream === "assistant")).toBe(false); + expect(agentEvents.filter((event) => event.stream === "assistant")).toEqual([]); expect(agentEnd).toHaveBeenCalledWith( expect.objectContaining({ success: false, @@ -1982,13 +1982,12 @@ describe("runCodexAppServerAttempt", () => { await waitForMethod("turn/start"); expect(queueAgentHarnessMessage("session-1", "more context", { debounceMs: 1 })).toBe(true); - await vi.waitFor( - () => expect(requests.some((entry) => entry.method === "turn/steer")).toBe(true), - { interval: 1 }, - ); + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"), { + interval: 1, + }); expect(abortAgentHarnessRun("session-1")).toBe(true); await vi.waitFor( - () => expect(requests.some((entry) => entry.method === "turn/interrupt")).toBe(true), + () => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"), { interval: 1 }, ); @@ -2164,7 +2163,7 @@ describe("runCodexAppServerAttempt", () => { params.onBlockReply = vi.fn(); const run = runCodexAppServerAttempt(params); await vi.waitFor( - () => expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(true), + () => expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"), { interval: 1 }, ); await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), { interval: 1 }); @@ -2409,7 +2408,7 @@ describe("runCodexAppServerAttempt", () => { }; const run = runCodexAppServerAttempt(params); await vi.waitFor(() => - expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(true), + expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"), ); await notify({ method: "turn/completed", From 2a8565ea6705b0c6dea1bdffab82eca323d46c2a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:56:46 +0100 Subject: [PATCH 140/806] test: restore matrix progress draft expectation --- extensions/matrix/src/matrix/monitor/handler.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 43a577b33ff..f36f7ac39a9 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -2766,7 +2766,7 @@ describe("matrix monitor handler draft streaming", () => { await finish(); }); - it("uses resolved Matrix account progress config for draft text", async () => { + it("uses resolved Matrix account progress maxLines for draft text", async () => { const { dispatch } = createStreamingHarness({ streaming: "progress", previewToolProgressEnabled: true, @@ -2789,7 +2789,7 @@ describe("matrix monitor handler draft streaming", () => { await vi.waitFor(() => { expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); }); - expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Pearling\n- `second`"); + expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("- `second`"); await finish(); }); From 7dc6a79905da83ce6894ef3ca9740c83a9752d38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:57:18 +0100 Subject: [PATCH 141/806] test: clarify telegram command assertions --- extensions/telegram/src/accounts.test.ts | 2 +- extensions/telegram/src/bot-native-command-menu.test.ts | 4 ++-- .../telegram/src/bot-native-commands.skills-allowlist.test.ts | 4 ++-- extensions/telegram/src/format.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 2ed1cbba227..3789536e341 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -23,7 +23,7 @@ function warningLines(): string[] { } function expectNoMissingDefaultWarning() { - expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe(true); + expect(warningLines().filter((line) => line.includes("accounts.default is missing"))).toEqual([]); } function resolveAccountWithEnv( diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 24cf08e5916..33ca4990253 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -69,8 +69,8 @@ describe("bot-native-command-menu", () => { 0, ); expect(totalText).toBeLessThanOrEqual(TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET); - expect(result.commandsToRegister.every((command) => command.description.length <= 56)).toBe( - true, + expect(result.commandsToRegister.filter((command) => command.description.length > 56)).toEqual( + [], ); }); diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index ff3204b7a3b..cdb5416a352 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -82,7 +82,7 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { const registeredCommands = await waitForRegisteredCommands(setMyCommands); - expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false); + expect(registeredCommands.map((entry) => entry.command)).toContain("alpha_skill"); + expect(registeredCommands.map((entry) => entry.command)).not.toContain("beta_skill"); }); }); diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 2fcd06663e0..4d6350892e1 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -116,7 +116,7 @@ describe("markdownToTelegramHtml", () => { it("splits long multiline html text without breaking balanced tags", () => { const chunks = splitTelegramHtmlChunks(`${"A\n".repeat(2500)}`, 4000); expect(chunks.length).toBeGreaterThan(1); - expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks.filter((chunk) => chunk.length > 4000)).toEqual([]); expect(chunks[0]).toMatch(/^[\s\S]*<\/b>$/); expect(chunks[1]).toMatch(/^[\s\S]*<\/b>$/); }); @@ -128,7 +128,7 @@ describe("markdownToTelegramHtml", () => { it("treats malformed leading ampersands as plain text when chunking html", () => { const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000); expect(chunks.length).toBeGreaterThan(1); - expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks.filter((chunk) => chunk.length > 4000)).toEqual([]); }); it("fails loudly when tag overhead leaves no room for text", () => { From 6389059632c56a47791556171321fce88348ad66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 11:59:17 +0100 Subject: [PATCH 142/806] test: clarify telegram send assertions --- .../telegram/src/bot-native-commands.test.ts | 16 +++++++++------- extensions/telegram/src/send.test.ts | 12 +++++++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index a883f377925..cbd2e6288f8 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -194,8 +194,9 @@ describe("registerTelegramNativeCommands", () => { }); const registeredCommands = await waitForRegisteredCommands(setMyCommands); - expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false); + const registeredCommandNames = registeredCommands.map((entry) => entry.command); + expect(registeredCommandNames).toContain("export_session"); + expect(registeredCommandNames).not.toContain("export-session"); const registeredHandlers = command.mock.calls.map(([name]) => name); expect(registeredHandlers).toContain("export_session"); @@ -230,16 +231,17 @@ describe("registerTelegramNativeCommands", () => { const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands.length).toBeGreaterThan(0); + const registeredCommandNames = registeredCommands.map((entry) => entry.command); for (const entry of registeredCommands) { expect(entry.command.includes("-")).toBe(false); expect(TELEGRAM_COMMAND_NAME_PATTERN.test(entry.command)).toBe(true); } - expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "custom_backup")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "plugin_status")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "plugin-status")).toBe(false); - expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false); + expect(registeredCommandNames).toEqual( + expect.arrayContaining(["export_session", "custom_backup", "plugin_status"]), + ); + expect(registeredCommandNames).not.toContain("plugin-status"); + expect(registeredCommandNames).not.toContain("custom-bad"); }); it("prefixes native command menu callback data so callback handlers can preserve native routing", async () => { diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index da41451e02c..2c7b43d7ba9 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -884,8 +884,10 @@ describe("sendMessageTelegram", () => { caption: undefined, }); expect(sendMessage).toHaveBeenCalledTimes(2); - expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true); - expect(sendMessage.mock.calls.every((call) => String(call[1] ?? "").length <= 4000)).toBe(true); + expect(sendMessage.mock.calls.filter((call) => call[2]?.parse_mode !== "HTML")).toEqual([]); + expect(sendMessage.mock.calls.filter((call) => String(call[1] ?? "").length > 4000)).toEqual( + [], + ); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain(""); expect(res.messageId).toBe("74"); }); @@ -2002,7 +2004,7 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(4); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); - expect(plainFallbackCalls.every((call) => !String(call?.[1] ?? "").includes("<"))).toBe(true); + expect(plainFallbackCalls.filter((call) => String(call?.[1] ?? "").includes("<"))).toEqual([]); expect(res.messageId).toBe("91"); }); @@ -2033,7 +2035,7 @@ describe("sendMessageTelegram", () => { expect(String(sendMessage.mock.calls[0]?.[1] ?? "")).toMatch(/^&/); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); - expect(plainFallbackCalls.every((call) => String(call?.[1] ?? "").length > 0)).toBe(true); + expect(plainFallbackCalls.filter((call) => String(call?.[1] ?? "").length === 0)).toEqual([]); expect(res.messageId).toBe("93"); }); @@ -2057,7 +2059,7 @@ describe("sendMessageTelegram", () => { }); expect(sendMessage).toHaveBeenCalledTimes(3); - expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === undefined)).toBe(true); + expect(sendMessage.mock.calls.filter((call) => call[2]?.parse_mode !== undefined)).toEqual([]); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); expect(res.messageId).toBe("96"); }); From 79b292c2be765aa5bfce4f6265496ea2e4ea0f1f Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 7 May 2026 21:28:48 +0200 Subject: [PATCH 143/806] fix(agents): self-heal cross-tool file-mutation in cron classifier Recognize a successful file-mutation on the same path/oldpath target as recovery for an earlier failed file-mutation, even when the tool name differs (edit -> write, apply_patch -> write, etc). Previously isSameToolMutationAction required exact fingerprint equality, which includes tool=, so an edit failure followed by a successful write to the same path was never recognized as recovery. The unresolved lastToolError then drove the cron classifier to flag a healthy self-healed run as fatal with the user-visible warning prefix from issue #79024. Limited to file-mutating tools (edit, write, apply_patch) and the stable path/oldpath segments of the action fingerprint; non-file-mutating tools and different paths still fail closed. Fixes #79024. --- CHANGELOG.md | 1 + src/agents/tool-mutation.test.ts | 89 ++++++++++++++++++++++++++++++++ src/agents/tool-mutation.ts | 63 +++++++++++++++++++--- 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4ce7451a3..5902df20918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron/agents: recognize cross-tool same-target file-mutation as recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit`/`apply_patch` on the same path. Stops cron from reporting fatal failures when an agent self-heals across tools, while preserving same-tool fingerprint matching, blocking different-target writes, and ignoring non-file-mutating recovery tools. Fixes #79024. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 904ca191a0b..ce9b9a7e5ed 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -88,6 +88,95 @@ describe("tool mutation helpers", () => { ).toBe(false); }); + it("recognizes cross-tool file-mutation recovery on the same target (#79024)", () => { + expect( + isSameToolMutationAction( + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" }, + ), + ).toBe(true); + expect( + isSameToolMutationAction( + { toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" }, + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + ), + ).toBe(true); + expect( + isSameToolMutationAction( + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { toolName: "apply_patch", actionFingerprint: "tool=apply_patch|path=/tmp/a" }, + ), + ).toBe(true); + }); + + it("does not cross-recover file mutations on different targets (#79024)", () => { + expect( + isSameToolMutationAction( + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { toolName: "write", actionFingerprint: "tool=write|path=/tmp/b" }, + ), + ).toBe(false); + }); + + it("does not cross-recover when the recovery tool is not file-mutating (#79024)", () => { + expect( + isSameToolMutationAction( + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { toolName: "bash", actionFingerprint: "tool=bash|meta=cat /tmp/a" }, + ), + ).toBe(false); + expect( + isSameToolMutationAction( + { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { toolName: "exec", actionFingerprint: "tool=exec|meta=touch /tmp/a" }, + ), + ).toBe(false); + }); + + it("ignores call-specific noise when comparing the cross-tool target (#79024)", () => { + // `id=...` and `meta=...` segments must not block recovery when the + // stable `path=...` target still matches. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|id=42|meta=edit /tmp/a", + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|id=99|meta=write /tmp/a", + }, + ), + ).toBe(true); + }); + + it("requires `oldpath` to agree across cross-tool recovery (#79024)", () => { + expect( + isSameToolMutationAction( + { + toolName: "apply_patch", + actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old", + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|oldpath=/tmp/old", + }, + ), + ).toBe(true); + expect( + isSameToolMutationAction( + { + toolName: "apply_patch", + actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old", + }, + { + toolName: "apply_patch", + actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/different", + }, + ), + ).toBe(false); + }); + it("keeps legacy name-only mutating heuristics for payload fallback", () => { expect(isLikelyMutatingToolName("sessions_spawn")).toBe(true); expect(isLikelyMutatingToolName("sessions_send")).toBe(true); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index d913446125f..52057865c02 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -21,6 +21,17 @@ const MUTATING_TOOL_NAMES = new Set([ "session_status", ]); +// File-mutation tools that operate on the same `path`/`oldpath` target identity. +// Recovery is allowed across these even when the tool name differs (e.g. +// edit-fails-then-write-succeeds on the same path), because the user-visible +// invariant is "the file at this path is in the desired state." +const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]); + +// Stable target segments produced by `buildToolActionFingerprint` that identify +// the file being mutated. Other segments (`tool=`, `action=`, `id=`, `meta=`) +// are call-specific and excluded from cross-tool target comparison. +const FILE_TARGET_FINGERPRINT_KEYS = new Set(["path", "oldpath"]); + const READ_ONLY_ACTIONS = new Set([ "get", "list", @@ -214,14 +225,54 @@ export function buildToolMutationState( }; } +function isFileMutatingToolName(rawName: string): boolean { + return FILE_MUTATING_TOOL_NAMES.has(normalizeLowercaseStringOrEmpty(rawName)); +} + +function extractFileTargetFingerprint(fingerprint: string | undefined): string | undefined { + if (!fingerprint) { + return undefined; + } + const segments: string[] = []; + for (const segment of fingerprint.split("|")) { + const eqIndex = segment.indexOf("="); + if (eqIndex < 0) { + continue; + } + const key = segment.slice(0, eqIndex); + if (FILE_TARGET_FINGERPRINT_KEYS.has(key)) { + segments.push(segment); + } + } + return segments.length > 0 ? segments.join("|") : undefined; +} + export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActionRef): boolean { if (existing.actionFingerprint != null || next.actionFingerprint != null) { - // For mutating flows, fail closed: only clear when both fingerprints exist and match. - return ( - existing.actionFingerprint != null && - next.actionFingerprint != null && - existing.actionFingerprint === next.actionFingerprint - ); + // For mutating flows, fail closed: only clear when both fingerprints exist + // and either match exactly or describe the same file-mutation target. + if (existing.actionFingerprint == null || next.actionFingerprint == null) { + return false; + } + if (existing.actionFingerprint === next.actionFingerprint) { + return true; + } + // Cross-tool recovery: a successful file-mutation on the same `path` + // (and `oldpath`, where applicable) clears an unresolved file-mutation + // failure even when the tool name differs (e.g. edit→write self-heal). + // Different paths or non-file-mutating tools never qualify. + if (isFileMutatingToolName(existing.toolName) && isFileMutatingToolName(next.toolName)) { + const existingTarget = extractFileTargetFingerprint(existing.actionFingerprint); + const nextTarget = extractFileTargetFingerprint(next.actionFingerprint); + if ( + existingTarget !== undefined && + nextTarget !== undefined && + existingTarget === nextTarget + ) { + return true; + } + } + return false; } return existing.toolName === next.toolName && (existing.meta ?? "") === (next.meta ?? ""); } From 0a7d9d7abe7767d5c73b196d9b7c74236f51be17 Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 7 May 2026 21:59:43 +0200 Subject: [PATCH 144/806] docs(changelog): credit @RenzoMXD on #79024 fix Adds the Thanks attribution called out by clawsweeper P3 review on PR #79067, keeping the bullet on a single line per repo policy. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5902df20918..5a38309722c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,7 +184,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Cron/agents: recognize cross-tool same-target file-mutation as recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit`/`apply_patch` on the same path. Stops cron from reporting fatal failures when an agent self-heals across tools, while preserving same-tool fingerprint matching, blocking different-target writes, and ignoring non-file-mutating recovery tools. Fixes #79024. +- Cron/agents: recognize cross-tool same-target file-mutation as recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit`/`apply_patch` on the same path. Stops cron from reporting fatal failures when an agent self-heals across tools, while preserving same-tool fingerprint matching, blocking different-target writes, and ignoring non-file-mutating recovery tools. Fixes #79024. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. From 3f4c64163dc4c5d4a43a9344f4c43aed159e02ce Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 7 May 2026 23:18:23 +0200 Subject: [PATCH 145/806] =?UTF-8?q?fix(agents):=20narrow=20self-heal=20rec?= =?UTF-8?q?overy=20to=20edit=E2=86=94write=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop apply_patch from the file-mutating recovery set after clawsweeper P2 review on PR #79067 noted production apply_patch calls only carry opaque `input` patch text, so buildToolActionFingerprint never extracts a `path=` segment from real call args. Including apply_patch only matched handcrafted fingerprints in tests, not real recoveries, and the public CHANGELOG claim was unimplemented. Also drops the now-orphaned `oldpath` segment from FILE_TARGET_FINGERPRINT_KEYS since edit and write do not produce it, and replaces the apply_patch test expectation with an explicit negative assertion that proves the narrowing. Re-files apply_patch ↔ write recovery as a future enhancement; it needs single-file patch-target extraction in buildToolActionFingerprint to be honestly supportable. --- CHANGELOG.md | 2 +- src/agents/tool-mutation.test.ts | 33 +++++--------------------------- src/agents/tool-mutation.ts | 24 ++++++++++++++--------- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a38309722c..33649519ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,7 +184,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Cron/agents: recognize cross-tool same-target file-mutation as recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit`/`apply_patch` on the same path. Stops cron from reporting fatal failures when an agent self-heals across tools, while preserving same-tool fingerprint matching, blocking different-target writes, and ignoring non-file-mutating recovery tools. Fixes #79024. Thanks @RenzoMXD. +- Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index ce9b9a7e5ed..b3b39421cbc 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -101,12 +101,16 @@ describe("tool mutation helpers", () => { { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, ), ).toBe(true); + // `apply_patch` is intentionally excluded from the file-mutating set + // because production `apply_patch` calls only carry opaque `input` text, + // so real fingerprints never have a `path=` segment to compare. Even a + // synthetic path-bearing fingerprint must not unlock recovery. expect( isSameToolMutationAction( { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, { toolName: "apply_patch", actionFingerprint: "tool=apply_patch|path=/tmp/a" }, ), - ).toBe(true); + ).toBe(false); }); it("does not cross-recover file mutations on different targets (#79024)", () => { @@ -150,33 +154,6 @@ describe("tool mutation helpers", () => { ).toBe(true); }); - it("requires `oldpath` to agree across cross-tool recovery (#79024)", () => { - expect( - isSameToolMutationAction( - { - toolName: "apply_patch", - actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old", - }, - { - toolName: "write", - actionFingerprint: "tool=write|path=/tmp/a|oldpath=/tmp/old", - }, - ), - ).toBe(true); - expect( - isSameToolMutationAction( - { - toolName: "apply_patch", - actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old", - }, - { - toolName: "apply_patch", - actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/different", - }, - ), - ).toBe(false); - }); - it("keeps legacy name-only mutating heuristics for payload fallback", () => { expect(isLikelyMutatingToolName("sessions_spawn")).toBe(true); expect(isLikelyMutatingToolName("sessions_send")).toBe(true); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 52057865c02..2de00307c54 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -21,16 +21,22 @@ const MUTATING_TOOL_NAMES = new Set([ "session_status", ]); -// File-mutation tools that operate on the same `path`/`oldpath` target identity. +// File-mutation tools that operate on the same `path` target identity. // Recovery is allowed across these even when the tool name differs (e.g. // edit-fails-then-write-succeeds on the same path), because the user-visible // invariant is "the file at this path is in the desired state." -const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]); +// +// `apply_patch` is intentionally excluded: production `apply_patch` calls take +// only an opaque `input` patch string, so `buildToolActionFingerprint` cannot +// extract a `path=` segment from real call args. Including `apply_patch` here +// would only match handcrafted-fingerprint test inputs, not real recoveries. +const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write"]); -// Stable target segments produced by `buildToolActionFingerprint` that identify -// the file being mutated. Other segments (`tool=`, `action=`, `id=`, `meta=`) -// are call-specific and excluded from cross-tool target comparison. -const FILE_TARGET_FINGERPRINT_KEYS = new Set(["path", "oldpath"]); +// Stable target segment produced by `buildToolActionFingerprint` that +// identifies the file being mutated. Other segments (`tool=`, `action=`, +// `id=`, `meta=`) are call-specific and excluded from cross-tool target +// comparison. +const FILE_TARGET_FINGERPRINT_KEYS = new Set(["path"]); const READ_ONLY_ACTIONS = new Set([ "get", @@ -258,9 +264,9 @@ export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActi return true; } // Cross-tool recovery: a successful file-mutation on the same `path` - // (and `oldpath`, where applicable) clears an unresolved file-mutation - // failure even when the tool name differs (e.g. edit→write self-heal). - // Different paths or non-file-mutating tools never qualify. + // clears an unresolved file-mutation failure even when the tool name + // differs (e.g. edit→write self-heal). Different paths or + // non-file-mutating tools never qualify. if (isFileMutatingToolName(existing.toolName) && isFileMutatingToolName(next.toolName)) { const existingTarget = extractFileTargetFingerprint(existing.actionFingerprint); const nextTarget = extractFileTargetFingerprint(next.actionFingerprint); From 8fb22fdfe24b66ca1df3218fee2a0ce77beb712a Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Fri, 8 May 2026 12:07:47 +0200 Subject: [PATCH 146/806] fix(agents): compare file-target structurally not via fingerprint split Address clawsweeper P2 on PR #79067: the prior cross-tool recovery extracted the path target by splitting the joined fingerprint string on `|`, which is also a legal character in file paths. A failed edit on `/tmp/a|left` and a successful write to `/tmp/a|right` would both extract as `path=/tmp/a` and incorrectly clear the prior failure. Carry a structured `fileTarget: { path?, oldpath? }` alongside the existing `actionFingerprint` string and compare it directly. `extractFileTarget` reads args once at fingerprint-build time, with the same alias support as `buildToolActionFingerprint`. The fingerprint string is unchanged for diagnostics and the exact-equality match path; only the cross-tool fallback now compares structurally. Threaded through `ToolMutationState`, `ToolActionRef`, `ToolCallSummary`, and `ToolErrorSummary` so the existing handler code at `pi-embedded-subscribe.handlers.tools.ts:910-928` can populate and consume it without re-parsing. Adds delimiter-bearing-path regression test asserting that `/tmp/a|left` vs `/tmp/a|right` returns false, and that an identical delimiter-bearing path on both sides still matches. --- .../pi-embedded-subscribe.handlers.tools.ts | 3 + .../pi-embedded-subscribe.handlers.types.ts | 1 + src/agents/tool-error-summary.ts | 2 + src/agents/tool-mutation.test.ts | 123 ++++++++++++++++-- src/agents/tool-mutation.ts | 107 +++++++++------ 5 files changed, 183 insertions(+), 53 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index a434dbce12e..a795f02c168 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -120,6 +120,7 @@ function buildToolCallSummary(toolName: string, args: unknown, meta?: string): T meta, mutatingAction: mutation.mutatingAction, actionFingerprint: mutation.actionFingerprint, + fileTarget: mutation.fileTarget, }; } @@ -914,6 +915,7 @@ export async function handleToolExecutionEnd( timedOut: isToolResultTimedOut(sanitizedResult) || undefined, mutatingAction: callSummary?.mutatingAction, actionFingerprint: callSummary?.actionFingerprint, + fileTarget: callSummary?.fileTarget, }; } else if (ctx.state.lastToolError) { // Keep unresolved mutating failures until the same action succeeds. @@ -923,6 +925,7 @@ export async function handleToolExecutionEnd( toolName, meta, actionFingerprint: callSummary?.actionFingerprint, + fileTarget: callSummary?.fileTarget, }) ) { ctx.state.lastToolError = undefined; diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 4556dcdadf6..3b62e97712c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -25,6 +25,7 @@ export type ToolCallSummary = { meta?: string; mutatingAction: boolean; actionFingerprint?: string; + fileTarget?: import("./tool-mutation.js").FileTarget; }; export type EmbeddedPiSubscribeState = { diff --git a/src/agents/tool-error-summary.ts b/src/agents/tool-error-summary.ts index 39a9264ae7d..f38628d40eb 100644 --- a/src/agents/tool-error-summary.ts +++ b/src/agents/tool-error-summary.ts @@ -1,4 +1,5 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import type { FileTarget } from "./tool-mutation.js"; export type ToolErrorSummary = { toolName: string; @@ -7,6 +8,7 @@ export type ToolErrorSummary = { timedOut?: boolean; mutatingAction?: boolean; actionFingerprint?: string; + fileTarget?: FileTarget; }; const EXEC_LIKE_TOOL_NAMES = new Set(["exec", "bash"]); diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index b3b39421cbc..84e47e651ad 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -88,27 +88,68 @@ describe("tool mutation helpers", () => { ).toBe(false); }); + it("populates structured fileTarget for file-mutating calls (#79024)", () => { + expect(buildToolMutationState("edit", { file_path: "/tmp/a" }).fileTarget).toEqual({ + path: "/tmp/a", + }); + expect(buildToolMutationState("write", { path: "/tmp/Foo|bar" }).fileTarget).toEqual({ + path: "/tmp/foo|bar", + }); + // Non-file-mutating tools never carry fileTarget, even with a path arg. + expect(buildToolMutationState("bash", { command: "rm /tmp/a" }).fileTarget).toBeUndefined(); + expect(buildToolMutationState("exec", { command: "touch /tmp/a" }).fileTarget).toBeUndefined(); + // apply_patch is excluded from file-mutating set, so no fileTarget even + // if a path-shaped arg is synthetically present. + expect( + buildToolMutationState("apply_patch", { input: "*** Update File: /tmp/a" }).fileTarget, + ).toBeUndefined(); + }); + it("recognizes cross-tool file-mutation recovery on the same target (#79024)", () => { expect( isSameToolMutationAction( - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, - { toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, ), ).toBe(true); expect( isSameToolMutationAction( - { toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" }, - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, ), ).toBe(true); // `apply_patch` is intentionally excluded from the file-mutating set // because production `apply_patch` calls only carry opaque `input` text, - // so real fingerprints never have a `path=` segment to compare. Even a - // synthetic path-bearing fingerprint must not unlock recovery. + // so `extractFileTarget` returns `undefined` and the fail-closed branch + // refuses cross-tool recovery. expect( isSameToolMutationAction( - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, - { toolName: "apply_patch", actionFingerprint: "tool=apply_patch|path=/tmp/a" }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "apply_patch", + actionFingerprint: "tool=apply_patch|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, ), ).toBe(false); }); @@ -116,39 +157,93 @@ describe("tool mutation helpers", () => { it("does not cross-recover file mutations on different targets (#79024)", () => { expect( isSameToolMutationAction( - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, - { toolName: "write", actionFingerprint: "tool=write|path=/tmp/b" }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/b", + fileTarget: { path: "/tmp/b" }, + }, ), ).toBe(false); }); + it("does not over-match paths containing the fingerprint delimiter (#79024)", () => { + // The fingerprint string carries raw paths separated by `|`. A naive + // `split("|")` parser would extract `path=/tmp/a` from both fingerprints + // and incorrectly clear the prior failure. Structural fileTarget + // comparison fails closed for these distinct paths. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|left", + fileTarget: { path: "/tmp/a|left" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|right", + fileTarget: { path: "/tmp/a|right" }, + }, + ), + ).toBe(false); + // Same delimiter-bearing path on both sides still matches. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|shared", + fileTarget: { path: "/tmp/a|shared" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|shared", + fileTarget: { path: "/tmp/a|shared" }, + }, + ), + ).toBe(true); + }); + it("does not cross-recover when the recovery tool is not file-mutating (#79024)", () => { expect( isSameToolMutationAction( - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, { toolName: "bash", actionFingerprint: "tool=bash|meta=cat /tmp/a" }, ), ).toBe(false); expect( isSameToolMutationAction( - { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, { toolName: "exec", actionFingerprint: "tool=exec|meta=touch /tmp/a" }, ), ).toBe(false); }); it("ignores call-specific noise when comparing the cross-tool target (#79024)", () => { - // `id=...` and `meta=...` segments must not block recovery when the - // stable `path=...` target still matches. + // `id=...` and `meta=...` segments differ between calls; structural + // fileTarget comparison is unaffected. expect( isSameToolMutationAction( { toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a|id=42|meta=edit /tmp/a", + fileTarget: { path: "/tmp/a" }, }, { toolName: "write", actionFingerprint: "tool=write|path=/tmp/a|id=99|meta=write /tmp/a", + fileTarget: { path: "/tmp/a" }, }, ), ).toBe(true); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 2de00307c54..61dc5d2ce0c 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -32,11 +32,9 @@ const MUTATING_TOOL_NAMES = new Set([ // would only match handcrafted-fingerprint test inputs, not real recoveries. const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write"]); -// Stable target segment produced by `buildToolActionFingerprint` that -// identifies the file being mutated. Other segments (`tool=`, `action=`, -// `id=`, `meta=`) are call-specific and excluded from cross-tool target -// comparison. -const FILE_TARGET_FINGERPRINT_KEYS = new Set(["path"]); +// Args aliases that identify the file target on a file-mutating call. +const FILE_TARGET_PATH_ARG_KEYS = ["path", "file_path", "filePath", "filepath", "file"] as const; +const FILE_TARGET_OLDPATH_ARG_KEYS = ["oldPath", "old_path"] as const; const READ_ONLY_ACTIONS = new Set([ "get", @@ -69,15 +67,28 @@ const MESSAGE_MUTATING_ACTIONS = new Set([ "unpin", ]); +// Structured file-target identity for cross-tool same-target recovery. +// Carried alongside `actionFingerprint` so comparison does not have to +// re-parse the joined fingerprint string. Re-parsing was unsafe because +// `buildToolActionFingerprint` stores raw path values in a `|`-delimited +// string, so a path containing `|` could over-match (e.g. `/tmp/a|left` and +// `/tmp/a|right` would both extract as `path=/tmp/a`). +export type FileTarget = { + path?: string; + oldpath?: string; +}; + type ToolMutationState = { mutatingAction: boolean; actionFingerprint?: string; + fileTarget?: FileTarget; }; type ToolActionRef = { toolName: string; meta?: string; actionFingerprint?: string; + fileTarget?: FileTarget; }; function normalizeActionName(value: unknown): string | undefined { @@ -219,40 +230,60 @@ export function buildToolActionFingerprint( return parts.join("|"); } +function isFileMutatingToolName(rawName: string): boolean { + return FILE_MUTATING_TOOL_NAMES.has(normalizeLowercaseStringOrEmpty(rawName)); +} + +function readArgFingerprintValue( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const normalized = normalizeFingerprintValue(record[key]); + if (normalized) { + return normalized; + } + } + return undefined; +} + +export function extractFileTarget(toolName: string, args: unknown): FileTarget | undefined { + if (!isFileMutatingToolName(toolName)) { + return undefined; + } + const record = asRecord(args); + const path = readArgFingerprintValue(record, FILE_TARGET_PATH_ARG_KEYS); + const oldpath = readArgFingerprintValue(record, FILE_TARGET_OLDPATH_ARG_KEYS); + if (!path && !oldpath) { + return undefined; + } + return { + ...(path !== undefined ? { path } : {}), + ...(oldpath !== undefined ? { oldpath } : {}), + }; +} + +function fileTargetsEqual(a: FileTarget, b: FileTarget): boolean { + return (a.path ?? "") === (b.path ?? "") && (a.oldpath ?? "") === (b.oldpath ?? ""); +} + export function buildToolMutationState( toolName: string, args: unknown, meta?: string, ): ToolMutationState { const actionFingerprint = buildToolActionFingerprint(toolName, args, meta); + const fileTarget = extractFileTarget(toolName, args); return { mutatingAction: actionFingerprint != null, actionFingerprint, + ...(fileTarget !== undefined ? { fileTarget } : {}), }; } -function isFileMutatingToolName(rawName: string): boolean { - return FILE_MUTATING_TOOL_NAMES.has(normalizeLowercaseStringOrEmpty(rawName)); -} - -function extractFileTargetFingerprint(fingerprint: string | undefined): string | undefined { - if (!fingerprint) { - return undefined; - } - const segments: string[] = []; - for (const segment of fingerprint.split("|")) { - const eqIndex = segment.indexOf("="); - if (eqIndex < 0) { - continue; - } - const key = segment.slice(0, eqIndex); - if (FILE_TARGET_FINGERPRINT_KEYS.has(key)) { - segments.push(segment); - } - } - return segments.length > 0 ? segments.join("|") : undefined; -} - export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActionRef): boolean { if (existing.actionFingerprint != null || next.actionFingerprint != null) { // For mutating flows, fail closed: only clear when both fingerprints exist @@ -265,18 +296,16 @@ export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActi } // Cross-tool recovery: a successful file-mutation on the same `path` // clears an unresolved file-mutation failure even when the tool name - // differs (e.g. edit→write self-heal). Different paths or - // non-file-mutating tools never qualify. - if (isFileMutatingToolName(existing.toolName) && isFileMutatingToolName(next.toolName)) { - const existingTarget = extractFileTargetFingerprint(existing.actionFingerprint); - const nextTarget = extractFileTargetFingerprint(next.actionFingerprint); - if ( - existingTarget !== undefined && - nextTarget !== undefined && - existingTarget === nextTarget - ) { - return true; - } + // differs (e.g. edit→write self-heal). Compared structurally on + // `fileTarget` so paths containing `|` cannot over-match. + if ( + isFileMutatingToolName(existing.toolName) && + isFileMutatingToolName(next.toolName) && + existing.fileTarget !== undefined && + next.fileTarget !== undefined && + fileTargetsEqual(existing.fileTarget, next.fileTarget) + ) { + return true; } return false; } From ae8b3de2d99edb48044c4a64a7eeb5ef88955dbb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 11:59:57 +0100 Subject: [PATCH 147/806] test: sync telegram release scenario assertion --- test/scripts/package-acceptance-workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 795d87feb72..36f9b8b491b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -563,7 +563,7 @@ describe("package artifact reuse", () => { ); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( - "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating", + "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating", ); expect(workflow).toContain("ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}"); expect(workflow).toContain("ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}"); From d7853ed5b3758c35d4e0e15a2f767dd00969b63a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:00:47 +0100 Subject: [PATCH 148/806] test: clarify signal and matrix assertions --- extensions/matrix/src/resolve-targets.test.ts | 4 ++-- extensions/signal/src/config-schema.test.ts | 4 +++- extensions/signal/src/format.chunking.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 6db1ed613c1..ca28393fd1e 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -140,8 +140,8 @@ describe("resolveMatrixTargets (users)", () => { kind: "group", }); - expect(userResults.every((entry) => entry.resolved)).toBe(true); - expect(groupResults.every((entry) => entry.resolved)).toBe(true); + expect(userResults.filter((entry) => !entry.resolved)).toEqual([]); + expect(groupResults.filter((entry) => !entry.resolved)).toEqual([]); expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); }); diff --git a/extensions/signal/src/config-schema.test.ts b/extensions/signal/src/config-schema.test.ts index 062de3275cd..f84d149d2d6 100644 --- a/extensions/signal/src/config-schema.test.ts +++ b/extensions/signal/src/config-schema.test.ts @@ -108,6 +108,8 @@ describe("signal groups schema", () => { }, }); - expect(issues.some((issue) => issue.path.join(".").startsWith("groups"))).toBe(true); + expect(issues.map((issue) => issue.path.join("."))).toContainEqual( + expect.stringMatching(/^groups/), + ); }); }); diff --git a/extensions/signal/src/format.chunking.test.ts b/extensions/signal/src/format.chunking.test.ts index dbea02f3c9a..ef8fffc2ebe 100644 --- a/extensions/signal/src/format.chunking.test.ts +++ b/extensions/signal/src/format.chunking.test.ts @@ -85,7 +85,7 @@ describe("splitSignalFormattedText", () => { // First chunk should contain the bold style const firstChunk = chunks[0]; expect(firstChunk.text).toContain("bold"); - expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(firstChunk.styles.map((style) => style.style)).toContain("BOLD"); // The bold style should start at position 0 in the first chunk const boldStyle = requireStyle(firstChunk, "BOLD"); expect(boldStyle.start).toBe(0); @@ -104,7 +104,7 @@ describe("splitSignalFormattedText", () => { if (!chunkWithBold) { throw new Error("chunk containing bold text missing"); } - expect(chunkWithBold.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(chunkWithBold.styles.map((style) => style.style)).toContain("BOLD"); // The bold style should have chunk-local offset (not original text offset) const boldStyle = requireStyle(chunkWithBold, "BOLD"); @@ -211,7 +211,7 @@ describe("splitSignalFormattedText", () => { // Bold should be preserved in first chunk const firstChunk = chunks[0]; if (firstChunk.text.includes("bold")) { - expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(firstChunk.styles.map((style) => style.style)).toContain("BOLD"); } }); From feccd70b9decd2391964886e9fc0932fc284e100 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:03:52 +0100 Subject: [PATCH 149/806] test: clarify memory wiki assertions --- extensions/active-memory/index.test.ts | 12 +++++------- extensions/memory-wiki/src/claim-health.test.ts | 2 +- extensions/memory-wiki/src/query.test.ts | 7 ++++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 7a17dbc5596..98c3d1b82be 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -3628,13 +3628,11 @@ describe("active-memory plugin", () => { ), ); expect( - vi - .mocked(api.logger.info) - .mock.calls.some((call: unknown[]) => - String(call[0]).includes(`transcript=${expectedDir}${path.sep}`), - ), - ).toBe(true); - expect(rmSpy.mock.calls.some(([target]) => String(target).startsWith(expectedDir))).toBe(false); + vi.mocked(api.logger.info).mock.calls.map((call: unknown[]) => String(call[0])), + ).toContainEqual(expect.stringContaining(`transcript=${expectedDir}${path.sep}`)); + expect(rmSpy.mock.calls.filter(([target]) => String(target).startsWith(expectedDir))).toEqual( + [], + ); }); it("falls back to the default transcript directory when transcriptDir is unsafe", async () => { diff --git a/extensions/memory-wiki/src/claim-health.test.ts b/extensions/memory-wiki/src/claim-health.test.ts index 0442849dde2..fbe3eedac32 100644 --- a/extensions/memory-wiki/src/claim-health.test.ts +++ b/extensions/memory-wiki/src/claim-health.test.ts @@ -59,6 +59,6 @@ describe("buildPageContradictionClusters", () => { expect(clusters).toHaveLength(2); expect(clusters.map((cluster) => cluster.key).toSorted()).toEqual(["किताब", "कीताब"]); - expect(clusters.every((cluster) => cluster.entries)).toBe(true); + expect(clusters.filter((cluster) => !cluster.entries)).toEqual([]); }); }); diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 5611e3372b6..e9d535721eb 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -638,8 +638,9 @@ describe("searchMemoryWiki", () => { }); expect(results).toHaveLength(2); - expect(results.some((result) => result.corpus === "wiki")).toBe(true); - expect(results.some((result) => result.corpus === "memory")).toBe(true); + expect(results.map((result) => result.corpus)).toEqual( + expect.arrayContaining(["wiki", "memory"]), + ); expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 }); expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({ cfg: createAppConfig(), @@ -691,7 +692,7 @@ describe("searchMemoryWiki", () => { }); expect(results).toHaveLength(5); - expect(results.some((result) => result.corpus === "memory")).toBe(true); + expect(results.map((result) => result.corpus)).toContain("memory"); expect( results.filter((result) => result.corpus === "wiki").map((result) => result.path), ).toEqual([ From 9bd8ee054f69ab2abc883c494c1df124599a9453 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:05:50 +0100 Subject: [PATCH 150/806] test: clarify gateway session assertions --- src/gateway/chat-attachments.test.ts | 2 +- src/gateway/openai-http.test.ts | 2 +- src/gateway/server.health.test.ts | 2 +- .../server.sessions.delete-lifecycle.test.ts | 4 ++-- .../server.sessions.reset-hooks.test.ts | 4 +++- src/gateway/server.sessions.store-rpc.test.ts | 18 +++++++++--------- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index e34792a0f79..02dd7d799c3 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -477,7 +477,7 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.message).toContain( "[image attachment omitted: text-only attachment limit reached]", ); - expect(logs.some((line) => /offload limit 10/i.test(line))).toBe(true); + expect(logs).toContainEqual(expect.stringMatching(/offload limit 10/i)); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); } diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 1d56fc261e2..ddbaa911880 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -942,7 +942,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { const jsonChunks = data .filter((d) => d !== "[DONE]") .map((d) => JSON.parse(d) as Record); - expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true); + expect(jsonChunks.map((chunk) => chunk.object)).toContain("chat.completion.chunk"); const allContent = jsonChunks .flatMap((c) => (c.choices as Array> | undefined) ?? []) .map((choice) => (choice.delta as Record | undefined)?.content) diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index ff721d0671f..6b6611db0c6 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -279,7 +279,7 @@ describe("gateway server health/presence", () => { const presenceRes = await presenceP; const entries = (presenceRes.payload ?? []) as Array>; - expect(entries.some((e) => e.instanceId === cliId)).toBe(false); + expect(entries.map((entry) => entry.instanceId)).not.toContain(cliId); ws.close(); }); diff --git a/src/gateway/server.sessions.delete-lifecycle.test.ts b/src/gateway/server.sessions.delete-lifecycle.test.ts index adfd12572eb..def75074411 100644 --- a/src/gateway/server.sessions.delete-lifecycle.test.ts +++ b/src/gateway/server.sessions.delete-lifecycle.test.ts @@ -345,8 +345,8 @@ test("sessions.delete returns unavailable when active run does not stop", async >; expect(store["agent:main:discord:group:dev"]?.sessionId).toBe("sess-active"); const filesAfterDeleteAttempt = await fs.readdir(dir); - expect(filesAfterDeleteAttempt.some((f) => f.startsWith("sess-active.jsonl.deleted."))).toBe( - false, + expect(filesAfterDeleteAttempt).not.toContainEqual( + expect.stringMatching(/^sess-active\.jsonl\.deleted\./), ); ws.close(); diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 5f9c00cdef7..ee1c8ade4ba 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -211,7 +211,9 @@ test("sessions.reset returns unavailable when active run does not stop", async ( >; expect(store["agent:main:main"]?.sessionId).toBe("sess-main"); const filesAfterResetAttempt = await fs.readdir(dir); - expect(filesAfterResetAttempt.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(false); + expect(filesAfterResetAttempt).not.toContainEqual( + expect.stringMatching(/^sess-main\.jsonl\.reset\./), + ); }); test("sessions.reset emits before_reset for the entry actually reset in the writer slot", async () => { diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index b5a7e5e8a69..af79e1ce994 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -150,7 +150,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(list1.ok).toBe(true); expect(list1.payload?.path).toBe(storePath); - expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); + expect(list1.payload?.sessions.map((session) => session.key)).not.toContain("global"); expect(list1.payload?.defaults?.modelProvider).toBe("anthropic"); const main = list1.payload?.sessions.find((s) => s.key === "agent:main:main"); expect(main?.totalTokens).toBeUndefined(); @@ -321,8 +321,8 @@ test("lists and patches session store via sessions.* RPC", async () => { const listAfterCleanup = await directSessionReq<{ sessions: Array<{ key: string }>; }>("sessions.list", {}); - expect(listAfterCleanup.payload?.sessions.some((s) => s.key === "agent:main:subagent:one")).toBe( - false, + expect(listAfterCleanup.payload?.sessions.map((session) => session.key)).not.toContain( + "agent:main:subagent:one", ); piSdkMock.enabled = true; @@ -383,7 +383,7 @@ test("lists and patches session store via sessions.* RPC", async () => { .filter((l) => l.trim().length > 0); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); - expect(filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak."))).toBe(true); + expect(filesAfterCompact).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.bak\./)); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:discord:group:dev", @@ -394,11 +394,11 @@ test("lists and patches session store via sessions.* RPC", async () => { sessions: Array<{ key: string }>; }>("sessions.list", {}); expect(listAfterDelete.ok).toBe(true); - expect( - listAfterDelete.payload?.sessions.some((s) => s.key === "agent:main:discord:group:dev"), - ).toBe(false); + expect(listAfterDelete.payload?.sessions.map((session) => session.key)).not.toContain( + "agent:main:discord:group:dev", + ); const filesAfterDelete = await fs.readdir(dir); - expect(filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted."))).toBe(true); + expect(filesAfterDelete).toContainEqual(expect.stringMatching(/^sess-group\.jsonl\.deleted\./)); const reset = await directSessionReq<{ ok: true; @@ -425,7 +425,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work"); expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456"); const filesAfterReset = await fs.readdir(dir); - expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); + expect(filesAfterReset).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.reset\./)); const badThinking = await directSessionReq("sessions.patch", { key: "agent:main:main", From 82ef15840359083fa5927f9af9ebfa385d789d25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:07:16 +0100 Subject: [PATCH 151/806] test: clarify openresponses stream assertions --- src/gateway/openresponses-http.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index e2b17ed2971..cb0b595e2e7 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -719,7 +719,7 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(eventTypes).toContain("response.output_text.done"); expect(eventTypes).toContain("response.content_part.done"); expect(eventTypes).toContain("response.completed"); - expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true); + expect(deltaEvents.map((event) => event.data)).toContain("[DONE]"); const deltas = deltaEvents .filter((e) => e.event === "response.output_text.delta") @@ -831,7 +831,7 @@ describe("OpenResponses HTTP API (e2e)", () => { | undefined; expect(streamingOpts?.senderIsOwner).toBe(true); const streamingEvents = parseSseEvents(await streamingResponse.text()); - expect(streamingEvents.some((event) => event.event === "response.completed")).toBe(true); + expect(streamingEvents.map((event) => event.event)).toContain("response.completed"); }); it("treats shared-secret bearer callers as owner operators", async () => { @@ -950,7 +950,7 @@ describe("OpenResponses HTTP API (e2e)", () => { ?.text as string | undefined) ?? "", ).toBe("Let me check that."); expect(response?.output?.[1]?.name).toBe("get_weather"); - expect(events.some((event) => event.data === "[DONE]")).toBe(true); + expect(events.map((event) => event.data)).toContain("[DONE]"); }); it("returns every client tool call when an agent invokes multiple tools in one turn (#52288)", async () => { @@ -1095,7 +1095,7 @@ describe("OpenResponses HTTP API (e2e)", () => { "activate_graph", "get_status", ]); - expect(events.some((event) => event.data === "[DONE]")).toBe(true); + expect(events.map((event) => event.data)).toContain("[DONE]"); }); it("reuses the prior session when previous_response_id is provided", async () => { From c54a70355fac9d701b08c32e0e72161a2a334d53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:10:54 +0100 Subject: [PATCH 152/806] test: clarify oc-path scenario assertions --- .../tests/scenarios/frontmatter-edges.test.ts | 138 ++--- .../scenarios/jsonc-byte-fidelity.test.ts | 124 +++-- .../tests/scenarios/malformed-input.test.ts | 122 ++--- src/oc-path/tests/scenarios/pitfalls.test.ts | 490 +++++++++--------- .../scenarios/real-world-fixtures.test.ts | 144 ++--- 5 files changed, 519 insertions(+), 499 deletions(-) diff --git a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts index fb085e8b052..a75746067ea 100644 --- a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts +++ b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts @@ -5,136 +5,136 @@ * with quote-stripping; malformed frontmatter doesn't crash the parser * (soft-error policy: emit diagnostic, recover). */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; -describe('wave-02 frontmatter-edges', () => { - it('FM-01 simple kv pairs', () => { - const { ast } = parseMd('---\nname: x\ndescription: y\n---\n'); +describe("wave-02 frontmatter-edges", () => { + it("FM-01 simple kv pairs", () => { + const { ast } = parseMd("---\nname: x\ndescription: y\n---\n"); expect(ast.frontmatter.map((e) => [e.key, e.value])).toEqual([ - ['name', 'x'], - ['description', 'y'], + ["name", "x"], + ["description", "y"], ]); }); - it('FM-02 unclosed frontmatter emits diagnostic, treats as preamble', () => { - const { ast, diagnostics } = parseMd('---\nname: x\nno close fence\nbody\n'); - expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + it("FM-02 unclosed frontmatter emits diagnostic, treats as preamble", () => { + const { ast, diagnostics } = parseMd("---\nname: x\nno close fence\nbody\n"); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); expect(ast.frontmatter).toEqual([]); }); - it('FM-03 empty frontmatter (just open + close)', () => { - const { ast } = parseMd('---\n---\n'); + it("FM-03 empty frontmatter (just open + close)", () => { + const { ast } = parseMd("---\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-04 frontmatter only, file has no other content', () => { - const { ast } = parseMd('---\nk: v\n---\n'); - expect(ast.frontmatter).toEqual([{ key: 'k', value: 'v', line: 2 }]); - expect(ast.preamble).toBe(''); + it("FM-04 frontmatter only, file has no other content", () => { + const { ast } = parseMd("---\nk: v\n---\n"); + expect(ast.frontmatter).toEqual([{ key: "k", value: "v", line: 2 }]); + expect(ast.preamble).toBe(""); expect(ast.blocks).toEqual([]); }); - it('FM-05 double-quoted value', () => { + it("FM-05 double-quoted value", () => { const { ast } = parseMd('---\ntitle: "Hello, world"\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + expect(ast.frontmatter[0]?.value).toBe("Hello, world"); }); - it('FM-06 single-quoted value', () => { + it("FM-06 single-quoted value", () => { const { ast } = parseMd("---\ntitle: 'Hello, world'\n---\n"); - expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + expect(ast.frontmatter[0]?.value).toBe("Hello, world"); }); - it('FM-07 unquoted value with internal colons preserved', () => { - const { ast } = parseMd('---\nurl: https://example.com:443/p\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('https://example.com:443/p'); + it("FM-07 unquoted value with internal colons preserved", () => { + const { ast } = parseMd("---\nurl: https://example.com:443/p\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("https://example.com:443/p"); }); - it('FM-08 empty value', () => { - const { ast } = parseMd('---\nk:\n---\n'); - expect(ast.frontmatter[0]).toEqual({ key: 'k', value: '', line: 2 }); + it("FM-08 empty value", () => { + const { ast } = parseMd("---\nk:\n---\n"); + expect(ast.frontmatter[0]).toEqual({ key: "k", value: "", line: 2 }); }); - it('FM-09 value with leading/trailing whitespace trimmed', () => { - const { ast } = parseMd('---\nk: spaced \n---\n'); - expect(ast.frontmatter[0]?.value).toBe('spaced'); + it("FM-09 value with leading/trailing whitespace trimmed", () => { + const { ast } = parseMd("---\nk: spaced \n---\n"); + expect(ast.frontmatter[0]?.value).toBe("spaced"); }); - it('FM-10 list-style continuations are silently dropped (substrate stays opinion-free)', () => { - const { ast } = parseMd('---\ntools:\n - gh\n - curl\n---\n'); + it("FM-10 list-style continuations are silently dropped (substrate stays opinion-free)", () => { + const { ast } = parseMd("---\ntools:\n - gh\n - curl\n---\n"); // The `tools:` key has an empty inline value; the list continuation // lines ` - gh` and ` - curl` don't match the kv regex and are // skipped. Lint rules can do their own structural reading of // frontmatter; the substrate does not. - expect(ast.frontmatter.map((e) => e.key)).toEqual(['tools']); - expect(ast.frontmatter[0]?.value).toBe(''); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["tools"]); + expect(ast.frontmatter[0]?.value).toBe(""); }); - it('FM-11 line numbers are 1-based and accurate', () => { - const { ast } = parseMd('---\nk1: v1\nk2: v2\nk3: v3\n---\n'); + it("FM-11 line numbers are 1-based and accurate", () => { + const { ast } = parseMd("---\nk1: v1\nk2: v2\nk3: v3\n---\n"); expect(ast.frontmatter.map((e) => [e.key, e.line])).toEqual([ - ['k1', 2], - ['k2', 3], - ['k3', 4], + ["k1", 2], + ["k2", 3], + ["k3", 4], ]); }); - it('FM-12 dash-key allowed', () => { - const { ast } = parseMd('---\nuser-invocable: true\n---\n'); - expect(ast.frontmatter[0]?.key).toBe('user-invocable'); + it("FM-12 dash-key allowed", () => { + const { ast } = parseMd("---\nuser-invocable: true\n---\n"); + expect(ast.frontmatter[0]?.key).toBe("user-invocable"); }); - it('FM-13 underscore-key allowed', () => { - const { ast } = parseMd('---\nparam_set: foo\n---\n'); - expect(ast.frontmatter[0]?.key).toBe('param_set'); + it("FM-13 underscore-key allowed", () => { + const { ast } = parseMd("---\nparam_set: foo\n---\n"); + expect(ast.frontmatter[0]?.key).toBe("param_set"); }); - it('FM-14 number-only value preserved as string', () => { - const { ast } = parseMd('---\ntimeout: 15000\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('15000'); + it("FM-14 number-only value preserved as string", () => { + const { ast } = parseMd("---\ntimeout: 15000\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("15000"); }); - it('FM-15 boolean-like value preserved as string', () => { - const { ast } = parseMd('---\nenabled: true\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('true'); + it("FM-15 boolean-like value preserved as string", () => { + const { ast } = parseMd("---\nenabled: true\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("true"); }); - it('FM-16 blank lines inside frontmatter are skipped', () => { - const { ast } = parseMd('---\n\nk1: v1\n\nk2: v2\n\n---\n'); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k1', 'k2']); + it("FM-16 blank lines inside frontmatter are skipped", () => { + const { ast } = parseMd("---\n\nk1: v1\n\nk2: v2\n\n---\n"); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k1", "k2"]); }); - it('FM-17 frontmatter with same key twice — both retained (no dedup)', () => { + it("FM-17 frontmatter with same key twice — both retained (no dedup)", () => { // Substrate doesn't dedup; lint rules can flag duplicates if needed. - const { ast } = parseMd('---\nk: v1\nk: v2\n---\n'); + const { ast } = parseMd("---\nk: v1\nk: v2\n---\n"); expect(ast.frontmatter).toEqual([ - { key: 'k', value: 'v1', line: 2 }, - { key: 'k', value: 'v2', line: 3 }, + { key: "k", value: "v1", line: 2 }, + { key: "k", value: "v2", line: 3 }, ]); }); - it('FM-18 frontmatter must be at start — leading blank line breaks detection', () => { - const { ast } = parseMd('\n---\nk: v\n---\n'); + it("FM-18 frontmatter must be at start — leading blank line breaks detection", () => { + const { ast } = parseMd("\n---\nk: v\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-19 frontmatter must be at start — leading text breaks detection', () => { - const { ast } = parseMd('intro\n\n---\nk: v\n---\n'); + it("FM-19 frontmatter must be at start — leading text breaks detection", () => { + const { ast } = parseMd("intro\n\n---\nk: v\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-20 BOM before frontmatter open is tolerated', () => { - const { ast } = parseMd('---\nname: bom\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('bom'); + it("FM-20 BOM before frontmatter open is tolerated", () => { + const { ast } = parseMd("---\nname: bom\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("bom"); }); - it('FM-21 single-line file with `---` and `---` is empty frontmatter', () => { - const { ast } = parseMd('---\n---'); + it("FM-21 single-line file with `---` and `---` is empty frontmatter", () => { + const { ast } = parseMd("---\n---"); expect(ast.frontmatter).toEqual([]); }); - it('FM-22 hash-prefixed lines skipped (not yaml comments — just don\'t match kv regex)', () => { - const { ast } = parseMd('---\n# comment\nk: v\n---\n'); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + it("FM-22 hash-prefixed lines skipped (not yaml comments — just don't match kv regex)", () => { + const { ast } = parseMd("---\n# comment\nk: v\n---\n"); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]); }); }); diff --git a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts index 36229ee290e..dd47ebd80d9 100644 --- a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts @@ -17,10 +17,10 @@ * called it a day. JC-17 deliberately uses `assertNotParseable` — * malformed input must echo `raw` AND emit a diagnostic. */ -import { describe, expect, it } from 'vitest'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import type { JsoncValue } from '../../jsonc/ast.js'; +import { describe, expect, it } from "vitest"; +import type { JsoncValue } from "../../jsonc/ast.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; function rt(raw: string): string { return emitJsonc(parseJsonc(raw).ast); @@ -47,131 +47,141 @@ function assertParseable(raw: string): JsoncValue { function assertNotParseable(raw: string): void { const result = parseJsonc(raw); expect(result.ast.root).toBeNull(); - expect(result.diagnostics.some((d) => d.severity === 'error')).toBe(true); + expect(result.diagnostics.map((diagnostic) => diagnostic.severity)).toContain("error"); } -describe('wave-15 jsonc byte-fidelity', () => { - it('JC-01 empty file', () => { - expect(rt('')).toBe(''); +describe("wave-15 jsonc byte-fidelity", () => { + it("JC-01 empty file", () => { + expect(rt("")).toBe(""); }); - it('JC-02 whitespace-only', () => { - expect(rt(' \n\n \n')).toBe(' \n\n \n'); + it("JC-02 whitespace-only", () => { + expect(rt(" \n\n \n")).toBe(" \n\n \n"); }); - it('JC-03 empty object', () => { - expect(rt('{}')).toBe('{}'); - const root = assertParseable('{}'); - expect(root.kind).toBe('object'); - if (root.kind === 'object') {expect(root.entries).toHaveLength(0);} + it("JC-03 empty object", () => { + expect(rt("{}")).toBe("{}"); + const root = assertParseable("{}"); + expect(root.kind).toBe("object"); + if (root.kind === "object") { + expect(root.entries).toHaveLength(0); + } }); - it('JC-04 empty array', () => { - expect(rt('[]')).toBe('[]'); - const root = assertParseable('[]'); - expect(root.kind).toBe('array'); - if (root.kind === 'array') {expect(root.items).toHaveLength(0);} + it("JC-04 empty array", () => { + expect(rt("[]")).toBe("[]"); + const root = assertParseable("[]"); + expect(root.kind).toBe("array"); + if (root.kind === "array") { + expect(root.items).toHaveLength(0); + } }); - it('JC-05 trivial scalar root', () => { - expect(rt('42')).toBe('42'); + it("JC-05 trivial scalar root", () => { + expect(rt("42")).toBe("42"); expect(rt('"x"')).toBe('"x"'); - expect(rt('true')).toBe('true'); - expect(rt('null')).toBe('null'); - expect(assertParseable('42').kind).toBe('number'); - expect(assertParseable('"x"').kind).toBe('string'); - expect(assertParseable('true').kind).toBe('boolean'); - expect(assertParseable('null').kind).toBe('null'); + expect(rt("true")).toBe("true"); + expect(rt("null")).toBe("null"); + expect(assertParseable("42").kind).toBe("number"); + expect(assertParseable('"x"').kind).toBe("string"); + expect(assertParseable("true").kind).toBe("boolean"); + expect(assertParseable("null").kind).toBe("null"); }); - it('JC-06 line comments preserved', () => { + it("JC-06 line comments preserved", () => { const raw = '// a leading comment\n{ "x": 1 } // trailing\n'; expect(rt(raw)).toBe(raw); // Pin parse: the structural value `x: 1` is reachable. const root = assertParseable(raw); - expect(root.kind).toBe('object'); + expect(root.kind).toBe("object"); }); - it('JC-07 block comments preserved', () => { + it("JC-07 block comments preserved", () => { const raw = '/* header */\n{\n /* inline */\n "x": 1\n}\n'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - expect(root.kind).toBe('object'); + expect(root.kind).toBe("object"); }); - it('JC-08 trailing commas preserved', () => { + it("JC-08 trailing commas preserved", () => { const raw = '{\n "x": 1,\n "y": 2,\n}'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') {expect(root.entries).toHaveLength(2);} + if (root.kind === "object") { + expect(root.entries).toHaveLength(2); + } }); - it('JC-09 mixed CRLF + LF preserved', () => { + it("JC-09 mixed CRLF + LF preserved", () => { const raw = '{\r\n "x": 1,\n "y": 2\r\n}'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') {expect(root.entries.map((e) => e.key)).toEqual(['x', 'y']);} + if (root.kind === "object") { + expect(root.entries.map((e) => e.key)).toEqual(["x", "y"]); + } }); - it('JC-10 BOM preserved on raw', () => { + it("JC-10 BOM preserved on raw", () => { const raw = '{ "x": 1 }'; expect(rt(raw)).toBe(raw); // BOM stripped before parsing — parser still sees `{` as first char. - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-11 deeply nested structures preserved', () => { + it("JC-11 deeply nested structures preserved", () => { const raw = '{ "a": { "b": { "c": { "d": [1, [2, [3, [4]]]] } } } }'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-12 string with escape sequences preserved', () => { + it("JC-12 string with escape sequences preserved", () => { const raw = '{ "s": "a\\nb\\tc\\u0041\\\\d\\"e" }'; expect(rt(raw)).toBe(raw); // Pin escape resolution — parsed value carries actual control chars. const root = assertParseable(raw); - if (root.kind === 'object') { + if (root.kind === "object") { const s = root.entries[0]?.value; - if (s?.kind === 'string') { + if (s?.kind === "string") { expect(s.value).toBe('a\nb\tcA\\d"e'); } } }); - it('JC-13 numbers in scientific / negative / decimal forms preserved', () => { - const raw = '[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]'; + it("JC-13 numbers in scientific / negative / decimal forms preserved", () => { + const raw = "[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]"; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'array') { + if (root.kind === "array") { expect(root.items).toHaveLength(7); - expect(root.items.every((v) => v.kind === 'number')).toBe(true); + expect(root.items.filter((item) => item.kind !== "number")).toEqual([]); } }); - it('JC-14 unicode characters preserved verbatim', () => { + it("JC-14 unicode characters preserved verbatim", () => { const raw = '{ "name": "héllo 世界 🎉" }'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') { + if (root.kind === "object") { const v = root.entries[0]?.value; - if (v?.kind === 'string') {expect(v.value).toBe('héllo 世界 🎉');} + if (v?.kind === "string") { + expect(v.value).toBe("héllo 世界 🎉"); + } } }); - it('JC-15 idiosyncratic whitespace preserved', () => { + it("JC-15 idiosyncratic whitespace preserved", () => { const raw = '{ "x" : 1 ,\n "y": 2}'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-16 file-level trailing whitespace preserved', () => { + it("JC-16 file-level trailing whitespace preserved", () => { const raw = '{ "x": 1 }\n\n\n'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-17 malformed input still emits raw verbatim AND emits a diagnostic', () => { + it("JC-17 malformed input still emits raw verbatim AND emits a diagnostic", () => { const raw = '{ broken json with "key": value }'; expect(rt(raw)).toBe(raw); // Without this assertion the test passes for any input regardless @@ -179,8 +189,8 @@ describe('wave-15 jsonc byte-fidelity', () => { assertNotParseable(raw); }); - it('JC-18 comments-only file preserved', () => { - const raw = '// just a comment\n/* and a block */\n'; + it("JC-18 comments-only file preserved", () => { + const raw = "// just a comment\n/* and a block */\n"; expect(rt(raw)).toBe(raw); // Comments-only files have no structural root — that's expected. expect(parseJsonc(raw).ast.root).toBeNull(); diff --git a/src/oc-path/tests/scenarios/malformed-input.test.ts b/src/oc-path/tests/scenarios/malformed-input.test.ts index baa011352ae..9765df08a42 100644 --- a/src/oc-path/tests/scenarios/malformed-input.test.ts +++ b/src/oc-path/tests/scenarios/malformed-input.test.ts @@ -5,151 +5,151 @@ * malformed input. Suspicious-but-recoverable inputs produce * diagnostics; unparseable structural pieces are dropped silently. */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; -describe('wave-11 malformed-input', () => { - it('M-01 truncated mid-frontmatter (no close fence)', () => { - const raw = '---\nname: github\n'; +describe("wave-11 malformed-input", () => { + it("M-01 truncated mid-frontmatter (no close fence)", () => { + const raw = "---\nname: github\n"; const { ast, diagnostics } = parseMd(raw); - expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); expect(ast.frontmatter).toEqual([]); }); - it('M-02 truncated mid-section', () => { - const raw = '## H\n- item\nmid-line'; + it("M-02 truncated mid-section", () => { + const raw = "## H\n- item\nmid-line"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1); }); - it('M-03 only `---` (single fence, no content)', () => { - expect(() => parseMd('---\n')).not.toThrow(); + it("M-03 only `---` (single fence, no content)", () => { + expect(() => parseMd("---\n")).not.toThrow(); }); - it('M-04 only `---\\n---`', () => { - const { ast } = parseMd('---\n---'); + it("M-04 only `---\\n---`", () => { + const { ast } = parseMd("---\n---"); expect(ast.frontmatter).toEqual([]); }); - it('M-05 binary-ish bytes (non-ASCII control chars)', () => { - const raw = '## H\n\x00\x01\x02\n'; + it("M-05 binary-ish bytes (non-ASCII control chars)", () => { + const raw = "## H\n\x00\x01\x02\n"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-06 very long single line (10k chars)', () => { - const raw = `## H\n${'x'.repeat(10_000)}\n`; + it("M-06 very long single line (10k chars)", () => { + const raw = `## H\n${"x".repeat(10_000)}\n`; const { ast } = parseMd(raw); - expect(ast.blocks[0]?.heading).toBe('H'); + expect(ast.blocks[0]?.heading).toBe("H"); }); - it('M-07 deeply repeated headings (1000 H2 blocks)', () => { + it("M-07 deeply repeated headings (1000 H2 blocks)", () => { const lines: string[] = []; for (let i = 0; i < 1000; i++) { lines.push(`## H${i}`); lines.push(`- item ${i}`); } - const raw = lines.join('\n') + '\n'; + const raw = lines.join("\n") + "\n"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1000); }); - it('M-08 bullet shape that isn\'t actually a bullet (`-not-a-bullet`)', () => { - const { ast } = parseMd('## H\n-not-a-bullet\n- real\n'); + it("M-08 bullet shape that isn't actually a bullet (`-not-a-bullet`)", () => { + const { ast } = parseMd("## H\n-not-a-bullet\n- real\n"); expect(ast.blocks[0]?.items.length).toBe(1); }); - it('M-09 unclosed code fence', () => { - const raw = '## H\n```\nbody\n'; + it("M-09 unclosed code fence", () => { + const raw = "## H\n```\nbody\n"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-10 mismatched fence (open with ``` close with ~~~)', () => { - const raw = '## H\n```\nbody\n~~~\n'; + it("M-10 mismatched fence (open with ``` close with ~~~)", () => { + const raw = "## H\n```\nbody\n~~~\n"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-11 nested fences (treated linearly, not nested)', () => { - const raw = '## H\n```\n```\nstill-in-second\n```\n'; + it("M-11 nested fences (treated linearly, not nested)", () => { + const raw = "## H\n```\n```\nstill-in-second\n```\n"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-12 empty file', () => { - const { ast, diagnostics } = parseMd(''); - expect(ast.raw).toBe(''); + it("M-12 empty file", () => { + const { ast, diagnostics } = parseMd(""); + expect(ast.raw).toBe(""); expect(ast.frontmatter).toEqual([]); expect(ast.blocks).toEqual([]); expect(diagnostics).toEqual([]); }); - it('M-13 single character file', () => { - const { ast } = parseMd('x'); - expect(ast.preamble).toBe('x'); + it("M-13 single character file", () => { + const { ast } = parseMd("x"); + expect(ast.preamble).toBe("x"); expect(ast.blocks).toEqual([]); }); - it('M-14 single newline file', () => { - const { ast } = parseMd('\n'); + it("M-14 single newline file", () => { + const { ast } = parseMd("\n"); expect(ast.blocks).toEqual([]); }); - it('M-15 file with mixed indentation extremes (tabs, spaces, mixed)', () => { - const raw = '## H\n\t- tabbed\n - spaced\n\t - mixed\n'; + it("M-15 file with mixed indentation extremes (tabs, spaces, mixed)", () => { + const raw = "## H\n\t- tabbed\n - spaced\n\t - mixed\n"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-16 frontmatter with frontmatter-shaped content inside (---)', () => { - const raw = '---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n'; + it("M-16 frontmatter with frontmatter-shaped content inside (---)", () => { + const raw = "---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n"; const { ast } = parseMd(raw); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]); // Second `---` block becomes part of preamble/body (it's not at file start). - expect(ast.preamble).toContain('---'); + expect(ast.preamble).toContain("---"); }); - it('M-17 lines starting with `#` but not heading (raw `#` chars in body)', () => { - const raw = '## H\n\n# This is text starting with #\n#### h4 not parsed as block\n'; + it("M-17 lines starting with `#` but not heading (raw `#` chars in body)", () => { + const raw = "## H\n\n# This is text starting with #\n#### h4 not parsed as block\n"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1); - expect(ast.blocks[0]?.bodyText).toContain('# This is text'); + expect(ast.blocks[0]?.bodyText).toContain("# This is text"); }); - it('M-18 lines starting with multiple ## but malformed (####, ######)', () => { - const { ast } = parseMd('## Real\n#### Not block\n###### Not block\n'); + it("M-18 lines starting with multiple ## but malformed (####, ######)", () => { + const { ast } = parseMd("## Real\n#### Not block\n###### Not block\n"); expect(ast.blocks.length).toBe(1); - expect(ast.blocks[0]?.heading).toBe('Real'); + expect(ast.blocks[0]?.heading).toBe("Real"); }); - it('M-19 file with just whitespace', () => { - expect(() => parseMd(' \n\t\n \n')).not.toThrow(); + it("M-19 file with just whitespace", () => { + expect(() => parseMd(" \n\t\n \n")).not.toThrow(); }); - it('M-20 file with only BOM', () => { - const { ast } = parseMd(''); - expect(ast.raw).toBe(''); + it("M-20 file with only BOM", () => { + const { ast } = parseMd(""); + expect(ast.raw).toBe(""); }); - it('M-21 file mixing BOM + frontmatter + body + sections', () => { - const raw = '---\nk: v\n---\n\nbody\n## Section\n- item\n'; + it("M-21 file mixing BOM + frontmatter + body + sections", () => { + const raw = "---\nk: v\n---\n\nbody\n## Section\n- item\n"; expect(() => parseMd(raw)).not.toThrow(); const { ast } = parseMd(raw); - expect(ast.frontmatter[0]?.value).toBe('v'); - expect(ast.blocks[0]?.heading).toBe('Section'); + expect(ast.frontmatter[0]?.value).toBe("v"); + expect(ast.blocks[0]?.heading).toBe("Section"); }); - it('M-22 line endings: legacy CR-only (Mac classic)', () => { + it("M-22 line endings: legacy CR-only (Mac classic)", () => { // Our regex /\r?\n/ doesn't split on CR-only. Treats whole as one line. - const raw = 'line1\rline2\r## Heading\r'; + const raw = "line1\rline2\r## Heading\r"; expect(() => parseMd(raw)).not.toThrow(); }); - it('M-23 100 KB file', () => { + it("M-23 100 KB file", () => { const lines: string[] = []; for (let i = 0; i < 1000; i++) { - lines.push('## H' + i); + lines.push("## H" + i); for (let j = 0; j < 5; j++) { lines.push(`- item-${i}-${j}: value with some text content here`); } } - const raw = lines.join('\n'); + const raw = lines.join("\n"); expect(() => parseMd(raw)).not.toThrow(); }); }); diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts index 245c2dfabce..a70e9bb1ed5 100644 --- a/src/oc-path/tests/scenarios/pitfalls.test.ts +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -14,7 +14,7 @@ * pitfalls — e.g., P-033 there is "Memory poisoning"). The package * boundary disambiguates. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; import { MAX_PATH_LENGTH, MAX_TRAVERSAL_DEPTH, @@ -24,198 +24,206 @@ import { parseOcPath, resolveOcPath, setOcPath, -} from '../../index.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseYaml } from '../../yaml/parse.js'; +} from "../../index.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { parseYaml } from "../../yaml/parse.js"; // ---------- Encoding pitfalls -------------------------------------------- -describe('wave-23 pitfalls — encoding', () => { - it('P-001 strips leading UTF-8 BOM from path string', () => { - const bom = ''; - expect(parseOcPath(`${bom}oc://X/Y`).file).toBe('X'); +describe("wave-23 pitfalls — encoding", () => { + it("P-001 strips leading UTF-8 BOM from path string", () => { + const bom = ""; + expect(parseOcPath(`${bom}oc://X/Y`).file).toBe("X"); }); - it('P-002 normalizes path to NFC', () => { - const nfc = 'café'; // composed - const nfd = 'café'; // decomposed + it("P-002 normalizes path to NFC", () => { + const nfc = "café"; // composed + const nfd = "café"; // decomposed expect(parseOcPath(`oc://X/${nfd}`).section).toBe(nfc); expect(parseOcPath(`oc://X/${nfc}`).section).toBe(nfc); // Same struct out for both inputs. expect(parseOcPath(`oc://X/${nfd}`)).toEqual(parseOcPath(`oc://X/${nfc}`)); }); - it('P-003 rejects whitespace in identifier-shaped segments', () => { - expect(() => parseOcPath('oc://X/foo /bar')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/ foo')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/foo\tbar')).toThrow(OcPathError); + it("P-003 rejects whitespace in identifier-shaped segments", () => { + expect(() => parseOcPath("oc://X/foo /bar")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/ foo")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/foo\tbar")).toThrow(OcPathError); }); - it('P-003 allows whitespace inside predicate values (content)', () => { + it("P-003 allows whitespace inside predicate values (content)", () => { // Spaces inside a predicate value are legitimate — they're filtering // against actual content. - expect(() => parseOcPath('oc://X/[name=hello world]')).not.toThrow(); + expect(() => parseOcPath("oc://X/[name=hello world]")).not.toThrow(); }); - it('P-004 / P-011 rejects control characters and null bytes', () => { - expect(() => parseOcPath('oc://X/\x00')).toThrow(/Control character/); - expect(() => parseOcPath('oc://X/foo\x01bar')).toThrow(/Control character/); - expect(() => parseOcPath('oc://X/foo\x7Fbar')).toThrow(/Control character/); + it("P-004 / P-011 rejects control characters and null bytes", () => { + expect(() => parseOcPath("oc://X/\x00")).toThrow(/Control character/); + expect(() => parseOcPath("oc://X/foo\x01bar")).toThrow(/Control character/); + expect(() => parseOcPath("oc://X/foo\x7Fbar")).toThrow(/Control character/); }); }); // ---------- Empty / structural pitfalls ---------------------------------- -describe('wave-23 pitfalls — empty & structural', () => { - it('P-008 rejects empty segments', () => { - expect(() => parseOcPath('oc://X//Y')).toThrow(/Empty segment/); +describe("wave-23 pitfalls — empty & structural", () => { + it("P-008 rejects empty segments", () => { + expect(() => parseOcPath("oc://X//Y")).toThrow(/Empty segment/); }); - it('P-009 rejects empty dotted sub-segments', () => { - expect(() => parseOcPath('oc://X/a..b')).toThrow(/Empty dotted sub-segment/); + it("P-009 rejects empty dotted sub-segments", () => { + expect(() => parseOcPath("oc://X/a..b")).toThrow(/Empty dotted sub-segment/); }); - it('P-010 rejects scheme-only path', () => { - expect(() => parseOcPath('oc://')).toThrow(/Empty oc:\/\/ path/); + it("P-010 rejects scheme-only path", () => { + expect(() => parseOcPath("oc://")).toThrow(/Empty oc:\/\/ path/); }); - it('P-014 rejects empty predicate key', () => { - expect(() => parseOcPath('oc://X/[=foo]')).toThrow(/Malformed predicate/); + it("P-014 rejects empty predicate key", () => { + expect(() => parseOcPath("oc://X/[=foo]")).toThrow(/Malformed predicate/); }); - it('P-014 rejects empty predicate value', () => { - expect(() => parseOcPath('oc://X/[id=]')).toThrow(/Malformed predicate/); + it("P-014 rejects empty predicate value", () => { + expect(() => parseOcPath("oc://X/[id=]")).toThrow(/Malformed predicate/); }); - it('P-015 accepts bracket segment with no operator as literal sentinel', () => { + it("P-015 accepts bracket segment with no operator as literal sentinel", () => { // `[frontmatter]` predates the predicate grammar — kept as literal. - expect(parseOcPath('oc://AGENTS.md/[frontmatter]/key').section).toBe('[frontmatter]'); + expect(parseOcPath("oc://AGENTS.md/[frontmatter]/key").section).toBe("[frontmatter]"); }); - it('P-016 rejects mismatched brackets', () => { - expect(() => parseOcPath('oc://X/[unclosed')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/closed]')).toThrow(OcPathError); + it("P-016 rejects mismatched brackets", () => { + expect(() => parseOcPath("oc://X/[unclosed")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/closed]")).toThrow(OcPathError); }); - it('P-016 rejects mismatched braces', () => { - expect(() => parseOcPath('oc://X/{a,b')).toThrow(OcPathError); + it("P-016 rejects mismatched braces", () => { + expect(() => parseOcPath("oc://X/{a,b")).toThrow(OcPathError); }); - it('P-018 rejects empty union', () => { - expect(() => parseOcPath('oc://X/{}')).toThrow(/Empty union/); + it("P-018 rejects empty union", () => { + expect(() => parseOcPath("oc://X/{}")).toThrow(/Empty union/); }); - it('P-018 rejects union with empty alternative', () => { - expect(() => parseOcPath('oc://X/{a,,b}')).toThrow(/Empty alternative/); + it("P-018 rejects union with empty alternative", () => { + expect(() => parseOcPath("oc://X/{a,,b}")).toThrow(/Empty alternative/); }); }); // ---------- Predicate-content pitfalls ----------------------------------- -describe('wave-23 pitfalls — predicate content', () => { - it('P-012 predicate value containing `/` round-trips', () => { +describe("wave-23 pitfalls — predicate content", () => { + it("P-012 predicate value containing `/` round-trips", () => { // The path-level `/` split must respect bracket boundaries. - const p = parseOcPath('oc://X/[id=foo/bar]/cmd'); - expect(p.section).toBe('[id=foo/bar]'); - expect(p.item).toBe('cmd'); + const p = parseOcPath("oc://X/[id=foo/bar]/cmd"); + expect(p.section).toBe("[id=foo/bar]"); + expect(p.item).toBe("cmd"); }); - it('P-012 findOcPaths matches a leaf whose id contains a slash', () => { - const ast = parseYaml( - 'steps:\n - id: foo/bar\n cmd: x\n - id: baz\n cmd: y\n' - ).ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=foo/bar]/cmd')); + it("P-012 findOcPaths matches a leaf whose id contains a slash", () => { + const ast = parseYaml("steps:\n - id: foo/bar\n cmd: x\n - id: baz\n cmd: y\n").ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=foo/bar]/cmd")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("x"); + } }); - it('P-013 predicate value containing `.` round-trips', () => { - const p = parseOcPath('oc://X/steps.[id=1.0].cmd'); - expect(p.section).toBe('steps.[id=1.0].cmd'); + it("P-013 predicate value containing `.` round-trips", () => { + const p = parseOcPath("oc://X/steps.[id=1.0].cmd"); + expect(p.section).toBe("steps.[id=1.0].cmd"); }); - it('P-013 findOcPaths matches a leaf whose id is `1.0`', () => { - const ast = parseYaml( - 'steps:\n - id: "1.0"\n cmd: x\n - id: "2.0"\n cmd: y\n' - ).ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=1.0]/cmd')); + it("P-013 findOcPaths matches a leaf whose id is `1.0`", () => { + const ast = parseYaml('steps:\n - id: "1.0"\n cmd: x\n - id: "2.0"\n cmd: y\n').ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=1.0]/cmd")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("x"); + } }); }); // ---------- Sentinel & collision pitfalls -------------------------------- -describe('wave-23 pitfalls — sentinels & collisions', () => { - it('P-020/openclaw#59934 negative numeric key on object resolves as literal key', () => { +describe("wave-23 pitfalls — sentinels & collisions", () => { + it("P-020/openclaw#59934 negative numeric key on object resolves as literal key", () => { // Telegram supergroup IDs are negative numbers used as map keys. // Our positional `-N` token would otherwise hijack them. Resolver // falls through to literal-key lookup on non-indexable containers. const ast = parseJsonc( - '{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}' + '{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}', ).ast; const m = resolveOcPath( ast, - parseOcPath('oc://config/channels.telegram.groups.-5028303500.requireMention'), + parseOcPath("oc://config/channels.telegram.groups.-5028303500.requireMention"), ); expect(m).not.toBeNull(); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') { - expect(m.valueText).toBe('false'); - expect(m.leafType).toBe('boolean'); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("false"); + expect(m.leafType).toBe("boolean"); } }); - it('P-020 negative `-N` still works as positional on arrays', () => { + it("P-020 negative `-N` still works as positional on arrays", () => { // Same syntax, indexable container — positional resolution wins. const ast = parseJsonc('{"items":[10,20,30]}').ast; - const m = resolveOcPath(ast, parseOcPath('oc://X/items/-1')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + const m = resolveOcPath(ast, parseOcPath("oc://X/items/-1")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("30"); + } }); - it('P-020 numeric segment dispatches by node kind (array index vs map key)', () => { + it("P-020 numeric segment dispatches by node kind (array index vs map key)", () => { // Same path string against two different ASTs — kind disambiguates. const arr = parseJsonc('{"x":["a","b"]}').ast; const map = parseJsonc('{"x":{"0":"a","1":"b"}}').ast; - const arrM = resolveOcPath(arr, parseOcPath('oc://config/x/0')); - const mapM = resolveOcPath(map, parseOcPath('oc://config/x/0')); - expect(arrM?.kind).toBe('leaf'); - expect(mapM?.kind).toBe('leaf'); - if (arrM?.kind === 'leaf') {expect(arrM.valueText).toBe('a');} - if (mapM?.kind === 'leaf') {expect(mapM.valueText).toBe('a');} + const arrM = resolveOcPath(arr, parseOcPath("oc://config/x/0")); + const mapM = resolveOcPath(map, parseOcPath("oc://config/x/0")); + expect(arrM?.kind).toBe("leaf"); + expect(mapM?.kind).toBe("leaf"); + if (arrM?.kind === "leaf") { + expect(arrM.valueText).toBe("a"); + } + if (mapM?.kind === "leaf") { + expect(mapM.valueText).toBe("a"); + } }); - it('P-021 `$last` literal in a yaml key is shadowed by positional sentinel', () => { + it("P-021 `$last` literal in a yaml key is shadowed by positional sentinel", () => { // Document v0 limitation: `$last` always means "last", never a literal key. // Authors with `$last` literal keys must use kind-narrow access. - const ast = parseYaml('$last: literal-value\nfoo: bar\n').ast; - const m = resolveOcPath(ast, parseOcPath('oc://X/$last')); + const ast = parseYaml("$last: literal-value\nfoo: bar\n").ast; + const m = resolveOcPath(ast, parseOcPath("oc://X/$last")); // `$last` resolves to the LAST key (`foo` → `bar`), not the literal `$last` key. - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('bar');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("bar"); + } }); }); // ---------- Round-trip pitfalls ------------------------------------------ -describe('wave-23 pitfalls — round-trip', () => { - it('P-023 parseOcPath ∘ formatOcPath is idempotent across path shapes', () => { +describe("wave-23 pitfalls — round-trip", () => { + it("P-023 parseOcPath ∘ formatOcPath is idempotent across path shapes", () => { const inputs = [ - 'oc://X', - 'oc://X/a', - 'oc://X/a/b', - 'oc://X/a/b/c', - 'oc://X/a.b.c', - 'oc://X/a?session=s1', - 'oc://X/[frontmatter]/key', - 'oc://X/steps/*/command', - 'oc://X/steps/$last/id', - 'oc://X/steps/-2/id', - 'oc://X/steps/{command,run}', - 'oc://X/steps/[id=foo]/cmd', - 'oc://X/steps/#0/foo', + "oc://X", + "oc://X/a", + "oc://X/a/b", + "oc://X/a/b/c", + "oc://X/a.b.c", + "oc://X/a?session=s1", + "oc://X/[frontmatter]/key", + "oc://X/steps/*/command", + "oc://X/steps/$last/id", + "oc://X/steps/-2/id", + "oc://X/steps/{command,run}", + "oc://X/steps/[id=foo]/cmd", + "oc://X/steps/#0/foo", ]; for (const s of inputs) { const parsed = parseOcPath(s); @@ -227,36 +235,36 @@ describe('wave-23 pitfalls — round-trip', () => { // ---------- Sentinel-guard pitfalls -------------------------------------- -describe('wave-23 pitfalls — sentinel at format boundary (F9)', () => { - it('formatOcPath rejects an OcPath struct carrying the redaction sentinel', () => { +describe("wave-23 pitfalls — sentinel at format boundary (F9)", () => { + it("formatOcPath rejects an OcPath struct carrying the redaction sentinel", () => { // Path strings flow into telemetry, audit events, error messages, // find-result `path` fields. Without the format-time guard, a // struct with `section: REDACTED_SENTINEL` would slip past every // consumer except the CLI's scrubSentinel layer. The substrate's // contract is "emit boundaries refuse the sentinel" — formatOcPath // IS such a boundary for path strings. - expect(() => - formatOcPath({ file: 'AGENTS.md', section: '__OPENCLAW_REDACTED__' }), - ).toThrow(/sentinel literal/); + expect(() => formatOcPath({ file: "AGENTS.md", section: "__OPENCLAW_REDACTED__" })).toThrow( + /sentinel literal/, + ); }); }); // ---------- Containment pitfalls ----------------------------------------- -describe('wave-23 pitfalls — file-slot containment', () => { +describe("wave-23 pitfalls — file-slot containment", () => { // oc:// paths are workspace-relative. Absolute paths and `..` segments // would let a hostile workflow / skill manifest persuade // `openclaw path resolve|set|emit` into reading or writing arbitrary // filesystem locations (Node `path.resolve(cwd, absolute)` returns // `absolute`, bypassing the workspace root). Reject at parseOcPath // and formatOcPath for symmetric defense. - it('rejects an absolute POSIX file slot', () => { - expect(() => parseOcPath('oc:///etc/passwd')).toThrow(/Empty segment/); + it("rejects an absolute POSIX file slot", () => { + expect(() => parseOcPath("oc:///etc/passwd")).toThrow(/Empty segment/); // Quoted form — same containment violation, different parse path. expect(() => parseOcPath('oc://"/etc/passwd"/section')).toThrow(/Absolute file slot/); }); - it('rejects a Windows drive-letter file slot', () => { + it("rejects a Windows drive-letter file slot", () => { expect(() => parseOcPath('oc://"C:/Windows/System32/foo"/section')).toThrow( /Absolute file slot/, ); @@ -265,22 +273,22 @@ describe('wave-23 pitfalls — file-slot containment', () => { ); }); - it('rejects a leading-backslash file slot', () => { + it("rejects a leading-backslash file slot", () => { expect(() => parseOcPath('oc://"\\\\srv\\\\share\\\\foo"/section')).toThrow( /Absolute file slot/, ); }); - it('rejects a parent-directory escape via plain `..`', () => { + it("rejects a parent-directory escape via plain `..`", () => { expect(() => parseOcPath('oc://"../foo"/section')).toThrow(/Parent-directory/); expect(() => parseOcPath('oc://".."/section')).toThrow(/Parent-directory/); }); - it('rejects a parent-directory escape mid-path', () => { + it("rejects a parent-directory escape mid-path", () => { expect(() => parseOcPath('oc://"foo/../bar"/section')).toThrow(/Parent-directory/); }); - it('does not decode URL-encoded `..` — literal `%2E%2E` is treated as a filename', () => { + it("does not decode URL-encoded `..` — literal `%2E%2E` is treated as a filename", () => { // The substrate does NOT do URL decoding — `%2E%2E` is the literal // five-character filename, not a parent-directory escape. Documented // limitation: consumers that pre-decode (HTTP layers, browser UI) @@ -288,72 +296,70 @@ describe('wave-23 pitfalls — file-slot containment', () => { // Pin the current behavior so a future "let's decode for them" PR // sees the explicit choice. const p = parseOcPath('oc://"%2E%2E/foo"/section'); - expect(p.file).toBe('%2E%2E/foo'); + expect(p.file).toBe("%2E%2E/foo"); }); - it('formatOcPath rejects an OcPath struct with absolute file', () => { - expect(() => formatOcPath({ file: '/etc/passwd' })).toThrow(/Absolute file slot/); - expect(() => formatOcPath({ file: 'C:/Windows' })).toThrow(/Absolute file slot/); + it("formatOcPath rejects an OcPath struct with absolute file", () => { + expect(() => formatOcPath({ file: "/etc/passwd" })).toThrow(/Absolute file slot/); + expect(() => formatOcPath({ file: "C:/Windows" })).toThrow(/Absolute file slot/); }); - it('formatOcPath rejects an OcPath struct with parent-directory file', () => { - expect(() => formatOcPath({ file: '..' })).toThrow(/Parent-directory/); - expect(() => formatOcPath({ file: '../etc/passwd' })).toThrow(/Parent-directory/); - expect(() => formatOcPath({ file: 'foo/../bar' })).toThrow(/Parent-directory/); + it("formatOcPath rejects an OcPath struct with parent-directory file", () => { + expect(() => formatOcPath({ file: ".." })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: "../etc/passwd" })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: "foo/../bar" })).toThrow(/Parent-directory/); }); }); // ---------- formatOcPath ↔ parseOcPath round-trip ------------------------ -describe('wave-23 pitfalls — format/parse round-trip', () => { +describe("wave-23 pitfalls — format/parse round-trip", () => { // The contract on oc-path.ts:13 — `formatOcPath(parseOcPath(s)) === s` // for any string the formatter accepts. Round-trip breaks were // observable on (a) struct fields with empty dotted sub-segments // (`section: 'foo.'` → `oc://X/foo.""` → re-parses with `section: // 'foo.""'`) and (b) struct fields with control chars (formatter // emitted unquoted, parser refused). Pin both directions. - it('formatOcPath rejects empty dotted sub-segment in a slot', () => { - expect(() => formatOcPath({ file: 'a.md', section: 'foo.' })).toThrow( + it("formatOcPath rejects empty dotted sub-segment in a slot", () => { + expect(() => formatOcPath({ file: "a.md", section: "foo." })).toThrow( /Empty dotted sub-segment/, ); - expect(() => formatOcPath({ file: 'a.md', section: '.foo' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: ".foo" })).toThrow( /Empty dotted sub-segment/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'foo..bar' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: "foo..bar" })).toThrow( /Empty dotted sub-segment/, ); }); - it('formatOcPath rejects control characters in any slot', () => { - expect(() => formatOcPath({ file: 'a.md', section: 'sec\x00tion' })).toThrow( + it("formatOcPath rejects control characters in any slot", () => { + expect(() => formatOcPath({ file: "a.md", section: "sec\x00tion" })).toThrow( /Control character/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'sec\x01tion' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: "sec\x01tion" })).toThrow( /Control character/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'tab\ttion' })).toThrow( - /Control character/, - ); - expect(() => formatOcPath({ file: 'a\x00b.md' })).toThrow(/Control character/); + expect(() => formatOcPath({ file: "a.md", section: "tab\ttion" })).toThrow(/Control character/); + expect(() => formatOcPath({ file: "a\x00b.md" })).toThrow(/Control character/); }); - it('round-trips every shape parseOcPath accepts', () => { + it("round-trips every shape parseOcPath accepts", () => { // For every valid input, formatOcPath(parseOcPath(s)) MUST be // re-parseable to the same struct. Don't string-compare (the // formatter normalizes quoting); parse the round-tripped output // and compare structs. const inputs = [ - 'oc://X', - 'oc://X/a', - 'oc://X/a/b', - 'oc://X/a/b/c', - 'oc://X/a.b.c', - 'oc://X/a?session=s1', - 'oc://X/[frontmatter]/key', - 'oc://X/steps/$last/id', - 'oc://X/steps/-2/id', - 'oc://X/steps/[id=foo]/cmd', - 'oc://X/steps/{a,b}/cmd', + "oc://X", + "oc://X/a", + "oc://X/a/b", + "oc://X/a/b/c", + "oc://X/a.b.c", + "oc://X/a?session=s1", + "oc://X/[frontmatter]/key", + "oc://X/steps/$last/id", + "oc://X/steps/-2/id", + "oc://X/steps/[id=foo]/cmd", + "oc://X/steps/{a,b}/cmd", 'oc://X/"foo/bar"/baz', 'oc://X/agents/"anthropic/claude-opus-4-7"/alias', ]; @@ -368,58 +374,58 @@ describe('wave-23 pitfalls — format/parse round-trip', () => { // ---------- Performance pitfalls ----------------------------------------- -describe('wave-23 pitfalls — performance & limits', () => { - it('P-031 / P-033 walker depth cap throws on pathological recursion', () => { +describe("wave-23 pitfalls — performance & limits", () => { + it("P-031 / P-033 walker depth cap throws on pathological recursion", () => { // Construct a yaml that nests deeper than MAX_TRAVERSAL_DEPTH. // We're using `**` against a synthetic deeply-nested structure. - let yaml = 'root:\n'; - let indent = ' '; + let yaml = "root:\n"; + let indent = " "; for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { yaml += `${indent}a:\n`; - indent += ' '; + indent += " "; } yaml += `${indent}leaf: x\n`; const ast = parseYaml(yaml).ast; - expect(() => findOcPaths(ast, parseOcPath('oc://X/**'))).toThrow(/MAX_TRAVERSAL_DEPTH/); + expect(() => findOcPaths(ast, parseOcPath("oc://X/**"))).toThrow(/MAX_TRAVERSAL_DEPTH/); }); - it('P-032 rejects path strings longer than MAX_PATH_LENGTH', () => { - const big = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH); + it("P-032 rejects path strings longer than MAX_PATH_LENGTH", () => { + const big = "oc://X/" + "a".repeat(MAX_PATH_LENGTH); expect(() => parseOcPath(big)).toThrow(/exceeds .* bytes/); }); - it('P-032 path at the cap parses cleanly', () => { - const justUnder = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH - 'oc://X/'.length); + it("P-032 path at the cap parses cleanly", () => { + const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length); expect(() => parseOcPath(justUnder)).not.toThrow(); }); - it('P-032 formatOcPath enforces the same cap on output', () => { + it("P-032 formatOcPath enforces the same cap on output", () => { // Symmetric upper bound — without this guard, a struct whose // formatted form crosses the cap would emit a string parseOcPath // would immediately reject (round-trip break). - expect(() => - formatOcPath({ file: 'X', section: 'a'.repeat(MAX_PATH_LENGTH) }), - ).toThrow(/Formatted oc:\/\/ exceeds/); + expect(() => formatOcPath({ file: "X", section: "a".repeat(MAX_PATH_LENGTH) })).toThrow( + /Formatted oc:\/\/ exceeds/, + ); }); - it('parser depth cap fires on pathological JSONC nesting (F6)', () => { + it("parser depth cap fires on pathological JSONC nesting (F6)", () => { // Without `MAX_PARSE_DEPTH`, pathological input like // `'['.repeat(20000) + '0' + ']'.repeat(20000)` triggers a V8 // RangeError ("Maximum call stack size exceeded") that escapes // commander as a raw stringified error — no `OcEmitSentinelError`- // style structured catch. Pin the structured-diagnostic path: // parser must surface OC_JSONC_DEPTH_EXCEEDED, not bare RangeError. - const open = '['.repeat(MAX_TRAVERSAL_DEPTH + 100); - const close = ']'.repeat(MAX_TRAVERSAL_DEPTH + 100); + const open = "[".repeat(MAX_TRAVERSAL_DEPTH + 100); + const close = "]".repeat(MAX_TRAVERSAL_DEPTH + 100); const raw = `${open}0${close}`; const result = parseJsonc(raw); expect(result.ast.root).toBeNull(); - expect( - result.diagnostics.some((d) => d.code === 'OC_JSONC_DEPTH_EXCEEDED'), - ).toBe(true); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "OC_JSONC_DEPTH_EXCEEDED", + ); }); - it('parser depth cap fires on JSONL line with deeply-nested JSON (F6)', () => { + it("parser depth cap fires on JSONL line with deeply-nested JSON (F6)", () => { // Per-line parseJsonc dispatch carries the same protection — each // value line is parsed in isolation and gets its own depth cap. // The line surfaces as `kind: 'malformed'` with the depth diagnostic. @@ -427,137 +433,139 @@ describe('wave-23 pitfalls — performance & limits', () => { for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { nested = `{"a":${nested}}`; } - const { diagnostics } = parseJsonl(nested + '\n'); + const { diagnostics } = parseJsonl(nested + "\n"); // The line-level diagnostic is OC_JSONL_LINE_MALFORMED (line failed); // we don't promote OC_JSONC_DEPTH_EXCEEDED through the JSONL layer // but the malformed-line detection prevents stack-overflow escape. - expect(diagnostics.some((d) => d.code === 'OC_JSONL_LINE_MALFORMED')).toBe(true); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_JSONL_LINE_MALFORMED"); }); }); // ---------- Coercion pitfalls -------------------------------------------- -describe('wave-23 pitfalls — coercion', () => { - it('P-029 numeric coercion is locale-independent', () => { +describe("wave-23 pitfalls — coercion", () => { + it("P-029 numeric coercion is locale-independent", () => { // `Number()` doesn't honor locale; `parseFloat` doesn't either in // practice, but we never use `parseFloat`. Verify `Number("1,5")` // returns NaN (which is rejected) and `"1.5"` returns 1.5. const ast = parseJsonc('{"x":1.0}').ast; - const r1 = setOcPath(ast, parseOcPath('oc://X/x'), '1.5'); + const r1 = setOcPath(ast, parseOcPath("oc://X/x"), "1.5"); expect(r1.ok).toBe(true); - const r2 = setOcPath(ast, parseOcPath('oc://X/x'), '1,5'); + const r2 = setOcPath(ast, parseOcPath("oc://X/x"), "1,5"); expect(r2.ok).toBe(false); - if (!r2.ok) {expect(r2.reason).toBe('parse-error');} + if (!r2.ok) { + expect(r2.reason).toBe("parse-error"); + } }); - it('P-030 boolean coercion is exact-match lowercase', () => { + it("P-030 boolean coercion is exact-match lowercase", () => { const ast = parseJsonc('{"x":true}').ast; - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'false').ok).toBe(true); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'False').ok).toBe(false); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'TRUE').ok).toBe(false); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'yes').ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "false").ok).toBe(true); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "False").ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "TRUE").ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "yes").ok).toBe(false); }); }); // ---------- Reserved character pitfalls ---------------------------------- -describe('wave-23 pitfalls — reserved characters', () => { - it('P-026 rejects `?` outside the query separator position', () => { +describe("wave-23 pitfalls — reserved characters", () => { + it("P-026 rejects `?` outside the query separator position", () => { // `?` triggers the query split. `oc://X/foo?session=s` is fine // (legitimate query). But `?` *inside* a segment after the query // section is consumed isn't a normal use case — the parser treats // the first `?` as the query split. - expect(parseOcPath('oc://X/foo?session=s').section).toBe('foo'); + expect(parseOcPath("oc://X/foo?session=s").section).toBe("foo"); // Empty key after `?` (no `=`): query parser silently ignores. - expect(() => parseOcPath('oc://X/foo?')).not.toThrow(); + expect(() => parseOcPath("oc://X/foo?")).not.toThrow(); }); - it('P-040 negative-index magnitude is bounded', () => { + it("P-040 negative-index magnitude is bounded", () => { // Out-of-range negative index → null at resolve time, not crash. const ast = parseJsonc('{"x":[1,2,3]}').ast; - expect(resolveOcPath(ast, parseOcPath('oc://X/x/-9999999999'))).toBeNull(); - expect(resolveOcPath(ast, parseOcPath('oc://X/x/-1'))?.kind).toBe('leaf'); + expect(resolveOcPath(ast, parseOcPath("oc://X/x/-9999999999"))).toBeNull(); + expect(resolveOcPath(ast, parseOcPath("oc://X/x/-1"))?.kind).toBe("leaf"); }); }); // ---------- Sentinel-redaction pitfall (P-036) --------------------------- -describe('wave-23 pitfalls — redaction sentinel', () => { +describe("wave-23 pitfalls — redaction sentinel", () => { // P-036 is fully covered by wave-21-sentinel-cross-kind. This is a // smoke test asserting the link is intact. - it('P-036 sentinel guard activates at emit time (covered by wave-21)', () => { + it("P-036 sentinel guard activates at emit time (covered by wave-21)", () => { expect(true).toBe(true); }); }); // ---------- DEFERRED — documented limits --------------------------------- -describe('wave-23 pitfalls — deferred (v0 limits)', () => { - it.skip('P-005 slash literal in key — v1: quoted segments', () => {}); - it.skip('P-006 dot literal in key — v1: quoted segments', () => {}); - it.skip('P-017 nested unions {a,{b,c}} — v1: parser stack', () => {}); - it.skip('P-019 wildcard inside wildcard — v1: pattern composition', () => {}); - it.skip('P-025 leading-zero numeric `01` — v1: explicit form', () => {}); - it.skip('P-027 `&` in segments — v1: percent-encoding', () => {}); - it.skip('P-028 percent-encoded segments — v1: rfc3986 layer', () => {}); - it.skip('P-034 ast mutation between resolve & consume — caller invariant', () => {}); - it.skip('P-035 stale paths from prior find — caller invariant', () => {}); +describe("wave-23 pitfalls — deferred (v0 limits)", () => { + it.skip("P-005 slash literal in key — v1: quoted segments", () => {}); + it.skip("P-006 dot literal in key — v1: quoted segments", () => {}); + it.skip("P-017 nested unions {a,{b,c}} — v1: parser stack", () => {}); + it.skip("P-019 wildcard inside wildcard — v1: pattern composition", () => {}); + it.skip("P-025 leading-zero numeric `01` — v1: explicit form", () => {}); + it.skip("P-027 `&` in segments — v1: percent-encoding", () => {}); + it.skip("P-028 percent-encoded segments — v1: rfc3986 layer", () => {}); + it.skip("P-034 ast mutation between resolve & consume — caller invariant", () => {}); + it.skip("P-035 stale paths from prior find — caller invariant", () => {}); }); // ---------- Injection pitfalls (C12 / W12) ------------------------------- -describe('wave-23 pitfalls — injection (caller-supplied hostile input)', () => { +describe("wave-23 pitfalls — injection (caller-supplied hostile input)", () => { // P-037: a hostile path string. The substrate's job is to either // parse safely or reject with `OcPathError` — never let undefined // behavior leak. These cases lock the rejection-or-safe contract. - it('P-037a control characters in path body are rejected', () => { - expect(() => parseOcPath('oc://a\x00b')).toThrow(OcPathError); - expect(() => parseOcPath('oc://a\x01b/c')).toThrow(OcPathError); - expect(() => parseOcPath('oc://a/b\x1Fc')).toThrow(OcPathError); + it("P-037a control characters in path body are rejected", () => { + expect(() => parseOcPath("oc://a\x00b")).toThrow(OcPathError); + expect(() => parseOcPath("oc://a\x01b/c")).toThrow(OcPathError); + expect(() => parseOcPath("oc://a/b\x1Fc")).toThrow(OcPathError); }); - it('P-037b NUL byte anywhere in path is rejected', () => { - expect(() => parseOcPath('oc://X.md/sec\x00tion')).toThrow(OcPathError); + it("P-037b NUL byte anywhere in path is rejected", () => { + expect(() => parseOcPath("oc://X.md/sec\x00tion")).toThrow(OcPathError); }); - it('P-037c BOM at start of path is stripped, not interpreted', () => { + it("P-037c BOM at start of path is stripped, not interpreted", () => { // BOM is unicode U+FEFF (0xFEFF). The substrate strips it before // scheme check; without stripping, the BOM-prefixed string would // fail the `oc://` scheme test. - const path = parseOcPath('oc://X.md/section'); - expect(path.file).toBe('X.md'); - expect(path.section).toBe('section'); + const path = parseOcPath("oc://X.md/section"); + expect(path.file).toBe("X.md"); + expect(path.section).toBe("section"); }); - it('P-037d session query is parsed only via the documented `?session=...` form', () => { + it("P-037d session query is parsed only via the documented `?session=...` form", () => { // Legal session form parses cleanly. - const ok = parseOcPath('oc://X.md/sec?session=cron:daily'); - expect(ok.section).toBe('sec'); - expect(ok.session).toBe('cron:daily'); + const ok = parseOcPath("oc://X.md/sec?session=cron:daily"); + expect(ok.section).toBe("sec"); + expect(ok.session).toBe("cron:daily"); // Substrate is lenient about loose `?garbage` — caller's // responsibility to construct paths from `formatOcPath`. Confirm // the loose form does NOT silently invent a session value. - const loose = parseOcPath('oc://X.md/sec?garbage'); + const loose = parseOcPath("oc://X.md/sec?garbage"); expect(loose.session).toBeUndefined(); }); - it('P-037e unescaped `&` in segments is rejected', () => { - expect(() => parseOcPath('oc://X.md/a&b')).toThrow(OcPathError); + it("P-037e unescaped `&` in segments is rejected", () => { + expect(() => parseOcPath("oc://X.md/a&b")).toThrow(OcPathError); }); - it('P-037f unescaped `%` in segments is rejected', () => { - expect(() => parseOcPath('oc://X.md/a%b')).toThrow(OcPathError); + it("P-037f unescaped `%` in segments is rejected", () => { + expect(() => parseOcPath("oc://X.md/a%b")).toThrow(OcPathError); }); - it('P-037g empty file slot is rejected', () => { - expect(() => parseOcPath('oc:///section')).toThrow(OcPathError); + it("P-037g empty file slot is rejected", () => { + expect(() => parseOcPath("oc:///section")).toThrow(OcPathError); }); - it('P-037h backslash-escape attempts are not treated as path traversal', () => { + it("P-037h backslash-escape attempts are not treated as path traversal", () => { // No special meaning — the literal backslash is just a regular // character. Doesn't allow escaping forward slashes. - expect(() => parseOcPath('oc://X.md/a\\../b')).toThrow(OcPathError); + expect(() => parseOcPath("oc://X.md/a\\../b")).toThrow(OcPathError); }); // P-038: predicate-value injection. `[k=v]` predicates filter @@ -565,60 +573,60 @@ describe('wave-23 pitfalls — injection (caller-supplied hostile input)', () => // operators must NOT escape the predicate scope or be interpreted // as a regex. - it('P-038a regex metacharacters in predicate value match literally', () => { + it("P-038a regex metacharacters in predicate value match literally", () => { const ast = parseJsonc('{ "items": [ {"name": "a.*"}, {"name": "abc"} ] }').ast; // Looking for the literal string "a.*" — should match only the // first item, not "abc" (which would match if `.*` were treated // as a regex). - const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[name=a.*]')); + const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[name=a.*]")); expect(matches).toHaveLength(1); }); - it('P-038b nested-bracket attempts in predicate value are kept literal', () => { + it("P-038b nested-bracket attempts in predicate value are kept literal", () => { // The substrate is permissive on nested brackets — they're part // of the literal predicate value, not interpreted as path syntax. // The match would be against the literal string "a[b]"; a // resolver that finds zero matches fails closed. - const path = parseOcPath('oc://X.jsonc/items/[name=a[b]]'); - expect(path.item).toBe('[name=a[b]]'); + const path = parseOcPath("oc://X.jsonc/items/[name=a[b]]"); + expect(path.item).toBe("[name=a[b]]"); // No data has the literal value `a[b]` here, so finding empty. const ast = parseJsonc('{ "items": [ {"name": "abc"} ] }').ast; expect(findOcPaths(ast, path)).toHaveLength(0); }); - it('P-038c equals-sign in predicate value is treated as part of the value', () => { + it("P-038c equals-sign in predicate value is treated as part of the value", () => { // The FIRST `=` separates key from value; subsequent `=`s belong // to the value. The rule keeps the predicate parser simple — // operators that prefix-match (`!=`, `<=`, `>=`) are tried // before `=`, then `=` consumes the rest. const ast = parseJsonc('{ "items": [ {"k": "a=b"}, {"k": "c"} ] }').ast; - const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[k=a=b]')); + const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[k=a=b]")); expect(matches).toHaveLength(1); }); - it('P-038d control characters in predicate value are rejected', () => { - expect(() => parseOcPath('oc://X.jsonc/items/[k=a\x00b]')).toThrow(OcPathError); + it("P-038d control characters in predicate value are rejected", () => { + expect(() => parseOcPath("oc://X.jsonc/items/[k=a\x00b]")).toThrow(OcPathError); }); - it('P-038e empty predicate body is rejected', () => { - expect(() => parseOcPath('oc://X.jsonc/items/[]')).toThrow(OcPathError); + it("P-038e empty predicate body is rejected", () => { + expect(() => parseOcPath("oc://X.jsonc/items/[]")).toThrow(OcPathError); }); - it('P-038f predicate-shaped bracket without operator is treated as literal sentinel', () => { + it("P-038f predicate-shaped bracket without operator is treated as literal sentinel", () => { // `[name]` without `=` is parsed as a literal-bracket sentinel // (e.g. `[frontmatter]`-style). The substrate accepts it as a // literal path segment — predicate parsing only kicks in when an // operator is present. Document this to lock the behavior. - const path = parseOcPath('oc://X.jsonc/items/[name]'); - expect(path.item).toBe('[name]'); + const path = parseOcPath("oc://X.jsonc/items/[name]"); + expect(path.item).toBe("[name]"); }); - it('P-038g predicate-shaped bracket with unsupported operator parses as literal', () => { + it("P-038g predicate-shaped bracket with unsupported operator parses as literal", () => { // `~` isn't in the supported-operator set; the parser doesn't // recognize it as a predicate, so it's accepted as a literal // bracket segment. This is the documented v1.1 behavior — a // future version may add `~` (regex) and bump SDK_VERSION. - const path = parseOcPath('oc://X.jsonc/items/[k~v]'); - expect(path.item).toBe('[k~v]'); + const path = parseOcPath("oc://X.jsonc/items/[k~v]"); + expect(path.item).toBe("[k~v]"); }); }); diff --git a/src/oc-path/tests/scenarios/real-world-fixtures.test.ts b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts index f633d08fa66..b4ab6c8e214 100644 --- a/src/oc-path/tests/scenarios/real-world-fixtures.test.ts +++ b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts @@ -5,24 +5,24 @@ * filename) — each parsed, resolved, and round-tripped to verify the * substrate handles realistic content. */ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { join, dirname } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const HERE = dirname(fileURLToPath(import.meta.url)); -const FIXTURES = join(HERE, '..', 'fixtures', 'real'); +const FIXTURES = join(HERE, "..", "fixtures", "real"); function load(name: string): string { - return readFileSync(join(FIXTURES, name), 'utf-8'); + return readFileSync(join(FIXTURES, name), "utf-8"); } -describe('wave-12 real-world-fixtures', () => { - it('F-01 SOUL.md parses + round-trips', () => { - const raw = load('SOUL.md'); +describe("wave-12 real-world-fixtures", () => { + it("F-01 SOUL.md parses + round-trips", () => { + const raw = load("SOUL.md"); const { ast, diagnostics } = parseMd(raw); expect(diagnostics).toEqual([]); expect(emitMd(ast)).toBe(raw); @@ -30,107 +30,109 @@ describe('wave-12 real-world-fixtures', () => { expect(ast.blocks.length).toBeGreaterThan(0); }); - it('F-02 AGENTS.md parses + resolves Tools section', () => { - const raw = load('AGENTS.md'); + it("F-02 AGENTS.md parses + resolves Tools section", () => { + const raw = load("AGENTS.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - const tools = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); - expect(tools?.kind).toBe('block'); - if (tools?.kind === 'block') { - expect(tools.node.items.some((i) => i.kv?.key === 'gh')).toBe(true); + const tools = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" }); + expect(tools?.kind).toBe("block"); + if (tools?.kind === "block") { + expect(tools.node.items.map((item) => item.kv?.key)).toContain("gh"); } }); - it('F-03 MEMORY.md frontmatter scope resolves via [frontmatter]', () => { - const raw = load('MEMORY.md'); + it("F-03 MEMORY.md frontmatter scope resolves via [frontmatter]", () => { + const raw = load("MEMORY.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const scope = resolveOcPath(ast, { - file: 'MEMORY.md', - section: '[frontmatter]', - field: 'scope', + file: "MEMORY.md", + section: "[frontmatter]", + field: "scope", }); - expect(scope?.kind).toBe('frontmatter'); - if (scope?.kind === 'frontmatter') {expect(scope.node.value).toBe('project');} - }); - - it('F-04 TOOLS.md table extracted from Tool Guidance section', () => { - const raw = load('TOOLS.md'); - const { ast } = parseMd(raw); - expect(emitMd(ast)).toBe(raw); - const guidance = resolveOcPath(ast, { - file: 'TOOLS.md', - section: 'tool-guidance', - }); - expect(guidance?.kind).toBe('block'); - if (guidance?.kind === 'block') { - expect(guidance.node.tables.length).toBeGreaterThan(0); - expect(guidance.node.tables[0]?.headers).toEqual(['tool', 'guidance']); + expect(scope?.kind).toBe("frontmatter"); + if (scope?.kind === "frontmatter") { + expect(scope.node.value).toBe("project"); } }); - it('F-05 IDENTITY.md sections resolvable by slug', () => { - const raw = load('IDENTITY.md'); + it("F-04 TOOLS.md table extracted from Tool Guidance section", () => { + const raw = load("TOOLS.md"); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const guidance = resolveOcPath(ast, { + file: "TOOLS.md", + section: "tool-guidance", + }); + expect(guidance?.kind).toBe("block"); + if (guidance?.kind === "block") { + expect(guidance.node.tables.length).toBeGreaterThan(0); + expect(guidance.node.tables[0]?.headers).toEqual(["tool", "guidance"]); + } + }); + + it("F-05 IDENTITY.md sections resolvable by slug", () => { + const raw = load("IDENTITY.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const trust = resolveOcPath(ast, { - file: 'IDENTITY.md', - section: 'trust-level', + file: "IDENTITY.md", + section: "trust-level", }); - expect(trust?.kind).toBe('block'); + expect(trust?.kind).toBe("block"); }); - it('F-06 USER.md Preferences items extracted', () => { - const raw = load('USER.md'); + it("F-06 USER.md Preferences items extracted", () => { + const raw = load("USER.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const prefs = resolveOcPath(ast, { - file: 'USER.md', - section: 'preferences', + file: "USER.md", + section: "preferences", }); - expect(prefs?.kind).toBe('block'); - if (prefs?.kind === 'block') { + expect(prefs?.kind).toBe("block"); + if (prefs?.kind === "block") { expect(prefs.node.items.length).toBeGreaterThan(0); } }); - it('F-07 HEARTBEAT.md schedules — H2 sections as triggers', () => { - const raw = load('HEARTBEAT.md'); + it("F-07 HEARTBEAT.md schedules — H2 sections as triggers", () => { + const raw = load("HEARTBEAT.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); expect(ast.blocks.length).toBeGreaterThanOrEqual(3); const slugs = ast.blocks.map((b) => b.slug); - expect(slugs).toContain('every-30m-wake'); - expect(slugs).toContain('every-4h-wake'); + expect(slugs).toContain("every-30m-wake"); + expect(slugs).toContain("every-4h-wake"); }); - it('F-08 SKILL.md frontmatter has name + description + tier', () => { - const raw = load('SKILL.md'); + it("F-08 SKILL.md frontmatter has name + description + tier", () => { + const raw = load("SKILL.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const fmKeys = ast.frontmatter.map((e) => e.key); - expect(fmKeys).toContain('name'); - expect(fmKeys).toContain('description'); - expect(fmKeys).toContain('tier'); + expect(fmKeys).toContain("name"); + expect(fmKeys).toContain("description"); + expect(fmKeys).toContain("tier"); }); - it('F-09 BOOTSTRAP.md round-trips', () => { - const raw = load('BOOTSTRAP.md'); + it("F-09 BOOTSTRAP.md round-trips", () => { + const raw = load("BOOTSTRAP.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); }); - it('F-10 all 8 fixtures combined round-trip-clean (sanity)', () => { + it("F-10 all 8 fixtures combined round-trip-clean (sanity)", () => { const names = [ - 'SOUL.md', - 'AGENTS.md', - 'MEMORY.md', - 'TOOLS.md', - 'IDENTITY.md', - 'USER.md', - 'HEARTBEAT.md', - 'SKILL.md', - 'BOOTSTRAP.md', + "SOUL.md", + "AGENTS.md", + "MEMORY.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "SKILL.md", + "BOOTSTRAP.md", ]; for (const name of names) { const raw = load(name); From ee935bb13b916e0dcea11cfa63cdaced3cd33037 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:12:12 +0100 Subject: [PATCH 153/806] test: clarify telegram sticker cache assertions --- extensions/telegram/src/sticker-cache.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/extensions/telegram/src/sticker-cache.test.ts b/extensions/telegram/src/sticker-cache.test.ts index 755970245dc..b73c101a7c8 100644 --- a/extensions/telegram/src/sticker-cache.test.ts +++ b/extensions/telegram/src/sticker-cache.test.ts @@ -146,19 +146,28 @@ describe("sticker-cache", () => { it("finds stickers by description substring", () => { const results = stickerCache.searchStickers("fox"); expect(results).toHaveLength(2); - expect(results.every((s) => s.description.toLowerCase().includes("fox"))).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("finds stickers by emoji", () => { const results = stickerCache.searchStickers("🦊"); expect(results).toHaveLength(2); - expect(results.every((s) => s.emoji === "🦊")).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("finds stickers by set name", () => { const results = stickerCache.searchStickers("CuteFoxes"); expect(results).toHaveLength(2); - expect(results.every((s) => s.setName === "CuteFoxes")).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("respects limit parameter", () => { From 487c615c657378fd3766360071d33ebcaad14fd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:13:24 +0100 Subject: [PATCH 154/806] test: clarify ollama setup assertions --- extensions/ollama/src/setup.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index afc6e5a5535..30f066d49e7 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -428,12 +428,9 @@ describe("ollama setup", () => { "qwen3-coder:480b-cloud", "gpt-oss:120b-cloud", ]); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/show"))).toBe( - false, - ); - expect( - fetchMock.mock.calls.some((call) => requestUrl(call[0]) === "https://ollama.com/api/tags"), - ).toBe(true); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls.filter((url) => url.endsWith("/api/show"))).toEqual([]); + expect(requestUrls).toContain("https://ollama.com/api/tags"); }); it("uses /api/show context windows when building Ollama model configs", async () => { @@ -699,9 +696,8 @@ describe("ollama setup", () => { }); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/pull"))).toBe( - false, - ); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls.filter((url) => url.endsWith("/api/pull"))).toEqual([]); expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([ "gemma4:latest", ]); From fd2914f534833fd3fe257c2c18290d00b6054bbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:14:38 +0100 Subject: [PATCH 155/806] test: clarify plugin discovery assertions --- src/plugins/discovery.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index b25f5548ff8..addcab16112 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -1657,7 +1657,7 @@ describe("discoverOpenClawPlugins", () => { const { candidates } = await discoverWithStateDir(stateDir, {}); - expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false); + expect(candidates.map((candidate) => candidate.idHint)).not.toContain("pack"); }); it.runIf(process.platform !== "win32")("blocks world-writable plugin paths", async () => { @@ -1693,7 +1693,7 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true); + expect(result.candidates.map((candidate) => candidate.idHint)).toContain("demo-pack"); expect(result.diagnostics).not.toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1804,12 +1804,12 @@ describe("discoverOpenClawPlugins", () => { const env = buildDiscoveryEnvWithOverrides(stateDir); const first = discoverWithEnv({ env }); - expect(first.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(true); + expect(first.candidates.map((candidate) => candidate.idHint)).toContain("fresh"); fs.rmSync(pluginPath, { force: true }); const second = discoverWithEnv({ env }); - expect(second.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(false); + expect(second.candidates.map((candidate) => candidate.idHint)).not.toContain("fresh"); }); it("discovers bundled and global plugins for each workspace-specific scan", () => { From fd3678a4893241baca4ddb0a25ba117399621ab2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:16:27 +0100 Subject: [PATCH 156/806] test: clarify plugin registry assertions --- src/plugins/manifest-registry.test.ts | 60 +++++++++++---------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 0949a619f0e..b0f7829fbaf 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -455,21 +455,17 @@ describe("loadPluginManifestRegistry", () => { config: { plugins: { entries: { "external-chat": { enabled: false } } } }, candidates: [candidate], }); - expect( - disabledRegistry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(disabledRegistry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); const allowlistRegistry = loadPluginManifestRegistry({ config: { plugins: { allow: ["other-plugin"] } }, candidates: [candidate], }); - expect( - allowlistRegistry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(allowlistRegistry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("suppresses duplicate warnings for explicit installed globals overriding bundled plugins", () => { @@ -1216,11 +1212,9 @@ describe("loadPluginManifestRegistry", () => { type: "object", additionalProperties: false, }); - expect( - registry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("hydrates supplemental official external catalog contracts for lagging npm manifests", () => { @@ -1249,11 +1243,9 @@ describe("loadPluginManifestRegistry", () => { }), }), ); - expect( - registry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("fills missing official external catalog descriptors for partial npm channel configs", () => { @@ -1888,7 +1880,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins).toEqual([]); expectRegistryDiagnosticContains(registry, expectedMessage); if (expectWarn) { - expect(registry.diagnostics.some((diag) => diag.level === "warn")).toBe(true); + expect(registry.diagnostics.map((diag) => diag.level)).toContain("warn"); } }); @@ -1920,11 +1912,9 @@ describe("loadPluginManifestRegistry", () => { }); expect(registry.plugins.map((plugin) => plugin.id)).toEqual(["codex"]); - expect( - registry.diagnostics.some((diag) => - diag.message.includes("openclaw.install.minHostVersion must use"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("openclaw.install.minHostVersion must use")]), + ); }); it("does not runtime-gate bundled source plugins by install minHostVersion", () => { @@ -1949,7 +1939,7 @@ describe("loadPluginManifestRegistry", () => { env: { OPENCLAW_VERSION: "2026.4.30" } as NodeJS.ProcessEnv, }); - expect(registry.plugins.some((plugin) => plugin.id === "codex")).toBe(true); + expect(registry.plugins.map((plugin) => plugin.id)).toContain("codex"); expect(registry.diagnostics.map((diag) => diag.message)).not.toEqual( expect.arrayContaining([expect.stringContaining("requires OpenClaw")]), ); @@ -2251,7 +2241,7 @@ describe("loadPluginManifestRegistry", () => { rootDir: fixture.rootDir, origin: "bundled", }); - expect(registry.plugins.some((entry) => entry.id === "bundled-hardlink")).toBe(true); + expect(registry.plugins.map((entry) => entry.id)).toContain("bundled-hardlink"); expect(hasUnsafeManifestDiagnostic(registry)).toBe(false); }); @@ -2338,12 +2328,12 @@ describe("loadPluginManifestRegistry", () => { }); expect(olderHost.plugins).toEqual([]); - expect( - olderHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.21")), - ).toBe(true); - expect(newerHost.plugins.some((plugin) => plugin.id === "synology-chat")).toBe(true); - expect( - newerHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.21")), - ).toBe(false); + expect(olderHost.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining("this host is 2026.3.21")]), + ); + expect(newerHost.plugins.map((plugin) => plugin.id)).toContain("synology-chat"); + expect(newerHost.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("this host is 2026.3.21")]), + ); }); }); From 52474c2d300d6f0addbd111f552353afe3c2710c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:18:13 +0100 Subject: [PATCH 157/806] test: clarify live assertion lists --- extensions/microsoft/microsoft.live.test.ts | 2 +- extensions/ollama/ollama.live.test.ts | 2 +- src/gateway/gateway-codex-harness.live.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/microsoft/microsoft.live.test.ts b/extensions/microsoft/microsoft.live.test.ts index 48c6c5e02f6..8bef656d11b 100644 --- a/extensions/microsoft/microsoft.live.test.ts +++ b/extensions/microsoft/microsoft.live.test.ts @@ -9,6 +9,6 @@ describeLive("microsoft plugin live", () => { const voices = await listMicrosoftVoices(); expect(voices.length).toBeGreaterThan(100); - expect(voices.some((voice) => voice.id === "en-US-MichelleNeural")).toBe(true); + expect(voices.map((voice) => voice.id)).toContain("en-US-MichelleNeural"); }, 60_000); }); diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts index 9c2e9eb1179..3a769c95d40 100644 --- a/extensions/ollama/ollama.live.test.ts +++ b/extensions/ollama/ollama.live.test.ts @@ -213,7 +213,7 @@ describe.skipIf(!LIVE)("ollama live", () => { const error = events.find((event) => (event as { type?: string }).type === "error"); expect(error).toBeUndefined(); - expect(events.some((event) => (event as { type?: string }).type === "done")).toBe(true); + expect(events.map((event) => (event as { type?: string }).type)).toContain("done"); expect(payload?.model).toBe(CHAT_MODEL); expect(payload?.options?.num_ctx).toBe(4096); expect(payload?.options?.top_p).toBe(0.9); diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index fa5a78ffa47..79436798fe2 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -364,7 +364,7 @@ async function verifyCodexImageProbe(params: { } const { extractPayloadText } = await import("./test-helpers.agent-results.js"); expect(extractPayloadText(payload.result)).toContain(expectedToken); - expect(events.some((event) => event.stream === "codex_app_server.lifecycle")).toBe(true); + expect(events.map((event) => event.stream)).toContain("codex_app_server.lifecycle"); } function findGuardianReviewStatus(events: CapturedAgentEvent[]): "approved" | "denied" | undefined { From 15b39313cc128be0de33bbfeead34e092bd01050 Mon Sep 17 00:00:00 2001 From: Chencheng Li <49442600+chencheng-li@users.noreply.github.com> Date: Fri, 8 May 2026 04:19:28 -0700 Subject: [PATCH 158/806] fix: separate Current time from Reference UTC (#42654) Merged via squash. Prepared head SHA: 0829399ebd1837c84a46fb84db80dd7f4b8bba30 Co-authored-by: chencheng-li <49442600+chencheng-li@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + extensions/memory-core/index.test.ts | 3 ++- extensions/memory-core/src/dreaming.test.ts | 3 ++- src/agents/current-time.ts | 2 +- src/auto-reply/reply/post-compaction-context.test.ts | 5 ++--- src/auto-reply/reply/session-reset-prompt.test.ts | 5 ++--- src/cron/isolated-agent.session-identity.test.ts | 3 ++- test/helpers/agents/prompt-composition-scenarios.ts | 6 ++++-- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33649519ae2..ea6be581312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -642,6 +642,7 @@ Docs: https://docs.openclaw.ai - Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong. - Browser/downloads: route explicit and managed browser download output directories through `fs-safe` validation before staging final files, so symlinked output roots are rejected before writes. (#78780) Thanks @jesse-merhi. - Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. +- Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. ## 2026.5.3-1 diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index 4bfc4955f88..90363bd7f14 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -84,8 +84,9 @@ describe("buildMemoryFlushPlan", () => { expect(plan?.prompt).toContain("memory/2026-02-16.md"); expect(plan?.prompt).toContain( - "Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York) / 2026-02-16 15:00 UTC", + "Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York)", ); + expect(plan?.prompt).toContain("Reference UTC: 2026-02-16 15:00 UTC"); expect(plan?.relativePath).toBe("memory/2026-02-16.md"); }); diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index b53bd7a493e..d28e0b498be 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -1943,7 +1943,8 @@ describe("short-term dreaming trigger", () => { const result = await runShortTermDreamingPromotionIfTriggered({ cleanedBody: [ "[cron:e795558c-a273-4124-ba88-d4916688d977 Memory Dreaming Promotion] __openclaw_memory_core_short_term_promotion_dream__", - "Current time: Thursday, April 16th, 2026 - 3:10 PM (America/Los_Angeles) / 2026-04-16 22:10 UTC", + "Current time: Thursday, April 16th, 2026 - 3:10 PM (America/Los_Angeles)", + "Reference UTC: 2026-04-16 22:10 UTC", ].join("\n"), trigger: "cron", workspaceDir, diff --git a/src/agents/current-time.ts b/src/agents/current-time.ts index b98b8594669..f0de821e226 100644 --- a/src/agents/current-time.ts +++ b/src/agents/current-time.ts @@ -26,7 +26,7 @@ export function resolveCronStyleNow(cfg: TimeConfigLike, nowMs: number): CronSty const formattedTime = formatUserTime(new Date(nowMs), userTimezone, userTimeFormat) ?? new Date(nowMs).toISOString(); const utcTime = new Date(nowMs).toISOString().replace("T", " ").slice(0, 16) + " UTC"; - const timeLine = `Current time: ${formattedTime} (${userTimezone}) / ${utcTime}`; + const timeLine = `Current time: ${formattedTime} (${userTimezone})\nReference UTC: ${utcTime}`; return { userTimezone, formattedTime, timeLine }; } diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 981304c4a90..ba2e86b2942 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -262,9 +262,8 @@ Never modify memory/YYYY-MM-DD.md destructively. expect(result).not.toBeNull(); expect(result).toContain("memory/2026-03-03.md"); expect(result).not.toContain("memory/YYYY-MM-DD.md"); - expect(result).toContain( - "Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York) / 2026-03-03 14:00 UTC", - ); + expect(result).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); + expect(result).toContain("Reference UTC: 2026-03-03 14:00 UTC"); }); it("appends current time line even when no YYYY-MM-DD placeholder is present", async () => { diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index d4f18acf6f4..1e8aa20280d 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -50,9 +50,8 @@ describe("buildBareSessionResetPrompt", () => { // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const prompt = buildBareSessionResetPrompt(cfg, nowMs); - expect(prompt).toContain( - "Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York) / 2026-03-03 14:00 UTC", - ); + expect(prompt).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); + expect(prompt).toContain("Reference UTC: 2026-03-03 14:00 UTC"); }); it("does not append a duplicate current time line", () => { diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index 9bd543d7d60..dd3b7e00650 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -55,7 +55,8 @@ describe("runCronIsolatedAgentTurn session identity", () => { const lines = call?.prompt?.split("\n") ?? []; expect(lines[0]).toContain("[cron:job-1"); expect(lines[0]).toContain("do it"); - expect(lines[1]).toMatch(/^Current time: .+ \(.+\) \/ \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/); + expect(lines[1]).toMatch(/^Current time: .+ \(.+\)$/); + expect(lines[2]).toMatch(/^Reference UTC: \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/); }); }); diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index f7d5d403f25..4043dd51d41 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -594,7 +594,8 @@ async function createMaintenanceScenario(workspaceDir: string): Promise Date: Fri, 8 May 2026 12:20:12 +0100 Subject: [PATCH 159/806] test: replace truthy test assertions --- extensions/telegram/src/bot.test.ts | 2 +- src/gateway/server-methods/server-methods.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 6f7e74d6315..13a6949c5a9 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1766,7 +1766,7 @@ describe("createTelegramBot", () => { mediaRef: "telegram:file/root-photo-1", }), ]); - expect(payload.ReplyChain?.[1]?.mediaPath).toBeTruthy(); + expect(payload.ReplyChain?.[1]?.mediaPath).toEqual(expect.any(String)); expect(getFileSpy).toHaveBeenCalledWith("root-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); }); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index c72cc9f6988..1991c99bfca 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -1483,7 +1483,7 @@ describe("exec approval handlers", () => { }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - expect(requested).toBeTruthy(); + expect(requested).toEqual(expect.objectContaining({ event: "exec.approval.requested" })); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["commandAnalysis"]).toEqual( expect.objectContaining({ commandCount: 1, nestedCommandCount: 0 }), From 79c1f1be48914fa4950155b809e95eb8da296235 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:22:55 +0100 Subject: [PATCH 160/806] test: remove weak no-throw wrappers --- .../discord/src/monitor/gateway-supervisor.test.ts | 12 +++++------- extensions/voice-call/src/manager/events.test.ts | 2 +- src/media/ffmpeg-exec.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/extensions/discord/src/monitor/gateway-supervisor.test.ts b/extensions/discord/src/monitor/gateway-supervisor.test.ts index 7dd18c9de23..b906b7a07c8 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.test.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.test.ts @@ -98,10 +98,10 @@ describe("createDiscordGatewaySupervisor", () => { }); expect(supervisor.drainPending(() => "continue")).toBe("continue"); - expect(() => supervisor.attachLifecycle(() => {})).not.toThrow(); - expect(() => supervisor.detachLifecycle()).not.toThrow(); - expect(() => supervisor.dispose()).not.toThrow(); - expect(() => supervisor.dispose()).not.toThrow(); + supervisor.attachLifecycle(() => {}); + supervisor.detachLifecycle(); + supervisor.dispose(); + supervisor.dispose(); }); it("keeps suppressing late gateway errors after dispose", () => { @@ -115,9 +115,7 @@ describe("createDiscordGatewaySupervisor", () => { supervisor.dispose(); - expect(() => - emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005")), - ).not.toThrow(); + emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005")); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"), ); diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 2428e1b8b41..bc3a0f94588 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -348,7 +348,7 @@ describe("processEvent (functional)", () => { from: "+15553333333", }); - expect(() => processEvent(ctx, event)).not.toThrow(); + processEvent(ctx, event); expect(ctx.activeCalls.size).toBe(0); }); diff --git a/src/media/ffmpeg-exec.test.ts b/src/media/ffmpeg-exec.test.ts index 4549da119e2..829f4e2c37b 100644 --- a/src/media/ffmpeg-exec.test.ts +++ b/src/media/ffmpeg-exec.test.ts @@ -111,7 +111,7 @@ describe("runFfprobe", () => { const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - expect(() => child.stdin?.emit("error", stdinError)).not.toThrow(); + child.stdin?.emit("error", stdinError); execCallback()(null, Buffer.from("ok"), Buffer.alloc(0)); await expect(promise).resolves.toBe("ok"); @@ -124,7 +124,7 @@ describe("runFfprobe", () => { const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - expect(() => child.stdin?.emit("error", stdinError)).not.toThrow(); + child.stdin?.emit("error", stdinError); const childError = new Error("ffprobe failed"); execCallback()(childError, "", ""); From bfa0ee3b33737c2141c90fe0deb7d4cf61b6962f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:24:43 +0100 Subject: [PATCH 161/806] test: strengthen no-throw assertions --- .../discord/src/monitor/inbound-job.test.ts | 23 +++++++++++++++++-- extensions/memory-lancedb/index.test.ts | 2 +- .../src/bot.create-telegram-bot.test.ts | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts index 643ed180859..0f8e373ff8f 100644 --- a/extensions/discord/src/monitor/inbound-job.test.ts +++ b/extensions/discord/src/monitor/inbound-job.test.ts @@ -88,7 +88,20 @@ describe("buildDiscordInboundJob", () => { }, ownerId: "user-1", }); - expect(() => JSON.stringify(job.payload)).not.toThrow(); + expect(JSON.parse(JSON.stringify(job.payload))).toEqual( + expect.objectContaining({ + threadChannel: { + id: "thread-1", + name: "codex", + parentId: "forum-1", + parent: { + id: "forum-1", + name: "Forum", + }, + ownerId: "user-1", + }, + }), + ); }); it("normalizes partial thread channels without reading throwing getters", async () => { @@ -115,7 +128,13 @@ describe("buildDiscordInboundJob", () => { parent: undefined, ownerId: undefined, }); - expect(() => JSON.stringify(job.payload)).not.toThrow(); + expect(JSON.parse(JSON.stringify(job.payload))).toEqual( + expect.objectContaining({ + threadChannel: { + id: "thread-1", + }, + }), + ); }); it("re-materializes the process context with an overridden abort signal", async () => { diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 00ab318e0f5..3d56dd7f69e 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -196,7 +196,7 @@ describe("memory plugin e2e", () => { resolvePath: (filePath: string) => filePath, }; - expect(() => memoryPlugin.register(mockApi as any)).not.toThrow(); + memoryPlugin.register(mockApi as any); expect(registerService).toHaveBeenCalledWith({ id: "memory-lancedb", start: expect.any(Function), diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index f0bc091c6aa..3a15b146a08 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -202,7 +202,7 @@ describe("createTelegramBot", () => { const errorHandler = catchMock.mock.calls[0]?.[0]; expect(errorHandler).toBeTypeOf("function"); - expect(() => errorHandler?.(new Error("handler boom"))).not.toThrow(); + errorHandler?.(new Error("handler boom")); expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("telegram bot error:")); }); From 6abfb66aa55faec2acff383a2b48c358c91f972f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:26:08 +0100 Subject: [PATCH 162/806] test: clarify package spec validator assertions --- .../resolve-openclaw-package-candidate.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index 2463e205f9d..2067329675e 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -16,13 +16,17 @@ afterEach(async () => { describe("resolve-openclaw-package-candidate", () => { it("accepts only OpenClaw release package specs for npm candidates", () => { - expect(() => validateOpenClawPackageSpec("openclaw@beta")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@alpha")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@latest")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-1")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-beta.2")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-alpha.2")).not.toThrow(); + for (const spec of [ + "openclaw@beta", + "openclaw@alpha", + "openclaw@latest", + "openclaw@2026.4.27", + "openclaw@2026.4.27-1", + "openclaw@2026.4.27-beta.2", + "openclaw@2026.4.27-alpha.2", + ]) { + expect(validateOpenClawPackageSpec(spec), spec).toBeUndefined(); + } expect(() => validateOpenClawPackageSpec("@evil/openclaw@1.0.0")).toThrow( "package_spec must be openclaw@alpha", From 8caef5d0ea85f21ffc7c92e5729180ae67129e0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:27:33 +0100 Subject: [PATCH 163/806] test: clarify cron job accepted paths --- src/cron/service.jobs.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 6a6085e2c37..fb4556c7b7d 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -59,7 +59,7 @@ describe("applyJobPatch", () => { to: "123", }); - expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); + applyJobPatch(job, switchToMainPatch()); expect(job.sessionTarget).toBe("main"); expect(job.payload.kind).toBe("systemEvent"); expect(job.delivery).toBeUndefined(); @@ -71,7 +71,7 @@ describe("applyJobPatch", () => { to: "https://example.invalid/cron", }); - expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); + applyJobPatch(job, switchToMainPatch()); expect(job.sessionTarget).toBe("main"); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); }); @@ -92,7 +92,7 @@ describe("applyJobPatch", () => { }, }; - expect(() => applyJobPatch(job, patch)).not.toThrow(); + applyJobPatch(job, patch); expect(job.payload.kind).toBe("agentTurn"); if (job.payload.kind === "agentTurn") { expect(job.payload.message).toBe("do it"); @@ -391,7 +391,7 @@ describe("applyJobPatch", () => { to: " https://example.invalid/failure ", }, }; - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); expect(job.delivery?.failureDestination?.to).toBe("https://example.invalid/failure"); }); @@ -402,7 +402,7 @@ describe("applyJobPatch", () => { to: "-10012345/6789", }); - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); expect(job.delivery?.to).toBe("-10012345/6789"); }); @@ -421,7 +421,8 @@ describe("applyJobPatch", () => { ...(to ? { to } : {}), }); - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); + expect(job.enabled).toBe(true); }); }); @@ -453,7 +454,8 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { { name: "case-insensitive defaultAgentId match", defaultAgentId: "Main", agentId: "MAIN" }, ] as const)("allows creating a main-session job for $name", ({ defaultAgentId, agentId }) => { const state = createMockState(now, { defaultAgentId }); - expect(() => createJob(state, mainJobInput(agentId))).not.toThrow(); + const job = createJob(state, mainJobInput(agentId)); + expect(job.sessionTarget).toBe("main"); }); it.each([ @@ -544,7 +546,8 @@ describe("applyJobPatch rejects sessionTarget main for non-default agents", () = ); return; } - expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).not.toThrow(); + applyJobPatch(job, patch, { defaultAgentId: "main" }); + expect(job.agentId).toBe("main"); }); it("rejects patching to a custom session target with path separators", () => { @@ -941,7 +944,7 @@ describe("recomputeNextRuns", () => { store: { version: 1 as const, jobs: [job] }, } as CronServiceState; - expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow(); + recomputeNextRunsForMaintenance(state); expect(job.state.nextRunAtMs).toBe(future); expect(job.state.scheduleErrorCount).toBeUndefined(); }); From 828de037ff3be5daae7a3ee183b576f89cc36361 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:28:52 +0100 Subject: [PATCH 164/806] test: clarify acpx runtime guard assertions --- extensions/acpx/src/runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index ec5e37d9429..6374610f02f 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -143,8 +143,8 @@ describe("AcpxRuntime fresh reset wrapper", () => { }); it("exposes assertSupportedRuntimeSessionMode as a typed guard", () => { - expect(() => __testing.assertSupportedRuntimeSessionMode("persistent")).not.toThrow(); - expect(() => __testing.assertSupportedRuntimeSessionMode("oneshot")).not.toThrow(); + expect(__testing.assertSupportedRuntimeSessionMode("persistent")).toBeUndefined(); + expect(__testing.assertSupportedRuntimeSessionMode("oneshot")).toBeUndefined(); expect(() => __testing.assertSupportedRuntimeSessionMode("run" as never)).toThrow( AcpRuntimeError, ); From f0af64958ccded4d23a70fface1a9faf5de55f8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:31:03 +0100 Subject: [PATCH 165/806] test: strengthen plugin registration assertions --- extensions/codex/index.test.ts | 3 ++- extensions/matrix/index.test.ts | 3 ++- extensions/slack/src/http/plugin-routes.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 989dbbf3e41..68505805576 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -84,7 +84,8 @@ describe("codex plugin", () => { }; delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved; - expect(() => plugin.register(api)).not.toThrow(); + plugin.register(api); + expect(api.registerProvider).toHaveBeenCalledWith(expect.objectContaining({ id: "codex" })); }); it("only claims the codex provider by default", () => { diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index e77ca10ab63..7992a144ac2 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -75,7 +75,8 @@ describe("matrix plugin", () => { if (!entry.setChannelRuntime) { throw new Error("expected Matrix runtime setter"); } - expect(() => entry.setChannelRuntime?.({ marker: "runtime" } as never)).not.toThrow(); + entry.setChannelRuntime({ marker: "runtime" } as never); + expect(runtimeMocks.setMatrixRuntime).not.toHaveBeenCalled(); }); it("wires CLI metadata through the bundled entry", () => { diff --git a/extensions/slack/src/http/plugin-routes.test.ts b/extensions/slack/src/http/plugin-routes.test.ts index 54c052f0842..ce8bad42c8c 100644 --- a/extensions/slack/src/http/plugin-routes.test.ts +++ b/extensions/slack/src/http/plugin-routes.test.ts @@ -42,7 +42,7 @@ describe("registerSlackPluginHttpRoutes", () => { }; const api = createApi(cfg, registerHttpRoute); - expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow(); + registerSlackPluginHttpRoutes(api); const paths = registerHttpRoute.mock.calls .map((call) => (call[0] as { path: string }).path) From 5517b82f78460afc4aef6c115160cec9807b413e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:32:36 +0100 Subject: [PATCH 166/806] test: clarify config infra accepted paths --- src/config/io.write-config.test.ts | 2 +- src/infra/archive-helpers.test.ts | 2 +- src/infra/outbound/abort.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index fecf0ac8f77..72882814ab0 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -156,7 +156,7 @@ describe("config io write", () => { }); await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true }); - expect(() => io.loadConfig()).not.toThrow(); + expect(io.loadConfig()).toMatchObject({ gateway: { mode: "local" } }); expect(warn).toHaveBeenCalledWith( expect.stringContaining( diff --git a/src/infra/archive-helpers.test.ts b/src/infra/archive-helpers.test.ts index 440444f262a..95351df001b 100644 --- a/src/infra/archive-helpers.test.ts +++ b/src/infra/archive-helpers.test.ts @@ -142,7 +142,7 @@ describe("archive helpers", () => { }, }); - expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow(); + checker({ path: "package", type: "Directory", size: 0 }); checker({ path: "package/a.txt", type: "File", size: 6 }); expectTarPreflightError( checker, diff --git a/src/infra/outbound/abort.test.ts b/src/infra/outbound/abort.test.ts index 794615b2a28..034909f2d4e 100644 --- a/src/infra/outbound/abort.test.ts +++ b/src/infra/outbound/abort.test.ts @@ -3,8 +3,8 @@ import { throwIfAborted } from "./abort.js"; describe("throwIfAborted", () => { it("does nothing when the signal is missing or not aborted", () => { - expect(() => throwIfAborted()).not.toThrow(); - expect(() => throwIfAborted(new AbortController().signal)).not.toThrow(); + expect(throwIfAborted()).toBeUndefined(); + expect(throwIfAborted(new AbortController().signal)).toBeUndefined(); }); it("throws a standard AbortError when the signal is aborted", () => { From 604c73a489e31e98be1755b7b0cf854c09980632 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:34:05 +0100 Subject: [PATCH 167/806] test: clarify cron cli list output --- src/cli/cron-cli/shared.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index bd90dc43baa..40570c68455 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -63,8 +63,7 @@ describe("printCronList", () => { // sessionTarget is intentionally omitted to simulate the bug }); - // This should not throw "Cannot read properties of undefined (reading 'trim')" - expect(() => printCronList([jobWithUndefinedTarget], runtime)).not.toThrow(); + printCronList([jobWithUndefinedTarget], runtime); // Verify output contains the job expect(logs.length).toBeGreaterThan(1); @@ -79,7 +78,7 @@ describe("printCronList", () => { sessionTarget: "isolated", }); - expect(() => printCronList([jobWithTarget], runtime)).not.toThrow(); + printCronList([jobWithTarget], runtime); expectLogsToInclude(logs, "isolated"); }); @@ -95,7 +94,7 @@ describe("printCronList", () => { state: undefined, } as unknown as CronJob; - expect(() => printCronList([malformedJob], runtime)).not.toThrow(); + printCronList([malformedJob], runtime); expectLogsToInclude(logs, "malformed-job"); }); From 1d8659fdcbb5a99f324d49e49546ff49cd3e9d02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:35:30 +0100 Subject: [PATCH 168/806] test: clarify cron store validator assertions --- src/cron/service/store.load-missing-session-target.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cron/service/store.load-missing-session-target.test.ts b/src/cron/service/store.load-missing-session-target.test.ts index bdcaade72c9..88a22044a27 100644 --- a/src/cron/service/store.load-missing-session-target.test.ts +++ b/src/cron/service/store.load-missing-session-target.test.ts @@ -62,7 +62,7 @@ describe("cron service store load: missing sessionTarget", () => { toolsAllow: ["exec"], }); expect(job.state.nextRunAtMs).toEqual(expect.any(Number)); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it('defaults missing sessionTarget to "main" for systemEvent payloads', async () => { @@ -85,7 +85,7 @@ describe("cron service store load: missing sessionTarget", () => { const job = findJobOrThrow(state, "missing-session-target-system-event"); expect(job.sessionTarget).toBe("main"); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it('defaults missing sessionTarget to "isolated" for agentTurn payloads', async () => { @@ -108,7 +108,7 @@ describe("cron service store load: missing sessionTarget", () => { const job = findJobOrThrow(state, "missing-session-target-agent-turn"); expect(job.sessionTarget).toBe("isolated"); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it("assertSupportedJobSpec throws a clear error when sessionTarget is missing", () => { From bc0abcee7464fd94d41ebf03978f174d9fd5d6c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:36:47 +0100 Subject: [PATCH 169/806] test: clarify config env var assertions --- src/config/config.env-vars.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 052121cf35c..6b87eaacaf6 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -47,7 +47,7 @@ describe("config env vars", () => { } }`); - expect(() => applyConfigEnvVars(cfg)).not.toThrow(); + expect(applyConfigEnvVars(cfg)).toBeUndefined(); expect(process.env.API_TOKEN).toBe("sk-test-123"); expect(process.env.PORT).toBeUndefined(); expect(process.env.DEBUG).toBeUndefined(); From 933f092c9857514682e8f0aba705dcd8e148a13c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:38:05 +0100 Subject: [PATCH 170/806] test: clarify runtime guard accepted path --- src/infra/runtime-guard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index 99d4e938e81..c2c03afcd69 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -102,7 +102,7 @@ describe("runtime-guard", () => { version: "22.16.0", execPath: "/usr/bin/node", }; - expect(() => assertSupportedRuntime(runtime, details)).not.toThrow(); + expect(assertSupportedRuntime(runtime, details)).toBeUndefined(); expect(runtime.exit).not.toHaveBeenCalled(); }); From 6dec8ee440a5fb1cca0780c695cd43325e6fcf52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:39:42 +0100 Subject: [PATCH 171/806] test: clarify archive path accepted paths --- src/infra/archive-path.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/archive-path.test.ts b/src/infra/archive-path.test.ts index 97a83a30e50..36453bd7b6c 100644 --- a/src/infra/archive-path.test.ts +++ b/src/infra/archive-path.test.ts @@ -30,7 +30,7 @@ describe("archive path helpers", () => { }); it.each(["", ".", "./"])("accepts empty-like entry paths: %j", (entryPath) => { - expect(() => validateArchiveEntryPath(entryPath)).not.toThrow(); + expect(validateArchiveEntryPath(entryPath)).toBeUndefined(); }); it.each([ From 79b88224e1cbf80371fa275f7eb366a064af375d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:41:07 +0100 Subject: [PATCH 172/806] test: clarify plugin registry cleanup --- src/plugins/http-registry.test.ts | 2 +- src/plugins/interactive.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index a6e30ea3320..6a1ef6e2cd6 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -121,7 +121,7 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(0); expect(logs).toEqual(['plugin: webhook path missing for account "default"']); - expect(() => unregister()).not.toThrow(); + unregister(); }); it("replaces stale route on same path when replaceExisting=true", () => { diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 8eb2eff706a..0d180fc0022 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -486,7 +486,7 @@ describe("plugin interactive handlers", () => { }; try { - expect(() => clearPluginInteractiveHandlers()).not.toThrow(); + clearPluginInteractiveHandlers(); const hydrated = globalStore[stateKey] as { interactiveHandlers?: Map; callbackDedupe?: { clear: () => void }; @@ -496,7 +496,7 @@ describe("plugin interactive handlers", () => { if (!hydrated.callbackDedupe) { throw new Error("expected hydrated callback dedupe"); } - expect(() => hydrated.callbackDedupe?.clear()).not.toThrow(); + hydrated.callbackDedupe.clear(); expect(hydrated.inflightCallbackDedupe).toBeInstanceOf(Set); const handler = vi.fn(async () => ({ handled: true })); From 250eff0e4d9eed2c4d77dd21e78d87b9a1b74e8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:42:19 +0100 Subject: [PATCH 173/806] test: clarify gateway http helper assertions --- src/gateway/http-common.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/http-common.test.ts b/src/gateway/http-common.test.ts index ced62124a26..d55e3de072d 100644 --- a/src/gateway/http-common.test.ts +++ b/src/gateway/http-common.test.ts @@ -292,7 +292,7 @@ describe("setSseHeaders", () => { const { res, setHeader } = makeMockHttpResponse(); // Ensure flushHeaders is not defined on the mock response. expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined(); - expect(() => setSseHeaders(res)).not.toThrow(); + setSseHeaders(res); expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); }); }); @@ -312,7 +312,7 @@ describe("watchClientDisconnect", () => { const { req, res } = buildReqRes(null, null); const controller = new AbortController(); const cleanup = watchClientDisconnect(req, res, controller); - expect(() => cleanup()).not.toThrow(); + cleanup(); expect(controller.signal.aborted).toBe(false); }); From b67bc04c43143fddedd857900593d3f20ee86c68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:44:20 +0100 Subject: [PATCH 174/806] test: clarify command queue reset assertions --- src/process/command-queue.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 3115917119e..0e32478e8bb 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -100,7 +100,7 @@ describe("command queue", () => { it("resetAllLanes is safe when no lanes have been created", () => { expect(getActiveTaskCount()).toBe(0); - expect(() => resetAllLanes()).not.toThrow(); + resetAllLanes(); expect(getActiveTaskCount()).toBe(0); }); @@ -567,7 +567,7 @@ describe("command queue", () => { // resetAllLanes calls notifyActiveTaskWaiters → Array.from(state.activeTaskWaiters). // Without the migration this would throw: // TypeError: undefined is not iterable - expect(() => resetAllLanes()).not.toThrow(); + resetAllLanes(); // waitForActiveTasks also accesses activeTaskWaiters. await expect(waitForActiveTasks(0)).resolves.toEqual({ drained: true }); From 390664c5bbe2702c7ce19fbe3b4556c5fa2ecc3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:45:58 +0100 Subject: [PATCH 175/806] test: clarify transcript event listener assertions --- src/sessions/transcript-events.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sessions/transcript-events.test.ts b/src/sessions/transcript-events.test.ts index bb7a366f80e..3256793d677 100644 --- a/src/sessions/transcript-events.test.ts +++ b/src/sessions/transcript-events.test.ts @@ -45,7 +45,7 @@ describe("transcript events", () => { cleanup.push(onSessionTranscriptUpdate(first)); cleanup.push(onSessionTranscriptUpdate(second)); - expect(() => emitSessionTranscriptUpdate("/tmp/session.jsonl")).not.toThrow(); + expect(emitSessionTranscriptUpdate("/tmp/session.jsonl")).toBeUndefined(); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); }); From ea2799389ab3c608493ae3141373a5ef70eaf190 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:47:48 +0100 Subject: [PATCH 176/806] test: clarify proxy tui cli accepted paths --- src/cli/program/build-program.version-alias.test.ts | 2 +- src/proxy-capture/store.sqlite.test.ts | 2 +- src/tui/tui.test.ts | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cli/program/build-program.version-alias.test.ts b/src/cli/program/build-program.version-alias.test.ts index 98ff282beb5..957962701ee 100644 --- a/src/cli/program/build-program.version-alias.test.ts +++ b/src/cli/program/build-program.version-alias.test.ts @@ -32,7 +32,7 @@ describe("buildProgram version alias handling", () => { throw new Error(`unexpected process.exit:${String(code)}`); }) as typeof process.exit); - expect(() => buildProgram()).not.toThrow(); + buildProgram(); expect(exitSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 84e1017ecc6..ebef558a6b5 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -54,7 +54,7 @@ describe("DebugProxyCaptureStore", () => { const store = makeStore(); store.close(); - expect(() => store.close()).not.toThrow(); + store.close(); expect(store.isClosed).toBe(true); }); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 5e502083460..b8d8a329890 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -394,8 +394,6 @@ describe("resolveCodexCliBin", () => { }); it("returns null or a valid path (never throws)", () => { - // The function should never throw regardless of environment - expect(() => resolveCodexCliBin()).not.toThrow(); const result = resolveCodexCliBin(); expect(result === null || typeof result === "string").toBe(true); }); From d04002c7d98e588eabe3b6b3bd77b05ef072718d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:50:39 +0100 Subject: [PATCH 177/806] test: clarify config preset schema assertions --- ui/src/ui/views/config-presets.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/config-presets.test.ts b/ui/src/ui/views/config-presets.test.ts index b72e0f731b2..e174bb504db 100644 --- a/ui/src/ui/views/config-presets.test.ts +++ b/ui/src/ui/views/config-presets.test.ts @@ -7,7 +7,9 @@ describe("detectActivePreset", () => { for (const preset of CONFIG_PRESETS) { const defaults = preset.patch.agents.defaults; - expect(() => OpenClawSchema.parse(preset.patch), preset.id).not.toThrow(); + expect(OpenClawSchema.safeParse(preset.patch), preset.id).toMatchObject({ + success: true, + }); expect(defaults.bootstrapMaxChars, preset.id).toBeGreaterThan(0); expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThan(0); expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThanOrEqual( From cd7f0086889007beb384a02e3a5672a70254c1ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:52:36 +0100 Subject: [PATCH 178/806] test: clarify config schema accepted assertions --- src/config/zod-schema.agent-defaults.test.ts | 18 +++++----- src/config/zod-schema.cron-retention.test.ts | 6 ++-- src/config/zod-schema.logging-levels.test.ts | 6 ++-- ...ema.session-maintenance-extensions.test.ts | 18 +++++----- src/config/zod-schema.talk.test.ts | 6 ++-- src/config/zod-schema.tts.test.ts | 34 +++++++++---------- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 95d2bdceea6..04738b37815 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -5,24 +5,24 @@ import { AgentEntrySchema } from "./zod-schema.agent-runtime.js"; describe("agent defaults schema", () => { it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ subagents: { archiveAfterMinutes: 0, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts videoGenerationModel", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ videoGenerationModel: { primary: "qwen/wan2.6-t2v", fallbacks: ["minimax/video-01"], }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts imageGenerationModel timeoutMs", () => { @@ -48,11 +48,11 @@ describe("agent defaults schema", () => { }); it("accepts mediaGenerationAutoProviderFallback", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ mediaGenerationAutoProviderFallback: false, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts experimental.localModelLean", () => { diff --git a/src/config/zod-schema.cron-retention.test.ts b/src/config/zod-schema.cron-retention.test.ts index a3733872956..e15cc0f3350 100644 --- a/src/config/zod-schema.cron-retention.test.ts +++ b/src/config/zod-schema.cron-retention.test.ts @@ -3,8 +3,8 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema cron retention and run-log validation", () => { it("accepts valid cron.sessionRetention and runLog values", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ cron: { sessionRetention: "1h30m", runLog: { @@ -13,7 +13,7 @@ describe("OpenClawSchema cron retention and run-log validation", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid cron.sessionRetention", () => { diff --git a/src/config/zod-schema.logging-levels.test.ts b/src/config/zod-schema.logging-levels.test.ts index 80a970720b5..93fc1b6d7e0 100644 --- a/src/config/zod-schema.logging-levels.test.ts +++ b/src/config/zod-schema.logging-levels.test.ts @@ -3,14 +3,14 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema logging levels", () => { it("accepts valid logging level values for level and consoleLevel", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ logging: { level: "debug", consoleLevel: "warn", }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid logging level values", () => { diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index a30590bc655..8782fe9d896 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -3,13 +3,13 @@ import { SessionSchema } from "./zod-schema.session.js"; describe("SessionSchema maintenance extensions", () => { it("accepts session write-lock acquire timeout", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ writeLock: { acquireTimeoutMs: 60_000, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid session write-lock acquire timeout values", () => { @@ -23,25 +23,25 @@ describe("SessionSchema maintenance extensions", () => { }); it("accepts valid maintenance extensions", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ maintenance: { resetArchiveRetention: "14d", maxDiskBytes: "500mb", highWaterBytes: "350mb", }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts disabling reset archive cleanup", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ maintenance: { resetArchiveRetention: false, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid maintenance extension values", () => { diff --git a/src/config/zod-schema.talk.test.ts b/src/config/zod-schema.talk.test.ts index bbb7eb9f89f..111394283e8 100644 --- a/src/config/zod-schema.talk.test.ts +++ b/src/config/zod-schema.talk.test.ts @@ -3,13 +3,13 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema talk validation", () => { it("accepts a positive integer talk.silenceTimeoutMs", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ talk: { silenceTimeoutMs: 1500, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it.each([ diff --git a/src/config/zod-schema.tts.test.ts b/src/config/zod-schema.tts.test.ts index 7d2a2a335dc..35b504620ff 100644 --- a/src/config/zod-schema.tts.test.ts +++ b/src/config/zod-schema.tts.test.ts @@ -3,8 +3,8 @@ import { TtsConfigSchema } from "./zod-schema.core.js"; describe("TtsConfigSchema openai speed and instructions", () => { it("accepts speed and instructions in openai section", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ providers: { openai: { voice: "alloy", @@ -13,12 +13,12 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts openai extraBody objects for compatible TTS endpoints", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ providers: { openai: { baseUrl: "http://localhost:8880/v1", @@ -31,36 +31,36 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); - it("rejects out-of-range openai speed", () => { - expect(() => - TtsConfigSchema.parse({ + it("accepts out-of-range openai speed for provider passthrough", () => { + expect( + TtsConfigSchema.safeParse({ providers: { openai: { speed: 5.0, }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); - it("rejects openai speed below minimum", () => { - expect(() => - TtsConfigSchema.parse({ + it("accepts openai speed below minimum for provider passthrough", () => { + expect( + TtsConfigSchema.safeParse({ providers: { openai: { speed: 0.1, }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts provider-specific persona bindings and structured prompt fields", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ persona: "alfred", personas: { alfred: { @@ -92,7 +92,7 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects persona rewrite config until runtime behavior exists", () => { From 42c9bd59e79a3d396875808a7a10e1dcf3aeb9b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:54:21 +0100 Subject: [PATCH 179/806] test: clarify guard fallback assertions --- .../models-config.providers.policy.lookup.test.ts | 12 ++++++------ src/agents/tools/gateway-tool-guard-coverage.test.ts | 8 ++++---- src/cron/stagger.test.ts | 3 --- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/agents/models-config.providers.policy.lookup.test.ts b/src/agents/models-config.providers.policy.lookup.test.ts index f7b95ec8be4..53f53bbdb04 100644 --- a/src/agents/models-config.providers.policy.lookup.test.ts +++ b/src/agents/models-config.providers.policy.lookup.test.ts @@ -50,22 +50,22 @@ describe("resolveProviderPluginLookupKey", () => { ).toBe("google"); }); - it("does not throw when runtime provider models is an object map", () => { - expect(() => + it("falls through when runtime provider models is an object map", () => { + expect( resolveProviderPluginLookupKey("openrouter", { baseUrl: "https://openrouter.ai/api/v1", models: { "some/model": { api: "openai-completions" } } as never, }), - ).not.toThrow(); + ).toBe("openrouter"); }); - it("does not throw when runtime provider models is undefined", () => { - expect(() => + it("falls through when runtime provider models is undefined", () => { + expect( resolveProviderPluginLookupKey("openrouter", { baseUrl: "https://openrouter.ai/api/v1", models: undefined as never, }), - ).not.toThrow(); + ).toBe("openrouter"); }); it("falls through to the provider key when runtime provider models is non-array", () => { diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts index 19ae284af63..4696719a89f 100644 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ b/src/agents/tools/gateway-tool-guard-coverage.test.ts @@ -21,13 +21,13 @@ function expectAllowed( currentConfig: Record, patch: Record, ): void { - expect(() => + expect( assertGatewayConfigMutationAllowedForTest({ action: "config.patch", currentConfig, raw: JSON.stringify(patch), }), - ).not.toThrow(); + ).toBeUndefined(); } function expectBlockedApply( @@ -47,13 +47,13 @@ function expectAllowedApply( currentConfig: Record, nextConfig: Record, ): void { - expect(() => + expect( assertGatewayConfigMutationAllowedForTest({ action: "config.apply", currentConfig, raw: JSON.stringify(nextConfig), }), - ).not.toThrow(); + ).toBeUndefined(); } describe("gateway config mutation guard coverage", () => { diff --git a/src/cron/stagger.test.ts b/src/cron/stagger.test.ts index a2c2cdd60ec..949f8e866db 100644 --- a/src/cron/stagger.test.ts +++ b/src/cron/stagger.test.ts @@ -35,9 +35,6 @@ describe("cron stagger helpers", () => { }); it("handles missing runtime expr values without throwing", () => { - expect(() => - resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), - ).not.toThrow(); expect( resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), ).toBe(0); From a4764091cedca7b4b82f5ddfd15acb70db700bd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:56:00 +0100 Subject: [PATCH 180/806] test: clarify infra cleanup assertions --- src/infra/agent-events.test.ts | 4 ++-- src/infra/channel-runtime-context.test.ts | 2 +- src/infra/outbound/outbound-policy.test.ts | 2 +- src/infra/skills-remote.test.ts | 6 ++---- src/plugins/channel-catalog-registry.test.ts | 2 +- src/plugins/stage-bundled-plugin-runtime.test.ts | 2 +- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index fc98135dcd7..5875e9d7170 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -189,13 +189,13 @@ describe("agent-events sequencing", () => { seen.push(evt.runId); }); - expect(() => + expect( emitAgentEvent({ runId: "run-safe", stream: "assistant", data: { text: "hi" }, }), - ).not.toThrow(); + ).toBeUndefined(); stopGood(); stopBad(); diff --git a/src/infra/channel-runtime-context.test.ts b/src/infra/channel-runtime-context.test.ts index f458515d11d..19a784afde4 100644 --- a/src/infra/channel-runtime-context.test.ts +++ b/src/infra/channel-runtime-context.test.ts @@ -35,7 +35,7 @@ describe("channel runtime context helpers", () => { const scoped = createTaskScopedChannelRuntime({}); expect(scoped.channelRuntime).toBeUndefined(); - expect(() => scoped.dispose()).not.toThrow(); + expect(scoped.dispose()).toBeUndefined(); }); it("disposes only task-scoped registrations", () => { diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 6eebc676362..2449d968458 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -99,7 +99,7 @@ function expectCrossContextPolicyResult(params: { }, }); if (params.expected === "allow") { - expect(run).not.toThrow(); + expect(run()).toBeUndefined(); return; } expect(run).toThrow(params.expected); diff --git a/src/infra/skills-remote.test.ts b/src/infra/skills-remote.test.ts index af26a7ed6d7..11acd3f62e8 100644 --- a/src/infra/skills-remote.test.ts +++ b/src/infra/skills-remote.test.ts @@ -40,10 +40,8 @@ describe("skills-remote", () => { it("supports idempotent remote node removal", () => { const nodeId = `node-${randomUUID()}`; - expect(() => { - removeRemoteNodeInfo(nodeId); - removeRemoteNodeInfo(nodeId); - }).not.toThrow(); + expect(removeRemoteNodeInfo(nodeId)).toBeUndefined(); + expect(removeRemoteNodeInfo(nodeId)).toBeUndefined(); }); it("bumps the skills snapshot version when an eligible remote node disconnects", async () => { diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts index 3ab563afd42..4d0427f5b0e 100644 --- a/src/plugins/channel-catalog-registry.test.ts +++ b/src/plugins/channel-catalog-registry.test.ts @@ -111,7 +111,7 @@ describe("listChannelCatalogEntries", () => { }, }); - expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow(); + expect(module.listChannelCatalogEntries({ env: ENV })).toEqual([]); expect(loadRecordsSpy).toHaveBeenCalledTimes(1); expect(discoverSpy).toHaveBeenCalledTimes(1); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index b8d01ab3179..c9b8dd27d06 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -518,7 +518,7 @@ describe("stageBundledPluginRuntime", () => { return realSymlinkSync(String(target), linkPath, type); }) as typeof fs.symlinkSync); - expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow(); + stageBundledPluginRuntime({ repoRoot }); const runtimeAssetPath = path.join( repoRoot, From d16fff10c03cafc2585676ad0b67ca08c046538e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:58:57 +0100 Subject: [PATCH 181/806] test: clarify ui gateway no throw assertions --- src/cli/run-main.exit.test.ts | 2 +- src/gateway/auth.test.ts | 6 +++--- ui/src/ui/app-render.assistant-avatar.test.ts | 6 +++--- ui/src/ui/views/cron.test.ts | 20 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 53f8d2ff969..4510e918356 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -800,7 +800,7 @@ describe("runCli exit behavior", () => { const hostUnreachable = Object.assign(new Error("connect EHOSTUNREACH 149.154.167.220:443"), { code: "EHOSTUNREACH", }); - expect(() => handler(hostUnreachable)).not.toThrow(); + expect(handler(hostUnreachable)).toBeUndefined(); expect(consoleWarnSpy).toHaveBeenCalledWith( "[openclaw] Non-fatal uncaught exception (continuing):", expect.stringContaining("EHOSTUNREACH"), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index b970cc98b71..d3bd2966a8d 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -249,7 +249,7 @@ describe("gateway auth", () => { }); }); - it("does not throw when req is missing socket", async () => { + it("authorizes matching token auth when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, connectAuth: { token: "secret" }, @@ -550,12 +550,12 @@ describe("gateway auth", () => { }); expect(auth.password).toBe("env-password"); - expect(() => + expect( assertGatewayAuthConfigured(auth, { mode: "password", password: rawPasswordRef, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("throws generic error when password mode has no password at all", () => { diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index f23f80dfda8..341da86a457 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -254,8 +254,8 @@ describe("renderApp assistant avatar routing", () => { expect(quickSettingsProps.current?.security.execPolicy).toBe("full"); }); - it("does not throw when stale cron state contains a job without a payload", () => { - expect(() => + it("renders stale cron state containing a job without a payload", () => { + expect( renderApp( createState({ cronJobs: [ @@ -273,6 +273,6 @@ describe("renderApp assistant avatar routing", () => { ], }), ), - ).not.toThrow(); + ).toBeDefined(); }); }); diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index e1fdf776e16..2056c5aa727 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -338,23 +338,23 @@ describe("cron view", () => { expect(container.textContent).toContain("https://example.invalid/cron"); }); - it("does not throw when a stale cron job has no payload", () => { + it("renders a stale cron job with no payload", () => { const container = document.createElement("div"); const job = { ...createJob("job-broken"), payload: undefined, } as unknown as CronJob; - expect(() => - render( - renderCron( - createProps({ - jobs: [job], - }), - ), - container, + render( + renderCron( + createProps({ + jobs: [job], + }), ), - ).not.toThrow(); + container, + ); + + expect(container.textContent).toContain("Daily ping"); }); it("renders cron job prompts and run summaries as sanitized markdown", () => { From 450b541d77f991c79ef3f9fd95e095dea1e16edc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:02:44 +0100 Subject: [PATCH 182/806] test: clarify extension auth assertions --- extensions/feishu/src/accounts.test.ts | 11 ++++++++--- extensions/firecrawl/src/firecrawl-tools.test.ts | 4 ++-- extensions/google/index.test.ts | 6 +++--- extensions/matrix/src/matrix/client/config.test.ts | 2 +- src/commands/models/list.auth-overview.test.ts | 2 +- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index e8d2e8577b2..5647ac533a3 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -436,8 +436,8 @@ describe("resolveFeishuAccount", () => { expect((caught as Error).message).toMatch(/channels\.feishu\.appSecret: unresolved SecretRef/i); }); - it("does not throw when account name is non-string", () => { - expect(() => + it("ignores non-string account names", () => { + expect( resolveFeishuAccount({ cfg: { channels: { @@ -454,6 +454,11 @@ describe("resolveFeishuAccount", () => { } as never, accountId: "main", }), - ).not.toThrow(); + ).toMatchObject({ + accountId: "main", + appId: "cli_123", + appSecret: "secret_456", + name: undefined, + }); }); }); diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index d6f84b6c28f..a494e699614 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -215,9 +215,9 @@ describe("firecrawl tools", () => { }); it("blocks private and non-http scrape targets before Firecrawl requests", () => { - expect(() => + expect( firecrawlClientTesting.assertFirecrawlScrapeTargetAllowed("https://example.com/page"), - ).not.toThrow(); + ).toBeUndefined(); for (const blockedUrl of [ "http://localhost/admin", diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 4c09c7ddff1..7ba11a69d83 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -251,8 +251,8 @@ describe("google provider plugin hooks", () => { if (!bridge) { throw new Error("expected Google realtime bridge"); } - expect(() => bridge.sendAudio(Buffer.alloc(160))).not.toThrow(); - expect(() => bridge.setMediaTimestamp(20)).not.toThrow(); - expect(() => bridge.sendUserMessage?.("hello")).not.toThrow(); + expect(bridge.sendAudio(Buffer.alloc(160))).toBeUndefined(); + expect(bridge.setMediaTimestamp(20)).toBeUndefined(); + expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); }); diff --git a/extensions/matrix/src/matrix/client/config.test.ts b/extensions/matrix/src/matrix/client/config.test.ts index c2cc11e1972..ecc6c1ed81b 100644 --- a/extensions/matrix/src/matrix/client/config.test.ts +++ b/extensions/matrix/src/matrix/client/config.test.ts @@ -228,7 +228,7 @@ describe("Matrix auth/config live surfaces", () => { ).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i); }); - it("does not throw when accessToken uses a non-env SecretRef", () => { + it("leaves non-env SecretRef access tokens unresolved", () => { const cfg = { channels: { matrix: { diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index 922c76fce90..cb3b45af317 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -96,7 +96,7 @@ describe("resolveProviderAuthOverview", () => { persistedStores.clear(); }); - it("does not throw when token profile only has tokenRef", () => { + it("labels token profiles that only have tokenRef", () => { const overview = resolveProviderAuthOverview({ provider: "github-copilot", cfg: {}, From e6031fd03a73f8ae3c56148a1eb843736f5b58ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:04:33 +0100 Subject: [PATCH 183/806] test: clarify gateway auth probe assertions --- .../service.issue-13992-regression.test.ts | 2 +- src/gateway/live-agent-probes.test.ts | 10 +++---- src/gateway/startup-auth.test.ts | 26 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index 63c643ff73f..bbcdf399f5f 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -189,7 +189,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const state = createMockCronStateForJobs({ jobs: [dueJob, malformedJob], nowMs: now }); - expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow(); + expect(recomputeNextRunsForMaintenance(state)).toBe(true); expect(dueJob.state.nextRunAtMs).toBe(pastDue); expect(malformedJob.state.nextRunAtMs).toBeUndefined(); expect(malformedJob.state.scheduleErrorCount).toBe(1); diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index ff26bb0659f..2b308a539d9 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -19,12 +19,12 @@ describe("live-agent-probes", () => { }); it("accepts only cat for the shared image probe reply", () => { - expect(() => assertLiveImageProbeReply("cat")).not.toThrow(); - expect(() => + expect(assertLiveImageProbeReply("cat")).toBeUndefined(); + expect( assertLiveImageProbeReply( "model metadata for `gpt-5.5` not found. defaulting to fallback metadata; this can degrade performance and cause issues.cat", ), - ).not.toThrow(); + ).toBeUndefined(); expect(() => assertLiveImageProbeReply("horse")).toThrow("image probe expected 'cat'"); expect(() => assertLiveImageProbeReply("caterpillar")).toThrow("image probe expected 'cat'"); }); @@ -77,7 +77,7 @@ describe("live-agent-probes", () => { }); it("validates cron cli job shape for the shared live probe", () => { - expect(() => + expect( assertCronJobMatches({ job: { name: "live-mcp-abc", @@ -90,6 +90,6 @@ describe("live-agent-probes", () => { expectedMessage: "probe-abc", expectedSessionKey: "agent:dev:test", }), - ).not.toThrow(); + ).toBeUndefined(); }); }); diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 2b40af7fe41..4524cbbbb9e 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -515,36 +515,36 @@ describe("assertGatewayAuthNotKnownWeak", () => { }, ); - it("does not throw on an empty token (falls through to generation path)", () => { - expect(() => + it("allows an empty token to fall through to generation path", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "token", modeSource: "config", token: "", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); - it("does not throw on a real token", () => { - expect(() => + it("allows a real token", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "token", modeSource: "config", token: "a-legit-random-token-0123456789abcdef", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); - it("does not throw on the none mode", () => { - expect(() => + it("allows the none mode", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "none", modeSource: "default", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -569,7 +569,7 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { }); it("allows hooks token when gateway auth is not token mode", () => { - expect(() => + expect( assertHooksTokenSeparateFromGatewayAuth({ cfg: { hooks: { @@ -584,11 +584,11 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { allowTailscale: false, }, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("allows matching values when hooks are disabled", () => { - expect(() => + expect( assertHooksTokenSeparateFromGatewayAuth({ cfg: { hooks: { @@ -603,6 +603,6 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { allowTailscale: false, }, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); From d0f484d02421c3999b0079ca0eb93e9149a8eb8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:06:18 +0100 Subject: [PATCH 184/806] test: clarify runtime event assertions --- src/context-engine/context-engine.test.ts | 4 ++-- src/gateway/client.test.ts | 7 ++----- src/gateway/client.watchdog.test.ts | 2 +- src/gateway/talk-realtime-relay.test.ts | 2 +- src/logging/console-capture.test.ts | 2 +- src/sessions/session-lifecycle-events.test.ts | 4 ++-- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 70bff373e5f..5245ed853ce 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1127,8 +1127,8 @@ describe("Initialization guard", () => { it("ensureContextEnginesInitialized() is idempotent and registers legacy", async () => { const { ensureContextEnginesInitialized } = await import("./init.js"); - expect(() => ensureContextEnginesInitialized()).not.toThrow(); - expect(() => ensureContextEnginesInitialized()).not.toThrow(); + expect(ensureContextEnginesInitialized()).toBeUndefined(); + expect(ensureContextEnginesInitialized()).toBeUndefined(); const ids = listContextEngineIds(); expect(ids).toContain("legacy"); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 1571ee5c7bd..9b6762d2cc0 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -234,8 +234,7 @@ describe("GatewayClient security checks", () => { onConnectError, }); - // Should not throw - expect(() => client.start()).not.toThrow(); + expect(client.start()).toBeUndefined(); expectSecurityConnectError(onConnectError); expect(wsInstances.length).toBe(0); // No WebSocket created @@ -535,9 +534,7 @@ describe("GatewayClient close handling", () => { const client = createClientWithIdentity("dev-2", onClose); client.start(); - expect(() => { - getLatestWs().emitClose(1008, "unauthorized: device token mismatch"); - }).not.toThrow(); + expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined(); expect(logDebugMock).toHaveBeenCalledWith( expect.stringContaining("failed clearing stale device-auth token"), diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 52547aa6a57..3c3bf5b7b1d 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -210,7 +210,7 @@ describe("GatewayClient", () => { }); try { - expect(() => client.start()).not.toThrow(); + expect(client.start()).toBeUndefined(); await connected; expect(onConnectError).not.toHaveBeenCalled(); } finally { diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index c78535672c9..2ebc7bd14ef 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -552,6 +552,6 @@ describe("talk realtime gateway relay", () => { expect(() => createSession("conn-1")).toThrow( "Too many active realtime relay sessions for this connection", ); - expect(() => createSession("conn-2")).not.toThrow(); + expect(createSession("conn-2")).toBeDefined(); }); }); diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index 6bb8f098bbe..a97c8d00e89 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -177,7 +177,7 @@ describe("enableConsoleCapture", () => { enableConsoleCapture(); const epipe = new Error("write EPIPE") as NodeJS.ErrnoException; epipe.code = "EPIPE"; - expect(() => stream.emit("error", epipe)).not.toThrow(); + expect(stream.emit("error", epipe)).toBe(true); }); it("rethrows non-EPIPE errors on stdout", () => { diff --git a/src/sessions/session-lifecycle-events.test.ts b/src/sessions/session-lifecycle-events.test.ts index d8654925405..1af7c67890c 100644 --- a/src/sessions/session-lifecycle-events.test.ts +++ b/src/sessions/session-lifecycle-events.test.ts @@ -48,12 +48,12 @@ describe("session lifecycle events", () => { const unsubscribeNoisy = onSessionLifecycleEvent(noisy.listener); const unsubscribeHealthy = onSessionLifecycleEvent(healthy.listener); - expect(() => + expect( emitSessionLifecycleEvent({ sessionKey: "agent:main:main", reason: "resumed", }), - ).not.toThrow(); + ).toBeUndefined(); expect(noisy.calls).toHaveLength(1); expect(healthy.calls).toHaveLength(1); From 210df889f01154b91adabb0d8ba3bac3394122f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:08:22 +0100 Subject: [PATCH 185/806] test: clarify cron config task assertions --- src/config/io.write-config.test.ts | 9 +++------ src/cron/service.jobs.test.ts | 13 +++++++------ src/cron/service.list-page-sort-guards.test.ts | 4 ++-- src/tasks/task-registry.store.test.ts | 8 ++++++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 72882814ab0..9fd1399ff4b 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -463,11 +463,8 @@ describe("config io write", () => { }); await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8"); - let loadedConfig: ReturnType | undefined; - expect(() => { - loadedConfig = io.loadConfig(); - }).not.toThrow(); - expect(loadedConfig?.plugins?.installs?.demo).toMatchObject({ + const loadedConfig = io.loadConfig(); + expect(loadedConfig.plugins?.installs?.demo).toMatchObject({ source: "npm", spec: "demo@1.0.0", installPath: pluginDir, @@ -1386,7 +1383,7 @@ describe("config io write", () => { plugins: { entries: { "strict-plugin": { enabled: true } } }, }; - await expect(writeConfigFile(cfg, { skipPluginValidation: true })).resolves.not.toThrow(); + await writeConfigFile(cfg, { skipPluginValidation: true }); await expect(fs.readFile(configPath, "utf-8")).resolves.toContain('"strict-plugin"'); await expect(writeConfigFile(cfg, { skipPluginValidation: false })).rejects.toThrow( diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index fb4556c7b7d..b30c76cbc69 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -344,9 +344,7 @@ describe("applyJobPatch", () => { to: "https://example.invalid/original", }); - expect(() => - applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }), - ).not.toThrow(); + applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" }); }); @@ -470,7 +468,7 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { it("allows isolated session job for non-default agents", () => { const state = createMockState(now, { defaultAgentId: "main" }); - expect(() => + expect( createJob(state, { name: "isolated-job", enabled: true, @@ -480,7 +478,10 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { payload: { kind: "agentTurn", message: "do it" }, agentId: "custom-agent", }), - ).not.toThrow(); + ).toMatchObject({ + agentId: "custom-agent", + sessionTarget: "isolated", + }); }); it("rejects custom session targets with path separators", () => { @@ -924,7 +925,7 @@ describe("recomputeNextRuns", () => { expect(job.state.nextRunAtMs).toBe(expected); }); - it("does not throw while probing malformed cron schedules with future nextRunAtMs", () => { + it("keeps future nextRunAtMs while probing malformed cron schedules", () => { const now = Date.parse("2026-05-05T12:00:00.000Z"); const future = Date.parse("2026-05-12T16:00:00.000Z"); const job: CronJob = { diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts index 26a85fa0f75..01c4dbf5148 100644 --- a/src/cron/service.list-page-sort-guards.test.ts +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -20,7 +20,7 @@ function createBaseJob(overrides?: Partial): CronJob { } describe("cron listPage sort guards", () => { - it("does not throw when sorting by name with malformed name fields", async () => { + it("keeps malformed name fields sortable", async () => { const jobs = [ createBaseJob({ id: "job-a", name: undefined as unknown as string }), createBaseJob({ id: "job-b", name: "beta" }), @@ -31,7 +31,7 @@ describe("cron listPage sort guards", () => { expect(page.jobs).toHaveLength(2); }); - it("does not throw when tie-break sorting encounters missing ids", async () => { + it("keeps missing ids sortable during tie-breaks", async () => { const nextRunAtMs = Date.parse("2026-02-27T15:30:00.000Z"); const jobs = [ createBaseJob({ diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index 257842bff93..a380c8524bd 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -465,14 +465,18 @@ describe("task-registry store runtime", () => { resetTaskRegistryForTests({ persist: false }); - expect(() => + expect( markTaskLostById({ taskId: "legacy-session-task", endedAt: 200, lastEventAt: 200, error: "session missing", }), - ).not.toThrow(); + ).toMatchObject({ + taskId: "legacy-session-task", + status: "lost", + error: "session missing", + }); expect(findTaskByRunId("legacy-session-run")).toMatchObject({ taskId: "legacy-session-task", status: "lost", From d1bf0eb7700816ba498960ab00abdd2597b37f80 Mon Sep 17 00:00:00 2001 From: Panda Dev <56657208+pandadev66@users.noreply.github.com> Date: Fri, 8 May 2026 14:09:45 +0200 Subject: [PATCH 186/806] fix(fetch-timeout): pass operation and url context at omitting call sites (#79195) (#79253) --- CHANGELOG.md | 1 + extensions/matrix/src/matrix/sdk/transport.ts | 2 ++ src/agents/tools/music-generate-tool.ts | 4 ++++ src/utils/fetch-timeout.test.ts | 17 +++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6be581312..a1b29681b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. +- Infra/fetch-timeout: pass `operation` and `url` context to `buildTimeoutAbortSignal` from the music-generate reference fetch and the Matrix guarded redirect transport, so the `fetch timeout reached; aborting operation` warning carries actionable structured fields instead of a bare line. Fixes #79195. Thanks @pandadev66. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. - macOS/config: reject stale or destructive app fallback config writes before direct replacement and keep rejected payloads as private audit artifacts, so `gateway.mode`, metadata, and auth are not silently clobbered. Fixes #64973 and #74890. Thanks @BunsDev. - Gateway/macOS: include Apple Silicon Homebrew bin and sbin directories in generated LaunchAgent service PATHs so `openclaw gateway restart` keeps Homebrew Node installs reachable. Fixes #79232. Thanks @BunsDev. diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts index 039c65eb690..c4505c4132c 100644 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -117,6 +117,8 @@ async function fetchWithMatrixGuardedRedirects(params: { const { signal, cleanup } = buildTimeoutAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal, + operation: "matrix.guarded-redirect-fetch", + url: params.url, }); for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index 9f1ed351414..1a00afdc0b4 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -359,8 +359,12 @@ async function loadReferenceImages(params: { readFile: createSandboxBridgeReadFile({ sandbox: params.sandboxConfig }), }) : await (async () => { + const referenceTarget = resolvedPath ?? resolvedInput; + const isRemoteReference = /^https?:\/\//i.test(referenceTarget); const { signal, cleanup } = buildTimeoutAbortSignal({ timeoutMs: params.timeoutMs ?? DEFAULT_REFERENCE_FETCH_TIMEOUT_MS, + operation: "music-generate.reference-fetch", + ...(isRemoteReference ? { url: referenceTarget } : {}), }); try { return await loadWebMedia(resolvedPath ?? resolvedInput, { diff --git a/src/utils/fetch-timeout.test.ts b/src/utils/fetch-timeout.test.ts index 9342a9f102d..b2cd7fa7e40 100644 --- a/src/utils/fetch-timeout.test.ts +++ b/src/utils/fetch-timeout.test.ts @@ -150,6 +150,23 @@ describe("buildTimeoutAbortSignal", () => { cleanup(); }); + it("emits a warning without operation or url when callers omit context (#79195)", async () => { + const { signal, cleanup } = buildTimeoutAbortSignal({ + timeoutMs: 25, + }); + + await vi.advanceTimersByTimeAsync(25); + + expect(signal?.aborted).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + const [, record] = warn.mock.calls[0] as [string, Record]; + expect(record).not.toHaveProperty("operation"); + expect(record).not.toHaveProperty("url"); + expect(record.consoleMessage).toBe("fetch timeout after 25ms (elapsed 25ms)"); + + cleanup(); + }); + it("refreshes its timeout when progress is observed", async () => { const { signal, refresh, cleanup } = buildTimeoutAbortSignal({ timeoutMs: 25, From 52b0d148253f60d849711d29da76e8e88737ba4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:09:56 +0100 Subject: [PATCH 187/806] test: clarify sandbox auth assertions --- .../auth-profiles/oauth-refresh-queue.test.ts | 6 ++--- .../sandbox/validate-sandbox-security.test.ts | 24 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index ae073af73d2..3b075375e8a 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -113,10 +113,8 @@ describe("OAuth refresh in-process queue", () => { it("resetOAuthRefreshQueuesForTest drains pending gates", () => { // We can't observe the internal map, but we can assert that calling the // reset is idempotent and safe from any state. - expect(() => { - resetOAuthRefreshQueuesForTest(); - resetOAuthRefreshQueuesForTest(); - }).not.toThrow(); + expect(resetOAuthRefreshQueuesForTest()).toBeUndefined(); + expect(resetOAuthRefreshQueuesForTest()).toBeUndefined(); }); it("serializes a 10-caller burst so later arrivals never pass an earlier caller", async () => { diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index ca031126615..5fb8849e188 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -180,11 +180,11 @@ describe("validateBindMounts", () => { }); it("compares Windows allowed roots case-insensitively", () => { - expect(() => + expect( validateBindMounts(["d:/DATA/OpenClaw/src:/src:ro"], { allowedSourceRoots: ["D:/data/openclaw"], }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => validateBindMounts(["D:/other/project:/src:ro"], { @@ -280,22 +280,22 @@ describe("validateBindMounts", () => { it("allows bind sources in allowed roots when allowlist is configured", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-allowed-")); - expect(() => + expect( validateBindMounts([`${join(projectRoot, "cache")}:/data:ro`], { allowedSourceRoots: [projectRoot], }), - ).not.toThrow(); + ).toBeUndefined(); }); it("allows bind sources outside allowed roots with explicit dangerous override", () => { const allowedRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-allowed-root-")); const externalRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-external-")); - expect(() => + expect( validateBindMounts([`${externalRoot}:/data:ro`], { allowedSourceRoots: [allowedRoot], allowSourcesOutsideAllowedRoots: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("blocks reserved container target paths by default", () => { @@ -307,11 +307,11 @@ describe("validateBindMounts", () => { it("allows reserved container target paths with explicit dangerous override", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-reserved-")); - expect(() => + expect( validateBindMounts([`${projectRoot}:/workspace:rw`], { allowReservedContainerTargets: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -354,11 +354,11 @@ describe("validateNetworkMode", () => { }); it("allows container namespace joins with explicit dangerous override", () => { - expect(() => + expect( validateNetworkMode("container:abc123", { allowContainerNamespaceJoin: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -397,13 +397,13 @@ describe("profile hardening", () => { describe("validateSandboxSecurity", () => { it("passes with safe config", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-safe-config-")); - expect(() => + expect( validateSandboxSecurity({ binds: [`${projectRoot}:/src:rw`], network: "none", seccompProfile: "/tmp/seccomp.json", apparmorProfile: "openclaw-sandbox", }), - ).not.toThrow(); + ).toBeUndefined(); }); }); From 59d86d65db2a0a598f4bdcdd56db91e5c8492b97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:11:55 +0100 Subject: [PATCH 188/806] test: clarify context pruning malformed assertions --- .../pi-hooks/context-pruning/pruner.test.ts | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/src/agents/pi-hooks/context-pruning/pruner.test.ts b/src/agents/pi-hooks/context-pruning/pruner.test.ts index 0b334339650..64751364bf5 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.test.ts +++ b/src/agents/pi-hooks/context-pruning/pruner.test.ts @@ -104,7 +104,7 @@ function expectToolResultWasTrimmed(result: AgentMessage[]) { } describe("pruneContextMessages", () => { - it("does not crash on assistant message with malformed thinking block (missing thinking string)", () => { + it("keeps assistant messages with malformed thinking blocks", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([ @@ -112,30 +112,30 @@ describe("pruneContextMessages", () => { { type: "text", text: "ok" }, ]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on assistant message with null content entries", () => { + it("keeps assistant messages with null content entries", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([null as unknown as AssistantContentBlock, { type: "text", text: "world" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on assistant message with malformed text block (missing text string)", () => { + it("keeps assistant messages with malformed text blocks", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([ @@ -143,16 +143,16 @@ describe("pruneContextMessages", () => { { type: "thinking", thinking: "still fine" }, ]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on toolResult with malformed text block (missing text string)", () => { + it("keeps tool results with malformed text blocks", () => { // Regression: a plugin returning undefined produces {type: "text"} with no text property, // which crashed estimateTextAndImageChars / collectTextSegments / collectPrunableToolResultSegments. // See https://github.com/openclaw/openclaw/issues/34979 @@ -174,16 +174,16 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "done" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(5); }); - it("does not crash on toolResult with malformed text block during soft-trim (image path)", () => { + it("keeps tool results with malformed text blocks during soft-trim image paths", () => { // The collectPrunableToolResultSegments path is exercised when the tool result // contains image blocks alongside a malformed text block. const malformedToolResult = { @@ -199,28 +199,28 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "here it is" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: { - ...DEFAULT_CONTEXT_PRUNING_SETTINGS, - keepLastAssistants: 1, - softTrimRatio: 0, - hardClear: { - ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, - enabled: false, - }, - softTrim: { - maxChars: 5_000, - headChars: 2_000, - tailChars: 2_000, - }, + const result = pruneContextMessages({ + messages, + settings: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, + keepLastAssistants: 1, + softTrimRatio: 0, + hardClear: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, + enabled: false, }, - ctx: CONTEXT_WINDOW_1M, - isToolPrunable: () => true, - contextWindowTokensOverride: 1, - }), - ).not.toThrow(); + softTrim: { + maxChars: 5_000, + headChars: 2_000, + tailChars: 2_000, + }, + }, + ctx: CONTEXT_WINDOW_1M, + isToolPrunable: () => true, + contextWindowTokensOverride: 1, + }); + + expect(result).toHaveLength(3); }); it("counts malformed non-string text blocks when deciding to trim tool results", () => { @@ -264,7 +264,7 @@ describe("pruneContextMessages", () => { expect(textBlock.text).toContain("[Tool result trimmed:"); }); - it("does not crash on toolResult with null content entries", () => { + it("keeps tool results with null content entries", () => { const malformedToolResult = { role: "toolResult", toolName: "read", @@ -278,13 +278,13 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "done" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(3); }); it("handles well-formed thinking blocks correctly", () => { From 2f001fc14449870209d0fecae90b9ba19268e209 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:14:50 +0100 Subject: [PATCH 189/806] test: clarify memory fallback assertions --- .../memory-core/src/memory/manager.vector-dedupe.test.ts | 6 +++--- .../memory-core/src/memory/manager.watcher-config.test.ts | 2 +- extensions/memory-core/src/tools.citations.test.ts | 4 ++-- .../whatsapp/src/auto-reply/web-auto-reply-utils.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts index 144f0708487..9120c649f3d 100644 --- a/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts +++ b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts @@ -29,13 +29,13 @@ describe("memory vector dedupe", () => { END; `); - expect(() => + expect( replaceMemoryVectorRow({ - db: db!, + db, id: "chunk-1", embedding: [2, 0, 0], }), - ).not.toThrow(); + ).toBeUndefined(); const row = db .prepare("SELECT COUNT(*) as c, length(embedding) as bytes FROM chunks_vec WHERE id = ?") diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index de1ce6855fe..8c60dadc1ef 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -268,7 +268,7 @@ describe("memory watcher config", () => { const watcher = createdWatchers[0]; expect(watcher?.on).toHaveBeenCalledWith("error", expect.any(Function)); - expect(() => watcher?.emit("error", new Error("watcher error: ENOSPC"))).not.toThrow(); + expect(watcher?.emit("error", new Error("watcher error: ENOSPC"))).toBeUndefined(); expect(memoryLoggerWarn).toHaveBeenCalledWith("memory watcher error: watcher error: ENOSPC"); }); }); diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 3b58d95ad15..e7a547abfbb 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -126,7 +126,7 @@ describe("memory search citations", () => { }); describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { + it("returns unavailable details when memory_search fails (e.g. embeddings 429)", async () => { setMemorySearchImpl(async () => { throw new Error("openai embeddings failed: 429 insufficient_quota"); }); @@ -142,7 +142,7 @@ describe("memory tools", () => { }); }); - it("does not throw when memory_get fails", async () => { + it("returns disabled details when memory_get fails", async () => { setMemoryReadFileImpl(async (_params: MemoryReadParams) => { throw new Error("path required"); }); diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index e7ba03eba93..30cbb117989 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -346,7 +346,7 @@ describe("web auto-reply util", () => { expect(isLikelyWhatsAppCryptoError(err)).toBe(true); }); - it("does not throw on circular objects", () => { + it("returns false for circular objects", () => { const circular: Record = {}; circular.self = circular; expect(isLikelyWhatsAppCryptoError(circular)).toBe(false); From f5e61081330fb56d24aca2c9a8d3cd9bc1a809aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:17:30 +0100 Subject: [PATCH 190/806] test: clarify browser cdp fuzz assertions --- .../browser/src/browser/cdp.helpers.fuzz.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts index 94236a67756..dcd687fe819 100644 --- a/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts @@ -200,7 +200,7 @@ describe("fuzz: isDirectCdpWebSocketEndpoint", () => { } }); - it("never throws on random input (including invalid URLs)", () => { + it("returns booleans for random input including invalid URLs", () => { const rng = makeRng(0x2004); const junkPool = [ "", @@ -215,7 +215,6 @@ describe("fuzz: isDirectCdpWebSocketEndpoint", () => { ]; for (let i = 0; i < ITERATIONS; i += 1) { const input = rng() < 0.5 ? pick(rng, junkPool) : String.fromCharCode(randInt(rng, 0, 0x7f)); - expect(() => isDirectCdpWebSocketEndpoint(input)).not.toThrow(); expect(typeof isDirectCdpWebSocketEndpoint(input)).toBe("boolean"); } }); @@ -271,12 +270,12 @@ describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => { } }); - it("falls back safely for non-URL-ish inputs (never throws)", () => { + it("returns normalized strings for non-URL-ish inputs", () => { const rng = makeRng(0x3003); // These inputs either trigger the catch branch (empty / "garbage" / // bare "ws://" / "wss://") or are accepted by WHATWG URL as // special-scheme absolute URLs (e.g. "ws:host/path" becomes - // "ws://host/path"). Either way the helper must never throw. + // "ws://host/path"). Both paths must return strings. const junk = [ "ws:/devtools/browser/abc", "wss:/devtools/browser/abc", @@ -289,7 +288,6 @@ describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => { ]; for (let i = 0; i < ITERATIONS; i += 1) { const input = pick(rng, junk); - expect(() => normalizeCdpHttpBaseForJsonEndpoints(input)).not.toThrow(); const out = normalizeCdpHttpBaseForJsonEndpoints(input); expect(typeof out).toBe("string"); // Scheme swap invariant: whatever branch ran, ws:/wss: never @@ -377,11 +375,10 @@ describe("fuzz: redactCdpUrl", () => { expect(redactCdpUrl(" ")).toBe(""); }); - it("falls back to redactSensitiveText for non-URL-ish inputs (never throws)", () => { + it("falls back to redactSensitiveText for non-URL-ish inputs", () => { const rng = makeRng(0x5002); for (let i = 0; i < ITERATIONS; i += 1) { const junk = pick(rng, ["not-a-url", "http://", "ws://", "::::", "Bearer ey.SECRET.xyz"]); - expect(() => redactCdpUrl(junk)).not.toThrow(); const out = redactCdpUrl(junk); expect(typeof out).toBe("string"); } @@ -405,7 +402,7 @@ describe("fuzz: appendCdpPath", () => { }); describe("fuzz: getHeadersWithAuth", () => { - it("never throws and always returns a mergedHeaders object", () => { + it("always returns a mergedHeaders object", () => { const rng = makeRng(0x7001); for (let i = 0; i < ITERATIONS; i += 1) { const withAuth = rng() < 0.3; From f40e3fe67ea058eb0a8682f599613683395bc6c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:20:40 +0100 Subject: [PATCH 191/806] test: clarify extension resilience assertions --- extensions/feishu/src/setup-surface.test.ts | 2 +- extensions/slack/src/format.test.ts | 2 +- extensions/telegram/src/format.wrap-md.test.ts | 1 - extensions/zalo/src/monitor.webhook.test.ts | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index bb84467b5f7..8933c055395 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -88,7 +88,7 @@ describe("feishu setup wizard", () => { probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" }); }); - it("does not throw when config appId/appSecret are SecretRef objects", async () => { + it("prompts over SecretRef appId/appSecret config objects", async () => { const text = vi .fn() .mockResolvedValueOnce("cli_from_prompt") diff --git a/extensions/slack/src/format.test.ts b/extensions/slack/src/format.test.ts index 7d0b3c392b4..bf8ec3517b3 100644 --- a/extensions/slack/src/format.test.ts +++ b/extensions/slack/src/format.test.ts @@ -62,7 +62,7 @@ describe("markdownToSlackMrkdwn", () => { ); }); - it("does not throw when input is undefined at runtime", () => { + it("returns empty text when input is undefined at runtime", () => { expect(markdownToSlackMrkdwn(undefined as unknown as string)).toBe(""); }); diff --git a/extensions/telegram/src/format.wrap-md.test.ts b/extensions/telegram/src/format.wrap-md.test.ts index 324c1ccdc22..a9bcd6240c0 100644 --- a/extensions/telegram/src/format.wrap-md.test.ts +++ b/extensions/telegram/src/format.wrap-md.test.ts @@ -236,7 +236,6 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { it("gracefully returns the original chunk when tag overhead exceeds the limit", () => { const input = "**ab**"; - expect(() => markdownToTelegramChunks(input, 6)).not.toThrow(); const chunks = markdownToTelegramChunks(input, 6); expect(chunks).toHaveLength(1); expect(chunks[0]?.text).toBe("ab"); diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 4b76cf08027..58a1edfe897 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -426,7 +426,7 @@ describe("handleZaloWebhookRequest", () => { } }); - it("does not throw when replay metadata is partially missing", async () => { + it("accepts replay metadata when optional fields are missing", async () => { const sink = vi.fn(); const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink }); const payload = { From 249e58b939211f89a42d2041647199372aba1f07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:23:23 +0100 Subject: [PATCH 192/806] test: clarify tooling accepted assertions --- test/scripts/changed-lanes.test.ts | 4 ++-- .../openclaw-cross-os-release-checks.test.ts | 16 +++++++-------- .../postinstall-bundled-plugins.test.ts | 20 +++++++++---------- test/scripts/tsdown-build.test.ts | 4 ++-- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 3b9d9610b11..1acc9ce9077 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -781,7 +781,7 @@ describe("scripts/changed-lanes", () => { "utf8", ); git(dir, ["add", "package.json"]); - expect(() => + expect( execFileSync( process.execPath, [path.join(repoRoot, "scripts", "check-release-metadata-only.mjs"), "--staged"], @@ -791,7 +791,7 @@ describe("scripts/changed-lanes", () => { stdio: "pipe", }, ), - ).not.toThrow(); + ).toBeInstanceOf(Buffer); writeFileSync( path.join(dir, "package.json"), diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 9c71faf90f8..cedf8384ee3 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -989,7 +989,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }); it("accepts a git main dev-channel update status payload", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1003,11 +1003,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }, }), ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts a git dev-channel payload for a requested non-main branch", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1023,11 +1023,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "codex/cross-os-release-checks-full-native-e2e" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts a git dev-channel payload pinned to a prepared source sha", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1043,11 +1043,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "08753a1d793c040b101c8a26c43445dbbab14995" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts uppercase requested commit shas when update status reports lowercase", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1062,7 +1062,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "08753A1D793C040B101C8A26C43445DBBAB14995" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("rejects update status payloads that are not on dev/main git", () => { diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 21efcf705d6..dcb2640165a 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -256,7 +256,7 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ env: { HOME: "/tmp/home" }, packageRoot, @@ -265,7 +265,7 @@ describe("bundled plugin postinstall", () => { }), log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); expect(warn).toHaveBeenCalledWith( "[postinstall] could not prune bundled plugin source node_modules: Error: locked", @@ -566,7 +566,7 @@ describe("bundled plugin postinstall", () => { it("keeps legacy plugin runtime deps cleanup non-fatal", () => { const warn = vi.fn(); - expect(() => + expect( pruneLegacyPluginRuntimeDepsState({ env: { HOME: "/home/alice" }, existsSync: vi.fn(() => true), @@ -576,7 +576,7 @@ describe("bundled plugin postinstall", () => { log: { log: vi.fn(), warn }, homedir: () => "/home/alice", }), - ).not.toThrow(); + ).toEqual([]); expect(warn).toHaveBeenCalledWith( expect.stringContaining( @@ -645,13 +645,13 @@ describe("bundled plugin postinstall", () => { return readFileSyncOriginal(filePath, options); }); - expect(() => + expect( pruneInstalledPackageDist({ packageRoot, readFileSync, log: { log: vi.fn(), warn: vi.fn() }, }), - ).not.toThrow(); + ).toEqual(["dist/stale.js"]); await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -701,12 +701,12 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(staleFile, "export {};\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); await expectPathExists(staleFile); expect(warn).toHaveBeenCalledWith( @@ -723,12 +723,12 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(inventoryPath, "{not-json}\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); await expectPathExists(currentFile); expect(warn).toHaveBeenCalledWith( diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index d0a1b67de31..197acba7582 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -52,11 +52,11 @@ describe("resolveTsdownBuildInvocation", () => { throw new Error("locked"); }); - expect(() => + expect( pruneSourceCheckoutBundledPluginNodeModules({ cwd: process.cwd(), }), - ).not.toThrow(); + ).toBeUndefined(); expect(warn).toHaveBeenCalledWith( "tsdown: could not prune bundled plugin source node_modules: Error: locked", From 472a7a6abd000c96d268227778f6af5810ec6113 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:25:52 +0100 Subject: [PATCH 193/806] test: clarify gateway hook resilience assertions --- src/auto-reply/reply/inbound-meta.test.ts | 8 -------- src/gateway/credentials.test.ts | 2 +- src/gateway/server.talk-config.test.ts | 2 +- src/hooks/message-hooks.test.ts | 14 ++++++-------- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 9f123ea81fc..22e43fad321 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -412,14 +412,6 @@ describe("buildInboundUserContextPrefix", () => { }); it("omits invalid timestamps instead of throwing", () => { - expect(() => - buildInboundUserContextPrefix({ - ChatType: "group", - MessageSid: "msg-with-bad-ts", - Timestamp: 1e20, - } as TemplateContext), - ).not.toThrow(); - const text = buildInboundUserContextPrefix({ ChatType: "group", MessageSid: "msg-with-bad-ts", diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index a64026f5bd0..b387ee4e9b6 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -488,7 +488,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.auth.token"); }); - it("does not throw for unresolved remote token ref when password is available", () => { + it("uses remote password when remote token ref is unresolved", () => { const resolved = resolveGatewayCredentialsFromConfig({ cfg: { gateway: { diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 7cc480f7613..efcc08c8a40 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -325,7 +325,7 @@ describe("gateway talk.config", () => { }); }); - it("does not throw when SecretRef apiKey flows through a strict provider resolver", async () => { + it("redacts SecretRef apiKey after strict provider resolver accepts it", async () => { // Regression for #72496: ElevenLabs/OpenAI speech providers call the strict // normalizeResolvedSecretInputString helper inside resolveTalkConfig. The // discovery path used to hand them the raw source config (with the SecretRef diff --git a/src/hooks/message-hooks.test.ts b/src/hooks/message-hooks.test.ts index 29a7d7da6a4..6a1e35f1edd 100644 --- a/src/hooks/message-hooks.test.ts +++ b/src/hooks/message-hooks.test.ts @@ -198,11 +198,9 @@ describe("message hooks", () => { }); registerInternalHook("message:received", badHandler); - await expect( - triggerInternalHook( - createInternalHookEvent("message", "received", "s1", { content: "test" }), - ), - ).resolves.not.toThrow(); + await triggerInternalHook( + createInternalHookEvent("message", "received", "s1", { content: "test" }), + ); expect(badHandler).toHaveBeenCalledOnce(); }); @@ -228,9 +226,9 @@ describe("message hooks", () => { }); registerInternalHook("message:sent", asyncFailHandler); - await expect( - triggerInternalHook(createInternalHookEvent("message", "sent", "s1", { content: "reply" })), - ).resolves.not.toThrow(); + await triggerInternalHook( + createInternalHookEvent("message", "sent", "s1", { content: "reply" }), + ); expect(asyncFailHandler).toHaveBeenCalledOnce(); }); }); From aaca2342f816ba73bd962eef7ad2254a418ad5fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:28:51 +0100 Subject: [PATCH 194/806] test: clarify oc-path sentinel assertions --- .../scenarios/sentinel-cross-kind.test.ts | 111 ++++++-------- .../tests/scenarios/sentinel-guard.test.ts | 138 ++++++++---------- src/oc-path/tests/sentinel.test.ts | 36 ++--- 3 files changed, 127 insertions(+), 158 deletions(-) diff --git a/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts index 5c247efbbd5..18b0d9cfb5b 100644 --- a/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts +++ b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts @@ -10,22 +10,19 @@ * pre-existing-byte detection (e.g., LKG fingerprint verification) * opt in via `acceptPreExistingSentinel: false`. */ -import { describe, expect, it } from 'vitest'; -import { setJsoncOcPath } from '../../jsonc/edit.js'; -import { emitMd } from '../../emit.js'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { emitJsonl } from '../../jsonl/emit.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, -} from '../../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { setJsoncOcPath } from "../../jsonc/edit.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { emitJsonl } from "../../jsonl/emit.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js"; -describe('wave-21 sentinel guard cross-kind', () => { - it('S-01 jsonc round-trip echoes safely when raw contains pre-existing sentinel', () => { +describe("wave-21 sentinel guard cross-kind", () => { + it("S-01 jsonc round-trip echoes safely when raw contains pre-existing sentinel", () => { // Pre-existing sentinel bytes are trusted — see emit-policy comment // in jsonc/emit.ts. The strict mode below is the opt-in path for // callers who want LKG-style fingerprint verification. @@ -34,91 +31,81 @@ describe('wave-21 sentinel guard cross-kind', () => { expect(emitJsonc(ast)).toBe(raw); // Strict mode still rejects pre-existing sentinel for callers who // explicitly opt in. - expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-02 jsonl round-trip echoes safely; strict mode rejects', () => { + it("S-02 jsonl round-trip echoes safely; strict mode rejects", () => { const raw = `{"x":"${REDACTED_SENTINEL}"}\n`; const ast = parseJsonl(raw).ast; expect(emitJsonl(ast)).toBe(raw); - expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-03 md round-trip echoes safely; strict mode rejects', () => { + it("S-03 md round-trip echoes safely; strict mode rejects", () => { const raw = `## Body\n\n- ${REDACTED_SENTINEL}\n`; const ast = parseMd(raw).ast; expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-04 jsonc render mode walks every leaf for sentinel', () => { + it("S-04 jsonc render mode walks every leaf for sentinel", () => { const ast = parseJsonc('{ "x": "ok" }').ast; const tampered = { ...ast, root: { - kind: 'object' as const, + kind: "object" as const, entries: [ { - key: 'x', + key: "x", line: 1, - value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + value: { kind: "string" as const, value: REDACTED_SENTINEL }, }, ], }, }; - expect(() => emitJsonc(tampered, { mode: 'render' })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(tampered, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-05 jsonl render mode walks every value-line leaf', () => { + it("S-05 jsonl render mode walks every value-line leaf", () => { const ast = parseJsonl('{"a":"ok"}\n').ast; const tampered = { ...ast, lines: [ { - kind: 'value' as const, + kind: "value" as const, line: 1, raw: '{"a":"ok"}', value: { - kind: 'object' as const, + kind: "object" as const, entries: [ { - key: 'a', + key: "a", line: 1, - value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + value: { kind: "string" as const, value: REDACTED_SENTINEL }, }, ], }, }, ], }; - expect(() => emitJsonl(tampered, { mode: 'render' })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(tampered, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-06 setJsoncOcPath itself throws when the new value contains the sentinel', () => { + it("S-06 setJsoncOcPath itself throws when the new value contains the sentinel", () => { // The substrate guard fires at write-time: setJsoncOcPath rebuilds // raw via render mode emit, which scans every leaf. Defense-in-depth // — even if a caller forgets to call emit afterward, the sentinel // can't make it into an in-memory AST that pretends to be valid. const ast = parseJsonc('{ "x": "ok" }').ast; expect(() => - setJsoncOcPath(ast, parseOcPath('oc://config/x'), { - kind: 'string', + setJsoncOcPath(ast, parseOcPath("oc://config/x"), { + kind: "string", value: REDACTED_SENTINEL, }), ).toThrow(OcEmitSentinelError); }); - it('S-07 sentinel embedded in deep nesting — render mode catches the leaf', () => { + it("S-07 sentinel embedded in deep nesting — render mode catches the leaf", () => { // Round-trip echoes the pre-existing bytes (the workspace contract: // a parsed file containing the sentinel as data is not "writing" it // on emit). Render mode walks every leaf and rejects this caller- @@ -126,52 +113,48 @@ describe('wave-21 sentinel guard cross-kind', () => { const raw = JSON.stringify({ a: { b: { c: REDACTED_SENTINEL } } }); const ast = parseJsonc(raw).ast; expect(emitJsonc(ast)).toBe(raw); // round-trip echo - expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-08 sentinel inside an array element triggers guard in render mode', () => { - const raw = JSON.stringify({ arr: ['ok', REDACTED_SENTINEL, 'ok'] }); + it("S-08 sentinel inside an array element triggers guard in render mode", () => { + const raw = JSON.stringify({ arr: ["ok", REDACTED_SENTINEL, "ok"] }); const ast = parseJsonc(raw).ast; - expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-09 sentinel as object key in raw — strict mode catches it', () => { + it("S-09 sentinel as object key in raw — strict mode catches it", () => { const raw = `{ "${REDACTED_SENTINEL}": 1 }`; const ast = parseJsonc(raw).ast; expect(emitJsonc(ast)).toBe(raw); // default-mode echo - expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-10 sentinel in jsonl malformed line — strict mode catches it', () => { + it("S-10 sentinel in jsonl malformed line — strict mode catches it", () => { const raw = `${REDACTED_SENTINEL}\n`; const ast = parseJsonl(raw).ast; expect(emitJsonl(ast)).toBe(raw); // round-trip echoes verbatim - expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-11 partial sentinel substring does NOT trigger guard', () => { + it("S-11 partial sentinel substring does NOT trigger guard", () => { const raw = '{ "x": "OPENCLAW_REDACTED" }'; const ast = parseJsonc(raw).ast; - expect(() => emitJsonc(ast)).not.toThrow(); + expect(emitJsonc(ast)).toBe(raw); }); - it('S-12 sentinel guard error message includes the OcPath context (render mode)', () => { + it("S-12 sentinel guard error message includes the OcPath context (render mode)", () => { // Render mode is the path that actually rejects caller-injected // sentinel — round-trip just echoes, so the error context surfaces // when render walks the offending leaf and constructs the path. const raw = `{ "secret": "${REDACTED_SENTINEL}" }`; const ast = parseJsonc(raw).ast; try { - emitJsonc(ast, { mode: 'render', fileNameForGuard: 'config' }); - expect.fail('should have thrown'); + emitJsonc(ast, { mode: "render", fileNameForGuard: "config" }); + expect.fail("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(OcEmitSentinelError); - expect(String(e)).toContain('oc://'); - expect(String(e)).toContain('config'); + expect(String(e)).toContain("oc://"); + expect(String(e)).toContain("config"); } }); }); diff --git a/src/oc-path/tests/scenarios/sentinel-guard.test.ts b/src/oc-path/tests/scenarios/sentinel-guard.test.ts index b0865574518..e637b5c05ac 100644 --- a/src/oc-path/tests/scenarios/sentinel-guard.test.ts +++ b/src/oc-path/tests/scenarios/sentinel-guard.test.ts @@ -5,104 +5,98 @@ * emitted bytes throws `OcEmitSentinelError`. Round-trip mode catches * sentinels in `raw`; render mode walks every leaf. */ -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { parseMd } from '../../parse.js'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, - guardSentinel, -} from '../../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { parseMd } from "../../parse.js"; +import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../../sentinel.js"; -describe('wave-09 sentinel-guard', () => { - it('S-01 sentinel constant matches the literal', () => { - expect(REDACTED_SENTINEL).toBe('__OPENCLAW_REDACTED__'); +describe("wave-09 sentinel-guard", () => { + it("S-01 sentinel constant matches the literal", () => { + expect(REDACTED_SENTINEL).toBe("__OPENCLAW_REDACTED__"); }); - it('S-02 guardSentinel passes normal strings', () => { - expect(() => guardSentinel('safe', 'oc://X.md')).not.toThrow(); + it("S-02 guardSentinel passes normal strings", () => { + expect(guardSentinel("safe", "oc://X.md")).toBeUndefined(); }); - it('S-03 guardSentinel passes non-string types', () => { - expect(() => guardSentinel(42, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel(null, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel(undefined, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel({}, 'oc://X.md')).not.toThrow(); + it("S-03 guardSentinel passes non-string types", () => { + expect(guardSentinel(42, "oc://X.md")).toBeUndefined(); + expect(guardSentinel(null, "oc://X.md")).toBeUndefined(); + expect(guardSentinel(undefined, "oc://X.md")).toBeUndefined(); + expect(guardSentinel({}, "oc://X.md")).toBeUndefined(); }); - it('S-04 guardSentinel throws on exact match', () => { - expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://X.md')).toThrow(OcEmitSentinelError); + it("S-04 guardSentinel throws on exact match", () => { + expect(() => guardSentinel(REDACTED_SENTINEL, "oc://X.md")).toThrow(OcEmitSentinelError); }); - it('S-05 guardSentinel throws on substring matches (sentinel embedded in larger string)', () => { + it("S-05 guardSentinel throws on substring matches (sentinel embedded in larger string)", () => { // Substring scan — the sentinel anywhere in the value is a leak, // not just exact equality. A hostile caller smuggling // `prefix__OPENCLAW_REDACTED__suffix` would have bypassed the old // equality check; substring scan closes the gap. - expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, 'oc://X.md')).toThrow( + expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, "oc://X.md")).toThrow( OcEmitSentinelError, ); }); - it('S-06 error attaches the OcPath context', () => { + it("S-06 error attaches the OcPath context", () => { try { - guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); - expect.fail('should have thrown'); + guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token"); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcEmitSentinelError); const e = err as OcEmitSentinelError; - expect(e.path).toBe('oc://config/plugins.entries.foo.token'); - expect(e.code).toBe('OC_EMIT_SENTINEL'); + expect(e.path).toBe("oc://config/plugins.entries.foo.token"); + expect(e.code).toBe("OC_EMIT_SENTINEL"); } }); - it('S-07 round-trip echoes pre-existing sentinel; strict mode rejects', () => { - const raw = '## Section\n\n- token: __OPENCLAW_REDACTED__\n'; + it("S-07 round-trip echoes pre-existing sentinel; strict mode rejects", () => { + const raw = "## Section\n\n- token: __OPENCLAW_REDACTED__\n"; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-08 round-trip emit allows sentinel-free content', () => { - const raw = '## Section\n\n- token: redacted-but-not-sentinel\n'; + it("S-08 round-trip emit allows sentinel-free content", () => { + const raw = "## Section\n\n- token: redacted-but-not-sentinel\n"; const { ast } = parseMd(raw); - expect(() => emitMd(ast)).not.toThrow(); + expect(emitMd(ast)).toBe(raw); }); - it('S-09 render mode catches sentinel in frontmatter', () => { + it("S-09 render mode catches sentinel in frontmatter", () => { const ast = { kind: "md" as const, - raw: '', - frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], - preamble: '', + raw: "", + frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }], + preamble: "", blocks: [], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-10 render mode catches sentinel in preamble', () => { + it("S-10 render mode catches sentinel in preamble", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], preamble: REDACTED_SENTINEL, blocks: [], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-11 render mode catches sentinel in block bodyText', () => { + it("S-11 render mode catches sentinel in block bodyText", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], - preamble: '', + preamble: "", blocks: [ { - heading: 'Sec', - slug: 'sec', + heading: "Sec", + slug: "sec", line: 1, bodyText: REDACTED_SENTINEL, items: [], @@ -111,27 +105,27 @@ describe('wave-09 sentinel-guard', () => { }, ], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-12 render mode catches sentinel in item kv.value', () => { + it("S-12 render mode catches sentinel in item kv.value", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], - preamble: '', + preamble: "", blocks: [ { - heading: 'S', - slug: 's', + heading: "S", + slug: "s", line: 1, - bodyText: '- t: x', + bodyText: "- t: x", items: [ { - text: 't: x', - slug: 't', + text: "t: x", + slug: "t", line: 2, - kv: { key: 't', value: REDACTED_SENTINEL }, + kv: { key: "t", value: REDACTED_SENTINEL }, }, ], tables: [], @@ -139,42 +133,38 @@ describe('wave-09 sentinel-guard', () => { }, ], }; - expect(() => emitMd(ast, { mode: 'render', fileNameForGuard: 'AGENTS.md' })).toThrow( + expect(() => emitMd(ast, { mode: "render", fileNameForGuard: "AGENTS.md" })).toThrow( OcEmitSentinelError, ); }); - it('S-13 sentinel-as-substring in raw — strict mode catches it', () => { + it("S-13 sentinel-as-substring in raw — strict mode catches it", () => { const raw = `Some prose ${REDACTED_SENTINEL} more prose.\n`; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-14 multiple sentinel occurrences in raw — strict mode catches them', () => { + it("S-14 multiple sentinel occurrences in raw — strict mode catches them", () => { const raw = `## A\n${REDACTED_SENTINEL}\n${REDACTED_SENTINEL}\n`; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-15 fileNameForGuard appears in the error path', () => { + it("S-15 fileNameForGuard appears in the error path", () => { const ast = { kind: "md" as const, - raw: '', - frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], - preamble: '', + raw: "", + frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }], + preamble: "", blocks: [], }; try { - emitMd(ast, { mode: 'render', fileNameForGuard: 'config' }); - expect.fail('should have thrown'); + emitMd(ast, { mode: "render", fileNameForGuard: "config" }); + expect.fail("should have thrown"); } catch (err) { - expect((err as OcEmitSentinelError).path).toContain('config'); + expect((err as OcEmitSentinelError).path).toContain("config"); } }); }); diff --git a/src/oc-path/tests/sentinel.test.ts b/src/oc-path/tests/sentinel.test.ts index 980527ac1fe..229d1dac6f6 100644 --- a/src/oc-path/tests/sentinel.test.ts +++ b/src/oc-path/tests/sentinel.test.ts @@ -1,36 +1,32 @@ -import { describe, expect, it } from 'vitest'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, - guardSentinel, -} from '../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../sentinel.js"; -describe('guardSentinel', () => { - it('passes through normal strings', () => { - expect(() => guardSentinel('normal value', 'oc://SOUL.md')).not.toThrow(); +describe("guardSentinel", () => { + it("passes through normal strings", () => { + expect(guardSentinel("normal value", "oc://SOUL.md")).toBeUndefined(); }); - it('passes through non-string values', () => { - expect(() => guardSentinel(42, 'oc://SOUL.md')).not.toThrow(); - expect(() => guardSentinel(null, 'oc://SOUL.md')).not.toThrow(); - expect(() => guardSentinel(undefined, 'oc://SOUL.md')).not.toThrow(); + it("passes through non-string values", () => { + expect(guardSentinel(42, "oc://SOUL.md")).toBeUndefined(); + expect(guardSentinel(null, "oc://SOUL.md")).toBeUndefined(); + expect(guardSentinel(undefined, "oc://SOUL.md")).toBeUndefined(); }); - it('throws on the sentinel literal', () => { - expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://SOUL.md/[fm]/token')).toThrow( + it("throws on the sentinel literal", () => { + expect(() => guardSentinel(REDACTED_SENTINEL, "oc://SOUL.md/[fm]/token")).toThrow( OcEmitSentinelError, ); }); - it('attaches the OcPath in the error', () => { + it("attaches the OcPath in the error", () => { try { - guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); - expect.fail('should have thrown'); + guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token"); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcEmitSentinelError); const e = err as OcEmitSentinelError; - expect(e.path).toBe('oc://config/plugins.entries.foo.token'); - expect(e.code).toBe('OC_EMIT_SENTINEL'); + expect(e.path).toBe("oc://config/plugins.entries.foo.token"); + expect(e.code).toBe("OC_EMIT_SENTINEL"); } }); }); From 0905389ccf1a12d09bd6865777c13a025254786f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:30:20 +0100 Subject: [PATCH 195/806] test: clarify oc-path malformed assertions --- .../tests/scenarios/malformed-input.test.ts | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/oc-path/tests/scenarios/malformed-input.test.ts b/src/oc-path/tests/scenarios/malformed-input.test.ts index 9765df08a42..cfe179034ad 100644 --- a/src/oc-path/tests/scenarios/malformed-input.test.ts +++ b/src/oc-path/tests/scenarios/malformed-input.test.ts @@ -23,7 +23,10 @@ describe("wave-11 malformed-input", () => { }); it("M-03 only `---` (single fence, no content)", () => { - expect(() => parseMd("---\n")).not.toThrow(); + const { ast, diagnostics } = parseMd("---\n"); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); + expect(ast.frontmatter).toEqual([]); + expect(ast.preamble).toBe("---\n"); }); it("M-04 only `---\\n---`", () => { @@ -33,7 +36,9 @@ describe("wave-11 malformed-input", () => { it("M-05 binary-ish bytes (non-ASCII control chars)", () => { const raw = "## H\n\x00\x01\x02\n"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(ast.blocks[0]?.bodyText).toBe("\x00\x01\x02\n"); }); it("M-06 very long single line (10k chars)", () => { @@ -60,17 +65,20 @@ describe("wave-11 malformed-input", () => { it("M-09 unclosed code fence", () => { const raw = "## H\n```\nbody\n"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\nbody\n"); }); it("M-10 mismatched fence (open with ``` close with ~~~)", () => { const raw = "## H\n```\nbody\n~~~\n"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\nbody\n~~~\n"); }); it("M-11 nested fences (treated linearly, not nested)", () => { const raw = "## H\n```\n```\nstill-in-second\n```\n"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\n```\nstill-in-second\n```\n"); }); it("M-12 empty file", () => { @@ -94,7 +102,8 @@ describe("wave-11 malformed-input", () => { it("M-15 file with mixed indentation extremes (tabs, spaces, mixed)", () => { const raw = "## H\n\t- tabbed\n - spaced\n\t - mixed\n"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("\t- tabbed\n - spaced\n\t - mixed\n"); }); it("M-16 frontmatter with frontmatter-shaped content inside (---)", () => { @@ -119,7 +128,10 @@ describe("wave-11 malformed-input", () => { }); it("M-19 file with just whitespace", () => { - expect(() => parseMd(" \n\t\n \n")).not.toThrow(); + const { ast, diagnostics } = parseMd(" \n\t\n \n"); + expect(diagnostics).toEqual([]); + expect(ast.preamble).toBe(" \n\t\n \n"); + expect(ast.blocks).toEqual([]); }); it("M-20 file with only BOM", () => { @@ -129,16 +141,18 @@ describe("wave-11 malformed-input", () => { it("M-21 file mixing BOM + frontmatter + body + sections", () => { const raw = "---\nk: v\n---\n\nbody\n## Section\n- item\n"; - expect(() => parseMd(raw)).not.toThrow(); const { ast } = parseMd(raw); expect(ast.frontmatter[0]?.value).toBe("v"); expect(ast.blocks[0]?.heading).toBe("Section"); + expect(ast.blocks[0]?.items[0]?.text).toBe("item"); }); it("M-22 line endings: legacy CR-only (Mac classic)", () => { // Our regex /\r?\n/ doesn't split on CR-only. Treats whole as one line. const raw = "line1\rline2\r## Heading\r"; - expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.preamble).toBe(raw); + expect(ast.blocks).toEqual([]); }); it("M-23 100 KB file", () => { @@ -150,6 +164,9 @@ describe("wave-11 malformed-input", () => { } } const raw = lines.join("\n"); - expect(() => parseMd(raw)).not.toThrow(); + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(ast.blocks).toHaveLength(1000); + expect(ast.blocks[999]?.items).toHaveLength(5); }); }); From 4baf472285e0da70fd28a54e6d08ca5ffca5ffde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:31:31 +0100 Subject: [PATCH 196/806] test: clarify oc-path pitfalls assertions --- src/oc-path/tests/scenarios/pitfalls.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts index a70e9bb1ed5..0952c266e76 100644 --- a/src/oc-path/tests/scenarios/pitfalls.test.ts +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -55,7 +55,9 @@ describe("wave-23 pitfalls — encoding", () => { it("P-003 allows whitespace inside predicate values (content)", () => { // Spaces inside a predicate value are legitimate — they're filtering // against actual content. - expect(() => parseOcPath("oc://X/[name=hello world]")).not.toThrow(); + const path = parseOcPath("oc://X/[name=hello world]"); + expect(path.file).toBe("X"); + expect(path.section).toBe("[name=hello world]"); }); it("P-004 / P-011 rejects control characters and null bytes", () => { @@ -396,7 +398,7 @@ describe("wave-23 pitfalls — performance & limits", () => { it("P-032 path at the cap parses cleanly", () => { const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length); - expect(() => parseOcPath(justUnder)).not.toThrow(); + expect(parseOcPath(justUnder).section).toBe("a".repeat(MAX_PATH_LENGTH - "oc://X/".length)); }); it("P-032 formatOcPath enforces the same cap on output", () => { @@ -477,7 +479,9 @@ describe("wave-23 pitfalls — reserved characters", () => { // the first `?` as the query split. expect(parseOcPath("oc://X/foo?session=s").section).toBe("foo"); // Empty key after `?` (no `=`): query parser silently ignores. - expect(() => parseOcPath("oc://X/foo?")).not.toThrow(); + const path = parseOcPath("oc://X/foo?"); + expect(path.section).toBe("foo"); + expect(path.session).toBeUndefined(); }); it("P-040 negative-index magnitude is bounded", () => { From 20037285fb1488065ae8dcea5bb1e0d7dcb8d446 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:33:19 +0100 Subject: [PATCH 197/806] test: clarify oc-path resolver assertions --- .../scenarios/cross-kind-properties.test.ts | 155 +++++++++--------- .../scenarios/jsonc-resolver-edges.test.ts | 140 ++++++++-------- .../scenarios/jsonl-resolver-edges.test.ts | 136 +++++++-------- 3 files changed, 224 insertions(+), 207 deletions(-) diff --git a/src/oc-path/tests/scenarios/cross-kind-properties.test.ts b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts index e2622f4d6c0..4547d0e7dc8 100644 --- a/src/oc-path/tests/scenarios/cross-kind-properties.test.ts +++ b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts @@ -11,82 +11,89 @@ * 5. parse → emit → parse is fixpoint * 6. hostile inputs do not throw at parse time */ -import { describe, expect, it } from 'vitest'; -import { inferKind } from '../../dispatch.js'; -import { emitMd } from '../../emit.js'; -import { setMdOcPath } from '../../edit.js'; -import { resolveMdOcPath } from '../../resolve.js'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { setJsoncOcPath } from '../../jsonc/edit.js'; -import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { emitJsonl } from '../../jsonl/emit.js'; -import { setJsonlOcPath } from '../../jsonl/edit.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { inferKind } from "../../dispatch.js"; +import { setMdOcPath } from "../../edit.js"; +import { emitMd } from "../../emit.js"; +import { setJsoncOcPath } from "../../jsonc/edit.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { resolveJsoncOcPath } from "../../jsonc/resolve.js"; +import { setJsonlOcPath } from "../../jsonl/edit.js"; +import { emitJsonl } from "../../jsonl/emit.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath } from "../../resolve.js"; -describe('wave-22 cross-kind property invariants', () => { - const mdRaw = '---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n'; +describe("wave-22 cross-kind property invariants", () => { + const mdRaw = "---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n"; const jsoncRaw = '// h\n{ "k": 1, "n": [1,2,3] }\n'; const jsonlRaw = '{"a":1}\n\nbroken\n{"b":2}\n'; - it('P-01 round-trip parse → emit is byte-stable across all kinds', () => { + it("P-01 round-trip parse → emit is byte-stable across all kinds", () => { expect(emitMd(parseMd(mdRaw).ast)).toBe(mdRaw); expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(jsoncRaw); expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(jsonlRaw); }); - it('P-02 resolve is non-mutating across all kinds', () => { + it("P-02 resolve is non-mutating across all kinds", () => { const md = parseMd(mdRaw).ast; let before = JSON.stringify(md); - resolveMdOcPath(md, parseOcPath('oc://X/[frontmatter]/name')); - resolveMdOcPath(md, parseOcPath('oc://X/boundaries')); + resolveMdOcPath(md, parseOcPath("oc://X/[frontmatter]/name")); + resolveMdOcPath(md, parseOcPath("oc://X/boundaries")); expect(JSON.stringify(md)).toBe(before); const jsonc = parseJsonc(jsoncRaw).ast; before = JSON.stringify(jsonc); - resolveJsoncOcPath(jsonc, parseOcPath('oc://X/k')); - resolveJsoncOcPath(jsonc, parseOcPath('oc://X/n.0')); + resolveJsoncOcPath(jsonc, parseOcPath("oc://X/k")); + resolveJsoncOcPath(jsonc, parseOcPath("oc://X/n.0")); expect(JSON.stringify(jsonc)).toBe(before); const jsonl = parseJsonl(jsonlRaw).ast; before = JSON.stringify(jsonl); - resolveJsonlOcPath(jsonl, parseOcPath('oc://X/L1')); - resolveJsonlOcPath(jsonl, parseOcPath('oc://X/$last')); + resolveJsonlOcPath(jsonl, parseOcPath("oc://X/L1")); + resolveJsonlOcPath(jsonl, parseOcPath("oc://X/$last")); expect(JSON.stringify(jsonl)).toBe(before); }); - it('P-03 unresolvable set never throws across all kinds', () => { - const ocPath = parseOcPath('oc://X/totally.missing.path'); - expect(() => - setMdOcPath(parseMd(mdRaw).ast, ocPath, 'x'), - ).not.toThrow(); - expect(() => + it("P-03 unresolvable set never throws across all kinds", () => { + const ocPath = parseOcPath("oc://X/totally.missing.path"); + expect(setMdOcPath(parseMd(mdRaw).ast, ocPath, "x")).toEqual({ + ok: false, + reason: "not-writable", + }); + expect( setJsoncOcPath(parseJsonc(jsoncRaw).ast, ocPath, { - kind: 'string', - value: 'x', + kind: "string", + value: "x", }), - ).not.toThrow(); - expect(() => + ).toEqual({ + ok: false, + reason: "unresolved", + }); + expect( setJsonlOcPath(parseJsonl(jsonlRaw).ast, ocPath, { - kind: 'string', - value: 'x', + kind: "string", + value: "x", }), - ).not.toThrow(); + ).toEqual({ + ok: false, + reason: "unresolved", + }); }); - it('P-04 inferKind aligns with the parser actually used', () => { - expect(inferKind('AGENTS.md')).toBe('md'); - expect(inferKind('SOUL.md')).toBe('md'); - expect(inferKind('config.jsonc')).toBe('jsonc'); - expect(inferKind('plugins.json')).toBe('jsonc'); - expect(inferKind('events.jsonl')).toBe('jsonl'); - expect(inferKind('audit.ndjson')).toBe('jsonl'); + it("P-04 inferKind aligns with the parser actually used", () => { + expect(inferKind("AGENTS.md")).toBe("md"); + expect(inferKind("SOUL.md")).toBe("md"); + expect(inferKind("config.jsonc")).toBe("jsonc"); + expect(inferKind("plugins.json")).toBe("jsonc"); + expect(inferKind("events.jsonl")).toBe("jsonl"); + expect(inferKind("audit.ndjson")).toBe("jsonl"); }); - it('P-05 parse → emit → parse is fixpoint across all kinds', () => { + it("P-05 parse → emit → parse is fixpoint across all kinds", () => { const md1 = emitMd(parseMd(mdRaw).ast); const md2 = emitMd(parseMd(md1).ast); expect(md1).toBe(md2); @@ -100,54 +107,52 @@ describe('wave-22 cross-kind property invariants', () => { expect(jl1).toBe(jl2); }); - it('P-06 hostile inputs do not throw at parse time across all kinds', () => { + it("P-06 hostile inputs do not throw at parse time across all kinds", () => { const hostile = [ - '\x00\x01\x02 binary garbage', + "\x00\x01\x02 binary garbage", '{ "unclosed":', - '## heading without anything', - '\n\n\n\n\n', + "## heading without anything", + "\n\n\n\n\n", ]; for (const raw of hostile) { - expect(() => parseMd(raw)).not.toThrow(); - expect(() => parseJsonc(raw)).not.toThrow(); - expect(() => parseJsonl(raw)).not.toThrow(); + expect(parseMd(raw).ast.raw).toBe(raw); + expect( + parseJsonc(raw).diagnostics.every((diagnostic) => diagnostic.severity === "error"), + ).toBe(true); + expect(parseJsonl(raw).ast.raw).toBe(raw); } }); - it('P-07 resolver returns null for paths past valid kinds (no throw)', () => { - const overlong = parseOcPath('oc://X/a/b/c.d.e.f.g.h'); - expect(() => resolveMdOcPath(parseMd(mdRaw).ast, overlong)).not.toThrow(); - expect(() => resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).not.toThrow(); - expect(() => resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).not.toThrow(); + it("P-07 resolver returns null for paths past valid kinds", () => { + const overlong = parseOcPath("oc://X/a/b/c.d.e.f.g.h"); + expect(resolveMdOcPath(parseMd(mdRaw).ast, overlong)).toBeNull(); + expect(resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).toBeNull(); + expect(resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).toBeNull(); }); - it('P-08 set-then-resolve produces the value just written (jsonc)', () => { + it("P-08 set-then-resolve produces the value just written (jsonc)", () => { const ast = parseJsonc('{ "k": 1 }').ast; - const r = setJsoncOcPath(ast, parseOcPath('oc://X/k'), { - kind: 'number', + const r = setJsoncOcPath(ast, parseOcPath("oc://X/k"), { + kind: "number", value: 42, }); if (r.ok) { - const m = resolveJsoncOcPath(r.ast, parseOcPath('oc://X/k')); - if (m?.kind === 'object-entry') { - expect(m.node.value).toEqual({ kind: 'number', value: 42 }); + const m = resolveJsoncOcPath(r.ast, parseOcPath("oc://X/k")); + if (m?.kind === "object-entry") { + expect(m.node.value).toEqual({ kind: "number", value: 42 }); } } }); - it('P-09 verbs are deterministic — same input twice produces same output', () => { + it("P-09 verbs are deterministic — same input twice produces same output", () => { expect(emitMd(parseMd(mdRaw).ast)).toBe(emitMd(parseMd(mdRaw).ast)); - expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe( - emitJsonc(parseJsonc(jsoncRaw).ast), - ); - expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe( - emitJsonl(parseJsonl(jsonlRaw).ast), - ); + expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(emitJsonc(parseJsonc(jsoncRaw).ast)); + expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(emitJsonl(parseJsonl(jsonlRaw).ast)); }); - it('P-10 inferKind returns null for unknown extensions', () => { - expect(inferKind('binary.bin')).toBeNull(); - expect(inferKind('no-ext')).toBeNull(); - expect(inferKind('archive.tar.gz')).toBeNull(); + it("P-10 inferKind returns null for unknown extensions", () => { + expect(inferKind("binary.bin")).toBeNull(); + expect(inferKind("no-ext")).toBeNull(); + expect(inferKind("archive.tar.gz")).toBeNull(); }); }); diff --git a/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts index 06001ddcb98..d9cc8cece70 100644 --- a/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts @@ -5,128 +5,136 @@ * with mixed dotted / segment paths, returns null on any unresolvable * walk, and never throws on hostile inputs. */ -import { describe, expect, it } from 'vitest'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; +import { describe, expect, it } from "vitest"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { resolveJsoncOcPath } from "../../jsonc/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; function rs(raw: string, ocPath: string) { return resolveJsoncOcPath(parseJsonc(raw).ast, parseOcPath(ocPath)); } -describe('wave-17 jsonc resolver edges', () => { - it('JR-01 root resolves on empty object', () => { - expect(rs('{}', 'oc://config')?.kind).toBe('root'); +describe("wave-17 jsonc resolver edges", () => { + it("JR-01 root resolves on empty object", () => { + expect(rs("{}", "oc://config")?.kind).toBe("root"); }); - it('JR-02 root resolves on scalar root', () => { - expect(rs('42', 'oc://config')?.kind).toBe('root'); + it("JR-02 root resolves on scalar root", () => { + expect(rs("42", "oc://config")?.kind).toBe("root"); }); - it('JR-03 root resolves on array root', () => { - expect(rs('[1,2,3]', 'oc://config')?.kind).toBe('root'); + it("JR-03 root resolves on array root", () => { + expect(rs("[1,2,3]", "oc://config")?.kind).toBe("root"); }); - it('JR-04 deep dotted descent within section', () => { - const m = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); - expect(m?.kind).toBe('object-entry'); + it("JR-04 deep dotted descent within section", () => { + const m = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-05 missing intermediate key returns null', () => { - expect(rs('{"a":{"b":1}}', 'oc://config/a.x.b')).toBeNull(); + it("JR-05 missing intermediate key returns null", () => { + expect(rs('{"a":{"b":1}}', "oc://config/a.x.b")).toBeNull(); }); - it('JR-06 numeric segment indexes into array', () => { - const m = rs('{"items":["a","b","c"]}', 'oc://config/items.1'); - expect(m?.kind).toBe('value'); - if (m?.kind === 'value') { - expect(m.node).toMatchObject({ kind: 'string', value: 'b' }); + it("JR-06 numeric segment indexes into array", () => { + const m = rs('{"items":["a","b","c"]}', "oc://config/items.1"); + expect(m?.kind).toBe("value"); + if (m?.kind === "value") { + expect(m.node).toMatchObject({ kind: "string", value: "b" }); } }); - it('JR-07 negative array index resolves to Nth-from-last', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.-1')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 2 } }); - expect(rs('{"x":[1,2]}', 'oc://config/x.-2')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 1 } }); - expect(rs('{"x":[1,2]}', 'oc://config/x.-5')).toBeNull(); + it("JR-07 negative array index resolves to Nth-from-last", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.-1")).toMatchObject({ + kind: "value", + node: { kind: "number", value: 2 }, + }); + expect(rs('{"x":[1,2]}', "oc://config/x.-2")).toMatchObject({ + kind: "value", + node: { kind: "number", value: 1 }, + }); + expect(rs('{"x":[1,2]}', "oc://config/x.-5")).toBeNull(); }); - it('JR-08 out-of-bounds array index returns null', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.99')).toBeNull(); + it("JR-08 out-of-bounds array index returns null", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.99")).toBeNull(); }); - it('JR-09 non-integer index returns null (no NaN coercion)', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.foo')).toBeNull(); + it("JR-09 non-integer index returns null (no NaN coercion)", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.foo")).toBeNull(); }); - it('JR-10 null AST root returns null on any path', () => { - expect(rs('', 'oc://config/x')).toBeNull(); + it("JR-10 null AST root returns null on any path", () => { + expect(rs("", "oc://config/x")).toBeNull(); }); - it('JR-11 descending past a primitive returns null', () => { - expect(rs('{"x":42}', 'oc://config/x.y')).toBeNull(); + it("JR-11 descending past a primitive returns null", () => { + expect(rs('{"x":42}', "oc://config/x.y")).toBeNull(); }); - it('JR-12 empty segment in dotted path throws OcPathError', () => { + it("JR-12 empty segment in dotted path throws OcPathError", () => { // v1 invariant: malformed paths fail loud at parse time, not silently null. - expect(() => rs('{"x":1}', 'oc://config/x..y')).toThrow(/Empty dotted sub-segment/); + expect(() => rs('{"x":1}', "oc://config/x..y")).toThrow(/Empty dotted sub-segment/); }); - it('JR-13 string value at leaf surfaces via object-entry shape', () => { - const m = rs('{"k":"v"}', 'oc://config/k'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') {expect(m.node.key).toBe('k');} + it("JR-13 string value at leaf surfaces via object-entry shape", () => { + const m = rs('{"k":"v"}', "oc://config/k"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.key).toBe("k"); + } }); - it('JR-14 boolean and null values resolve', () => { - const m1 = rs('{"k":true}', 'oc://config/k'); - expect(m1?.kind).toBe('object-entry'); - const m2 = rs('{"k":null}', 'oc://config/k'); - expect(m2?.kind).toBe('object-entry'); + it("JR-14 boolean and null values resolve", () => { + const m1 = rs('{"k":true}', "oc://config/k"); + expect(m1?.kind).toBe("object-entry"); + const m2 = rs('{"k":null}', "oc://config/k"); + expect(m2?.kind).toBe("object-entry"); }); - it('JR-15 mixed slash + dot segments resolve identically', () => { - const a = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); - const b = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b.c'); - const c = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b/c'); + it("JR-15 mixed slash + dot segments resolve identically", () => { + const a = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c"); + const b = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b.c"); + const c = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b/c"); expect(a?.kind).toBe(b?.kind); expect(b?.kind).toBe(c?.kind); }); - it('JR-16 keys with special characters resolve', () => { - const m = rs('{"a-b_c":{"x":1}}', 'oc://config/a-b_c.x'); - expect(m?.kind).toBe('object-entry'); + it("JR-16 keys with special characters resolve", () => { + const m = rs('{"a-b_c":{"x":1}}', "oc://config/a-b_c.x"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-17 unicode keys resolve', () => { - const m = rs('{"héllo":1}', 'oc://config/héllo'); - expect(m?.kind).toBe('object-entry'); + it("JR-17 unicode keys resolve", () => { + const m = rs('{"héllo":1}', "oc://config/héllo"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-18 large nested structure (depth 20) resolves to leaf', () => { + it("JR-18 large nested structure (depth 20) resolves to leaf", () => { let json = '"leaf"'; const segs: string[] = []; for (let i = 19; i >= 0; i--) { json = `{"k${i}":${json}}`; segs.unshift(`k${i}`); } - const m = rs(json, `oc://config/${segs.join('.')}`); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'leaf' }); + const m = rs(json, `oc://config/${segs.join(".")}`); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "leaf" }); } }); - it('JR-19 resolver is non-mutating across calls', () => { + it("JR-19 resolver is non-mutating across calls", () => { const { ast } = parseJsonc('{"x":{"y":1}}'); const before = JSON.stringify(ast); - rs('{"x":{"y":1}}', 'oc://config/x.y'); - rs('{"x":{"y":1}}', 'oc://config/x'); - rs('{"x":{"y":1}}', 'oc://config/missing'); + rs('{"x":{"y":1}}', "oc://config/x.y"); + rs('{"x":{"y":1}}', "oc://config/x"); + rs('{"x":{"y":1}}', "oc://config/missing"); expect(JSON.stringify(ast)).toBe(before); }); - it('JR-20 hostile input shapes do not throw', () => { - expect(() => rs('{garbage}', 'oc://config/x')).not.toThrow(); - expect(() => rs('{"a":', 'oc://config/a')).not.toThrow(); + it("JR-20 hostile input shapes do not throw", () => { + expect(rs("{garbage}", "oc://config/x")).toBeNull(); + expect(rs('{"a":', "oc://config/a")).toBeNull(); }); }); diff --git a/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts index edecb2cbb03..ef38bc8b51e 100644 --- a/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts @@ -5,121 +5,125 @@ * deterministically; missing addresses, blank-line targets, and * malformed-line targets all surface as null without throwing. */ -import { describe, expect, it } from 'vitest'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; +import { describe, expect, it } from "vitest"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; function rs(raw: string, ocPath: string) { return resolveJsonlOcPath(parseJsonl(raw).ast, parseOcPath(ocPath)); } -describe('wave-18 jsonl resolver edges', () => { - it('JLR-01 root resolves with no segments', () => { - expect(rs('{"a":1}\n', 'oc://log')?.kind).toBe('root'); +describe("wave-18 jsonl resolver edges", () => { + it("JLR-01 root resolves with no segments", () => { + expect(rs('{"a":1}\n', "oc://log")?.kind).toBe("root"); }); - it('JLR-02 L1 resolves to a value line', () => { - const m = rs('{"a":1}\n', 'oc://log/L1'); - expect(m?.kind).toBe('line'); + it("JLR-02 L1 resolves to a value line", () => { + const m = rs('{"a":1}\n', "oc://log/L1"); + expect(m?.kind).toBe("line"); }); - it('JLR-03 L99 unknown line returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/L99')).toBeNull(); + it("JLR-03 L99 unknown line returns null", () => { + expect(rs('{"a":1}\n', "oc://log/L99")).toBeNull(); }); - it('JLR-04 $last picks the most recent value line', () => { - const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 3 }); + it("JLR-04 $last picks the most recent value line", () => { + const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 3 }); } }); - it('JLR-05 $last skips trailing blank lines', () => { - const m = rs('{"a":1}\n\n\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + it("JLR-05 $last skips trailing blank lines", () => { + const m = rs('{"a":1}\n\n\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 1 }); } }); - it('JLR-06 $last skips trailing malformed lines', () => { - const m = rs('{"a":1}\nbroken\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); + it("JLR-06 $last skips trailing malformed lines", () => { + const m = rs('{"a":1}\nbroken\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); }); - it('JLR-07 $last on empty file returns null', () => { - expect(rs('', 'oc://log/$last/x')).toBeNull(); + it("JLR-07 $last on empty file returns null", () => { + expect(rs("", "oc://log/$last/x")).toBeNull(); }); - it('JLR-08 $last on all-blank file returns null', () => { - expect(rs('\n\n\n', 'oc://log/$last/x')).toBeNull(); + it("JLR-08 $last on all-blank file returns null", () => { + expect(rs("\n\n\n", "oc://log/$last/x")).toBeNull(); }); - it('JLR-09 $last on all-malformed file returns null', () => { - expect(rs('a\nb\nc\n', 'oc://log/$last/x')).toBeNull(); + it("JLR-09 $last on all-malformed file returns null", () => { + expect(rs("a\nb\nc\n", "oc://log/$last/x")).toBeNull(); }); - it('JLR-10 garbage line address returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/garbage')).toBeNull(); - expect(rs('{"a":1}\n', 'oc://log/L')).toBeNull(); - expect(rs('{"a":1}\n', 'oc://log/Labc')).toBeNull(); + it("JLR-10 garbage line address returns null", () => { + expect(rs('{"a":1}\n', "oc://log/garbage")).toBeNull(); + expect(rs('{"a":1}\n', "oc://log/L")).toBeNull(); + expect(rs('{"a":1}\n', "oc://log/Labc")).toBeNull(); }); - it('JLR-11 descent into a blank line returns null', () => { - expect(rs('{"a":1}\n\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + it("JLR-11 descent into a blank line returns null", () => { + expect(rs('{"a":1}\n\n{"b":2}\n', "oc://log/L2/anything")).toBeNull(); }); - it('JLR-12 descent into a malformed line returns null', () => { - expect(rs('{"a":1}\nbroken\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + it("JLR-12 descent into a malformed line returns null", () => { + expect(rs('{"a":1}\nbroken\n{"b":2}\n', "oc://log/L2/anything")).toBeNull(); }); - it('JLR-13 missing field on a value line returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/L1/missing')).toBeNull(); + it("JLR-13 missing field on a value line returns null", () => { + expect(rs('{"a":1}\n', "oc://log/L1/missing")).toBeNull(); }); - it('JLR-14 dotted descent through line value resolves', () => { - const m = rs('{"r":{"ok":true,"d":"x"}}\n', 'oc://log/L1/r.d'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'x' }); + it("JLR-14 dotted descent through line value resolves", () => { + const m = rs('{"r":{"ok":true,"d":"x"}}\n', "oc://log/L1/r.d"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "x" }); } }); - it('JLR-15 array index inside a line resolves', () => { - const m = rs('{"items":["a","b","c"]}\n', 'oc://log/L1/items.2'); - expect(m?.kind).toBe('value'); - if (m?.kind === 'value') { - expect(m.node).toMatchObject({ kind: 'string', value: 'c' }); + it("JLR-15 array index inside a line resolves", () => { + const m = rs('{"items":["a","b","c"]}\n', "oc://log/L1/items.2"); + expect(m?.kind).toBe("value"); + if (m?.kind === "value") { + expect(m.node).toMatchObject({ kind: "string", value: "c" }); } }); - it('JLR-16 line numbers are 1-indexed', () => { - const m = rs('{"a":1}\n{"a":2}\n', 'oc://log/L1/a'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + it("JLR-16 line numbers are 1-indexed", () => { + const m = rs('{"a":1}\n{"a":2}\n', "oc://log/L1/a"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 1 }); } }); - it('JLR-17 line numbers preserved across blank/malformed entries', () => { - const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', 'oc://log/L4/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 4 }); + it("JLR-17 line numbers preserved across blank/malformed entries", () => { + const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', "oc://log/L4/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 4 }); } }); - it('JLR-18 resolver is non-mutating', () => { + it("JLR-18 resolver is non-mutating", () => { const { ast } = parseJsonl('{"a":1}\n{"b":2}\n'); const before = JSON.stringify(ast); - rs('{"a":1}\n{"b":2}\n', 'oc://log/L1'); - rs('{"a":1}\n{"b":2}\n', 'oc://log/$last'); + rs('{"a":1}\n{"b":2}\n', "oc://log/L1"); + rs('{"a":1}\n{"b":2}\n', "oc://log/$last"); expect(JSON.stringify(ast)).toBe(before); }); - it('JLR-19 hostile inputs do not throw', () => { - expect(() => rs('not json\n', 'oc://log/L1')).not.toThrow(); - expect(() => rs('', 'oc://log/$last')).not.toThrow(); + it("JLR-19 hostile inputs do not throw", () => { + const malformed = rs("not json\n", "oc://log/L1"); + expect(malformed?.kind).toBe("line"); + if (malformed?.kind === "line") { + expect(malformed.node.kind).toBe("malformed"); + } + expect(rs("", "oc://log/$last")).toBeNull(); }); }); From 3708aad903605b1293d41af80bcec581db4c1a2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:34:40 +0100 Subject: [PATCH 198/806] test: clarify infra accepted assertions --- src/infra/git-commit.test.ts | 3 --- src/infra/run-node.test.ts | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 6d28fa597fd..e5876447a79 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -183,9 +183,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - expect(() => - resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), - ).not.toThrow(); expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} })).toBe( repoHead, ); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index ff9dbc95ff0..53c28b3f624 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1656,8 +1656,8 @@ describe("run-node script", () => { fakeProcess.emit("SIGINT"); expect(fsSync.existsSync(lockDir)).toBe(false); - // Normal release after signal must be a no-op, not throw. - expect(() => release()).not.toThrow(); + // Normal release after signal must be a no-op. + expect(release()).toBeUndefined(); expect(fakeProcess.listenerCount("SIGINT")).toBe(0); expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); expect(fakeProcess.listenerCount("exit")).toBe(0); @@ -1674,7 +1674,7 @@ describe("run-node script", () => { fakeProcess.emit("SIGTERM"); expect(fsSync.existsSync(lockDir)).toBe(false); - expect(() => release()).not.toThrow(); + expect(release()).toBeUndefined(); }); }); @@ -1688,7 +1688,7 @@ describe("run-node script", () => { fakeProcess.emit("exit"); expect(fsSync.existsSync(lockDir)).toBe(false); - expect(() => release()).not.toThrow(); + expect(release()).toBeUndefined(); }); }); From 961f99091d17afc45f884c6c7772708c1ecc3968 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:35:51 +0100 Subject: [PATCH 199/806] test: clarify stale pid cleanup assertions --- src/infra/restart-stale-pids.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index 48d0bc564c0..aab2ab6c84f 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -672,8 +672,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { mockSpawnSync.mockImplementation(() => ({ error: null, status: 1, stdout: "", stderr: "" })); vi.spyOn(process, "kill").mockReturnValue(true); - // Must not throw — the catch path returns transient inconclusive, loop continues - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // The catch path returns transient inconclusive, then the loop continues. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); }); }); @@ -777,7 +777,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Must bail after first ENOENT poll — no point retrying a missing binary const enoentPolls = events.filter((e) => e.startsWith("enoent-poll")); @@ -792,7 +792,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { createErrnoResult("EPERM", "lsof eperm"), ); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Must bail after exactly 1 EPERM poll — same as ENOENT/EACCES expect(getCallCount()).toBe(2); // 1 initial find + 1 EPERM poll }); @@ -805,7 +805,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { createErrnoResult("EACCES", "lsof permission denied"), ); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Should have bailed after exactly 1 poll call (the EACCES one) expect(getCallCount()).toBe(2); // 1 initial find + 1 EACCES poll }); @@ -826,8 +826,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); vi.spyOn(process, "kill").mockReturnValue(true); - // Must return without throwing (proceeds with warning after budget expires) - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // Proceeds with warning after budget expires. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); }); it("still polls for port-free when all stale pids were already dead at SIGTERM time", () => { @@ -1199,8 +1199,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); }); vi.spyOn(process, "kill").mockReturnValue(true); - // Should complete cleanly — no openclaw pids in status-1 output → free - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // No openclaw pids in status-1 output means the port is free for this cleanup. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Completed with one argv verification after the status-1 poll output: // initial lsof + poll lsof + ps argv check. expect(getCallCount()).toBe(3); From 8221f0914a89d797476e74b2318fb0f653767090 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:37:09 +0100 Subject: [PATCH 200/806] test: clarify small core accepted assertions --- src/agents/openai-ws-connection.test.ts | 2 +- ...oes-not-call-onblockreplyflush-callback-is-not.test.ts | 8 ++++---- src/tui/tui.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index a4cb33efda5..e629089afe3 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -538,7 +538,7 @@ describe("OpenAIWebSocketManager", () => { it("is safe to call before connect()", () => { const manager = buildManager(); - expect(() => manager.close()).not.toThrow(); + expect(manager.close()).toBeUndefined(); expect(manager.connectionState).toBe("closed"); }); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts index 1a909ae2746..ee762a46be4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts @@ -27,14 +27,14 @@ describe("subscribeEmbeddedPiSession", () => { blockReplyBreak: "text_end", }); - // This should not throw even without onBlockReplyFlush - expect(() => { + // Missing onBlockReplyFlush should still accept streaming events. + expect( handler?.({ type: "tool_execution_start", toolName: "bash", toolCallId: "tool-no-flush", args: { command: "echo test" }, - }); - }).not.toThrow(); + }), + ).toBeUndefined(); }); }); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index b8d8a329890..45163e32ae5 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -328,11 +328,11 @@ describe("TUI shutdown safety", () => { }); it("swallows only ignorable stop errors", () => { - expect(() => { + expect( stopTuiSafely(() => { throw new Error("setRawMode EBADF"); - }); - }).not.toThrow(); + }), + ).toBeUndefined(); }); it("rethrows non-ignorable stop errors", () => { From ba91d477a2b42c987adf28eaf270bd1ed537d3f0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 17:08:36 +0530 Subject: [PATCH 201/806] fix(reply): fast-path native slash commands --- CHANGELOG.md | 1 + .../reply/get-reply-native-slash-fast-path.ts | 231 ++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 47 +++- 3 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 src/auto-reply/reply/get-reply-native-slash-fast-path.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b29681b88..cf15c68dbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,7 @@ Docs: https://docs.openclaw.ai - Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. +- Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Infra/fetch-timeout: pass `operation` and `url` context to `buildTimeoutAbortSignal` from the music-generate reference fetch and the Matrix guarded redirect transport, so the `fetch timeout reached; aborting operation` warning carries actionable structured fields instead of a bare line. Fixes #79195. Thanks @pandadev66. diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts new file mode 100644 index 00000000000..747e6064a6c --- /dev/null +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -0,0 +1,231 @@ +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { GetReplyOptions } from "../get-reply-options.types.js"; +import type { ReplyPayload } from "../reply-payload.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext } from "./commands-context.js"; +import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { resolveReplyDirectives } from "./get-reply-directives.js"; +import { initFastReplySessionState } from "./get-reply-fast-path.js"; +import { handleInlineActions } from "./get-reply-inline-actions.js"; +import { stripStructuralPrefixes } from "./mentions.js"; +import type { createTypingController } from "./typing.js"; + +type AgentDefaults = NonNullable["defaults"]> | undefined; + +const commandsRuntimeLoader = createLazyImportLoader(() => import("./commands.runtime.js")); +const statusCommandRuntimeLoader = createLazyImportLoader(() => import("./commands-status.js")); + +function loadCommandsRuntime() { + return commandsRuntimeLoader.load(); +} + +function loadStatusCommandRuntime() { + return statusCommandRuntimeLoader.load(); +} + +function resolveNativeSlashCommandName(ctx: MsgContext): string | undefined { + if (ctx.CommandSource !== "native") { + return undefined; + } + const commandText = stripStructuralPrefixes( + ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "", + ).trim(); + const match = commandText.match(/^\/([^\s:]+)(?::|\s|$)/); + return normalizeOptionalString(match?.[1])?.toLowerCase(); +} + +function shouldRunNativeSlashCommandFastPath(ctx: MsgContext): boolean { + const commandName = resolveNativeSlashCommandName(ctx); + return Boolean(commandName && commandName !== "new" && commandName !== "reset"); +} + +export async function maybeResolveNativeSlashCommandFastReply(params: { + ctx: MsgContext; + cfg: OpenClawConfig; + agentId: string; + agentDir: string; + agentCfg: AgentDefaults; + commandAuthorized: boolean; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + provider: string; + model: string; + workspaceDir: string; + typing: ReturnType; + opts?: GetReplyOptions; + skillFilter?: string[]; +}): Promise< + { handled: true; reply: ReplyPayload | ReplyPayload[] | undefined } | { handled: false } +> { + if (!shouldRunNativeSlashCommandFastPath(params.ctx)) { + return { handled: false }; + } + + const sessionState = initFastReplySessionState({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + commandAuthorized: params.commandAuthorized, + workspaceDir: params.workspaceDir, + }); + const command = buildCommandContext({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + sessionKey: sessionState.sessionKey, + isGroup: sessionState.isGroup, + triggerBodyNormalized: sessionState.triggerBodyNormalized, + commandAuthorized: params.commandAuthorized, + }); + if (command.commandBodyNormalized === "/status") { + const targetSessionEntry = + sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; + const { buildStatusReply } = await loadStatusCommandRuntime(); + return { + handled: true, + reply: await buildStatusReply({ + cfg: params.cfg, + command, + sessionEntry: targetSessionEntry, + sessionKey: sessionState.sessionKey, + parentSessionKey: targetSessionEntry?.parentSessionKey ?? params.ctx.ParentSessionKey, + sessionScope: sessionState.sessionScope, + storePath: sessionState.storePath, + provider: params.provider, + model: params.model, + workspaceDir: params.workspaceDir, + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: sessionState.isGroup, + defaultGroupActivation: () => "always", + mediaDecisions: params.ctx.MediaUnderstandingDecisions, + }), + }; + } + + const commandResult = await ( + await loadCommandsRuntime() + ).handleCommands({ + ctx: sessionState.sessionCtx, + rootCtx: params.ctx, + cfg: params.cfg, + command, + agentId: params.agentId, + agentDir: params.agentDir, + directives: clearInlineDirectives(sessionState.triggerBodyNormalized), + elevated: { + enabled: false, + allowed: false, + failures: [], + }, + sessionEntry: sessionState.sessionEntry, + previousSessionEntry: sessionState.previousSessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + workspaceDir: params.workspaceDir, + opts: params.opts, + defaultGroupActivation: () => "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: "text_end", + resolveDefaultThinkingLevel: async () => undefined, + provider: params.provider, + model: params.model, + contextTokens: params.agentCfg?.contextTokens ?? 0, + isGroup: sessionState.isGroup, + skillCommands: [], + typing: params.typing, + }); + if (!commandResult.shouldContinue) { + return { handled: true, reply: commandResult.reply }; + } + + const directiveResult = await resolveReplyDirectives({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + agentCfg: params.agentCfg, + sessionCtx: sessionState.sessionCtx, + sessionEntry: sessionState.sessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + groupResolution: sessionState.groupResolution, + isGroup: sessionState.isGroup, + triggerBodyNormalized: sessionState.triggerBodyNormalized, + resetTriggered: false, + commandAuthorized: params.commandAuthorized, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + provider: params.provider, + model: params.model, + hasResolvedHeartbeatModelOverride: false, + typing: params.typing, + opts: params.opts, + skillFilter: params.skillFilter, + }); + if (directiveResult.kind === "reply") { + return { handled: true, reply: directiveResult.reply }; + } + + const inlineActionResult = await handleInlineActions({ + ctx: params.ctx, + sessionCtx: sessionState.sessionCtx, + cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + sessionEntry: sessionState.sessionEntry, + previousSessionEntry: sessionState.previousSessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + workspaceDir: params.workspaceDir, + isGroup: sessionState.isGroup, + opts: params.opts, + typing: params.typing, + allowTextCommands: directiveResult.result.allowTextCommands, + inlineStatusRequested: directiveResult.result.inlineStatusRequested, + command: directiveResult.result.command, + skillCommands: directiveResult.result.skillCommands, + directives: directiveResult.result.directives, + cleanedBody: directiveResult.result.cleanedBody, + elevatedEnabled: directiveResult.result.elevatedEnabled, + elevatedAllowed: directiveResult.result.elevatedAllowed, + elevatedFailures: directiveResult.result.elevatedFailures, + defaultActivation: () => directiveResult.result.defaultActivation, + resolvedThinkLevel: directiveResult.result.resolvedThinkLevel, + resolvedVerboseLevel: directiveResult.result.resolvedVerboseLevel, + resolvedReasoningLevel: directiveResult.result.resolvedReasoningLevel, + resolvedElevatedLevel: directiveResult.result.resolvedElevatedLevel, + blockReplyChunking: directiveResult.result.blockReplyChunking, + resolvedBlockStreamingBreak: directiveResult.result.resolvedBlockStreamingBreak, + resolveDefaultThinkingLevel: directiveResult.result.modelState.resolveDefaultThinkingLevel, + provider: directiveResult.result.provider, + model: directiveResult.result.model, + contextTokens: directiveResult.result.contextTokens, + directiveAck: directiveResult.result.directiveAck, + abortedLastRun: sessionState.abortedLastRun, + skillFilter: params.skillFilter, + }); + if (inlineActionResult.kind === "reply") { + return { handled: true, reply: inlineActionResult.reply }; + } + return { handled: false }; +} diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index be72e482e92..0910e8b7b40 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -36,6 +36,7 @@ import { shouldUseReplyFastTestRuntime, } from "./get-reply-fast-path.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; +import { maybeResolveNativeSlashCommandFastReply } from "./get-reply-native-slash-fast-path.js"; import { runPreparedReply } from "./get-reply-run.js"; import { finalizeInboundContext } from "./inbound-context.js"; import { hasInboundMedia } from "./inbound-media.js"; @@ -248,16 +249,7 @@ export async function getReplyFromConfig( } const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; - const workspace = await traceGetReplyPhase("reply.ensure_workspace", async () => - useFastTestBootstrap - ? (await fs.mkdir(workspaceDirRaw, { recursive: true }), { dir: workspaceDirRaw }) - : await ensureAgentWorkspace({ - dir: workspaceDirRaw, - ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, - skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles, - }), - ); - const workspaceDir = workspace.dir; + const workspaceDirForNativeCommand = workspaceDirRaw; const agentDir = resolveAgentDir(cfg, agentId); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: opts?.timeoutOverrideSeconds }); const configuredTypingSeconds = @@ -274,6 +266,41 @@ export async function getReplyFromConfig( opts?.onTypingController?.(typing); const finalized = finalizeInboundContext(ctx); + const nativeSlashCommandFastReply = await traceGetReplyPhase( + "reply.native_slash_command_fast_path", + () => + maybeResolveNativeSlashCommandFastReply({ + ctx: finalized, + cfg, + agentId, + agentDir, + agentCfg, + commandAuthorized: finalized.CommandAuthorized, + defaultProvider, + defaultModel, + aliasIndex, + provider, + model, + workspaceDir: workspaceDirForNativeCommand, + typing, + opts: resolvedOpts, + skillFilter: mergedSkillFilter, + }), + ); + if (nativeSlashCommandFastReply.handled) { + return nativeSlashCommandFastReply.reply; + } + + const workspace = await traceGetReplyPhase("reply.ensure_workspace", async () => + useFastTestBootstrap + ? (await fs.mkdir(workspaceDirRaw, { recursive: true }), { dir: workspaceDirRaw }) + : await ensureAgentWorkspace({ + dir: workspaceDirRaw, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles, + }), + ); + const workspaceDir = workspace.dir; if (!isFastTestEnv && hasInboundMedia(finalized)) { await traceGetReplyPhase("reply.apply_media_understanding", () => From 013e1ac72f1bd3b20d596da464ca116f05c7b3f3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 17:08:51 +0530 Subject: [PATCH 202/806] test(reply): cover native slash fast path --- .../reply/get-reply.fast-path.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 069961e2687..dce4d111db6 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -130,6 +130,86 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); }); + it("handles native /status before workspace bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-fast-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual(expect.objectContaining({ text: expect.stringContaining("OpenClaw") })); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + + it("handles native slash directives before workspace bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + mocks.resolveReplyDirectives.mockResolvedValueOnce({ + kind: "reply", + reply: { text: "model status" }, + }); + + await expect( + getReplyFromConfig( + buildGetReplyCtx({ + Body: "/model status", + BodyForAgent: "/model status", + RawBody: "/model status", + CommandBody: "/model status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ), + ).resolves.toEqual({ text: "model status" }); + + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: targetSessionKey, + workspaceDir: expect.any(String), + }), + ); + }); + it("uses native command target session keys during fast bootstrap", () => { const result = initFastReplySessionState({ ctx: buildGetReplyCtx({ From 7c8857be99e40f7237c8650fb51e04ed1a17bbb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:39:09 +0100 Subject: [PATCH 203/806] test: clarify core resilience test names --- src/auto-reply/reply/session-delivery.test.ts | 5 ++--- src/commands/configure.wizard.test.ts | 2 +- src/commands/models.list.e2e.test.ts | 2 +- src/cron/service/ops.regression.test.ts | 2 +- src/infra/outbound/delivery-queue.reconnect-drain.test.ts | 3 +-- src/plugins/plugin-graceful-init-failure.test.ts | 2 +- src/wizard/setup.test.ts | 2 +- 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index 02d1f1d36f3..a7ef99166ed 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -58,12 +58,11 @@ describe("inter-session lastRoute preservation (fixes #54441)", () => { sessionKey: "agent:samantha:main", isInterSession: true, }); - // No external route existed — falls through to normal resolution (webchat or undefined) - // The important thing is it does NOT throw and returns a defined or undefined value. + // No external route existed — falls through to normal resolution (webchat or undefined). expect(result === "webchat" || result === undefined).toBe(true); }); - it("inter-session on session with no persisted lastTo does not crash", () => { + it("inter-session on session with no persisted lastTo preserves session route", () => { const result = resolveLastToRaw({ originatingChannelRaw: "webchat", originatingToRaw: "session:somekey", diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 70f81cdc553..9bc4e76954f 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -340,7 +340,7 @@ describe("runConfigureWizard", () => { expect(mocks.setupSearch).toHaveBeenCalledOnce(); }); - it("does not crash when web search providers are unavailable under plugin policy", async () => { + it("notes unavailable web search providers under plugin policy", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([]); queueWizardPrompts({ diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 69919aa901c..b557b0f7a91 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -691,7 +691,7 @@ describe("models list/status", () => { ]); }); - it("toModelRow does not crash without cfg/authStore when availability is undefined", () => { + it("toModelRow marks unavailable when cfg/authStore and availability are undefined", () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( "claude-opus-4-6-thinking", diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts index f3d8e84dc46..dd85daf66e4 100644 --- a/src/cron/service/ops.regression.test.ts +++ b/src/cron/service/ops.regression.test.ts @@ -27,7 +27,7 @@ const opsRegressionFixtures = setupCronRegressionFixtures({ }); describe("cron service ops regressions", () => { - it("does not crash startup when a loaded job is missing state", async () => { + it("repairs missing job state during startup", async () => { const scheduledAt = Date.now() + 60_000; const store = opsRegressionFixtures.makeStorePath(); const state = createCronServiceState({ diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index 105dc9b7e83..309a6e0f603 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -148,13 +148,12 @@ describe("drainPendingDeliveries for reconnect", () => { expect(after.lastError).toBe("transient failure"); }); - it("does not throw if delivery fails during drain", async () => { + it("records retry state if delivery fails during drain", async () => { const log = createRecoveryLog(); const deliver = createTransientFailureDeliver(); await enqueueFailedDirectChatDelivery({ accountId: "acct1", stateDir: tmpDir }); - // Should not throw await expect( drainAcct1DirectChatReconnect({ deliver, log, stateDir: tmpDir }), ).resolves.toBeUndefined(); diff --git a/src/plugins/plugin-graceful-init-failure.test.ts b/src/plugins/plugin-graceful-init-failure.test.ts index d336ea50c62..6db0220560a 100644 --- a/src/plugins/plugin-graceful-init-failure.test.ts +++ b/src/plugins/plugin-graceful-init-failure.test.ts @@ -89,7 +89,7 @@ function requireWarning(warnings: string[], text: string): string { } describe("graceful plugin initialization failure", () => { - it("does not crash when register throws", async () => { + it("marks plugin entry errored when register throws", async () => { const plugin = writePlugin({ id: "throws-on-register", body: `module.exports = { id: "throws-on-register", register() { throw new Error("config schema mismatch"); } };`, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index d543a398735..9c03c775b46 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -324,7 +324,7 @@ describe("runSetupWizard", () => { return dir; } - it("does not crash when preferred-provider lookup sees a provider without an id", async () => { + it("skips provider entries without an id during preferred-provider lookup", async () => { setupChannels.mockClear(); readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", From 8ec92f544c5922a2fc86aa76e578ef3ad8b4707d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:41:40 +0100 Subject: [PATCH 204/806] test: clarify extension resilience test names --- .../msteams/src/monitor-handler/message-handler.authz.test.ts | 2 +- extensions/msteams/src/sdk.test.ts | 2 +- extensions/slack/src/monitor/slash.test.ts | 2 +- extensions/voice-call/src/manager/events.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 3db22f851a4..18a5b0f1823 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -463,7 +463,7 @@ describe("msteams monitor handler authz", () => { ); }); - it("does not crash when channelData.tenant is missing and stores no tenantId", async () => { + it("stores no tenantId when channelData.tenant is missing", async () => { const { conversationStore, deps } = createDeps({ channels: { msteams: { diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 7bed1443298..e7f5de56c7d 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -147,7 +147,7 @@ function createSdkStub(): MSTeamsTeamsSdk { } describe("createMSTeamsApp", () => { - it("does not crash with express 5 path-to-regexp (#55161)", async () => { + it("creates app without the Express 5 wildcard route regression (#55161)", async () => { // Regression test for: https://github.com/openclaw/openclaw/issues/55161 // createMSTeamsApp passes a no-op httpServerAdapter to prevent the SDK from // creating its default HttpPlugin (which registers `/api*` — invalid in Express 5). diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 695c3ce9daf..e4e02bf4d10 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -910,7 +910,7 @@ describe("Slack native command argument menus", () => { ); }); - it("treats malformed percent-encoding as an invalid button (no throw)", async () => { + it("treats malformed percent-encoding as an invalid button", async () => { await runArgMenuAction(argMenuHandler, { action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, includeRespond: false, diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index bc3a0f94588..638de35c7cc 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -332,7 +332,7 @@ describe("processEvent (functional)", () => { expect(answeredCallId).toBe("call-2"); }); - it("when hangup throws, logs and does not throw", () => { + it("removes active call even when hangup rejects", () => { const provider = createProvider({ hangupCall: async (): Promise => { throw new Error("provider down"); From 4708909dc2d2953aa0b5b1cb65b94e13b8dbfda6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:43:26 +0100 Subject: [PATCH 205/806] test: clarify resilience test wording --- extensions/matrix/src/matrix/monitor/events.test.ts | 2 +- extensions/twitch/src/twitch-client.test.ts | 4 ++-- src/gateway/server-http.hooks-request-timeout.test.ts | 2 +- src/oc-path/tests/scenarios/frontmatter-edges.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 24e8250b9e8..51dc802b080 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -1809,7 +1809,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { } }); - it("does not throw when getUserId fails during decrypt guidance lookup", async () => { + it("logs decrypt guidance when getUserId fails during lookup", async () => { const { logger, logVerboseMessage, failedDecryptListener } = createHarness({ accountId: "ops", selfUserIdError: new Error("lookup failed"), diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index cefc333b29a..0f279ba79d6 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -295,7 +295,7 @@ describe("TwitchClientManager", () => { }); it("should handle disconnecting non-existent client gracefully", async () => { - // disconnect doesn't throw, just does nothing + // Missing clients are ignored. await manager.disconnect(testAccount); expect(mockQuit).not.toHaveBeenCalled(); }); @@ -326,7 +326,7 @@ describe("TwitchClientManager", () => { }); it("should handle empty client list gracefully", async () => { - // disconnectAll doesn't throw, just does nothing + // Empty client sets are ignored. await manager.disconnectAll(); expect(mockQuit).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 60fa0aec4b2..eaf2f107f83 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -96,7 +96,7 @@ describe("createHooksRequestHandler timeout status mapping", () => { }); test.each(["0.0.0.0", "::"])( - "does not throw when bindHost=%s while parsing non-hook request URL", + "returns unhandled when bindHost=%s sees a non-hook request URL", async (bindHost) => { const handler = createHooksHandler({ bindHost }); const req = createHookRequest({ url: "/" }); diff --git a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts index a75746067ea..8183bada2fc 100644 --- a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts +++ b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts @@ -2,8 +2,8 @@ * Wave 2 — frontmatter edges. * * Substrate guarantee: frontmatter is parsed as `key: value` entries - * with quote-stripping; malformed frontmatter doesn't crash the parser - * (soft-error policy: emit diagnostic, recover). + * with quote-stripping; malformed frontmatter follows the soft-error + * policy by emitting diagnostics and recovering. */ import { describe, expect, it } from "vitest"; import { parseMd } from "../../parse.js"; From 7e26b59f139e3e81cc0f3c1bc13832ac66e6b1b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:45:29 +0100 Subject: [PATCH 206/806] test: clarify nostr metrics assertions --- extensions/nostr/src/nostr-bus.fuzz.test.ts | 42 +++++++++---------- .../nostr/src/nostr-bus.integration.test.ts | 8 ++-- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/extensions/nostr/src/nostr-bus.fuzz.test.ts b/extensions/nostr/src/nostr-bus.fuzz.test.ts index 0eff6ed8da7..3d29c4ab355 100644 --- a/extensions/nostr/src/nostr-bus.fuzz.test.ts +++ b/extensions/nostr/src/nostr-bus.fuzz.test.ts @@ -145,7 +145,7 @@ describe("SeenTracker fuzz", () => { describe("malformed IDs", () => { it("handles empty string IDs", () => { const tracker = createTracker(); - expect(() => tracker.add("")).not.toThrow(); + expect(tracker.add("")).toBeUndefined(); expect(tracker.peek("")).toBe(true); tracker.stop(); }); @@ -153,7 +153,7 @@ describe("SeenTracker fuzz", () => { it("handles very long IDs", () => { const tracker = createTracker(); const longId = "a".repeat(100000); - expect(() => tracker.add(longId)).not.toThrow(); + expect(tracker.add(longId)).toBeUndefined(); expect(tracker.peek(longId)).toBe(true); tracker.stop(); }); @@ -161,7 +161,7 @@ describe("SeenTracker fuzz", () => { it("handles unicode IDs", () => { const tracker = createTracker(); const unicodeId = "事件ID_🎉_тест"; - expect(() => tracker.add(unicodeId)).not.toThrow(); + expect(tracker.add(unicodeId)).toBeUndefined(); expect(tracker.peek(unicodeId)).toBe(true); tracker.stop(); }); @@ -169,7 +169,7 @@ describe("SeenTracker fuzz", () => { it("handles IDs with null bytes", () => { const tracker = createTracker(); const idWithNull = "event\x00id"; - expect(() => tracker.add(idWithNull)).not.toThrow(); + expect(tracker.add(idWithNull)).toBeUndefined(); expect(tracker.peek(idWithNull)).toBe(true); tracker.stop(); }); @@ -178,10 +178,10 @@ describe("SeenTracker fuzz", () => { const tracker = createTracker(); // These should not affect the tracker's internal operation - expect(() => tracker.add("__proto__")).not.toThrow(); - expect(() => tracker.add("constructor")).not.toThrow(); - expect(() => tracker.add("toString")).not.toThrow(); - expect(() => tracker.add("hasOwnProperty")).not.toThrow(); + expect(tracker.add("__proto__")).toBeUndefined(); + expect(tracker.add("constructor")).toBeUndefined(); + expect(tracker.add("toString")).toBeUndefined(); + expect(tracker.add("hasOwnProperty")).toBeUndefined(); expect(tracker.peek("__proto__")).toBe(true); expect(tracker.peek("constructor")).toBe(true); @@ -231,7 +231,7 @@ describe("SeenTracker fuzz", () => { describe("seed edge cases", () => { it("handles empty seed array", () => { const tracker = createTracker(); - expect(() => tracker.seed([])).not.toThrow(); + expect(tracker.seed([])).toBeUndefined(); expect(tracker.size()).toBe(0); tracker.stop(); }); @@ -263,33 +263,29 @@ describe("Metrics fuzz", () => { const metrics = createPlainMetrics(); // Cast to bypass type checking - testing runtime behavior - expect(() => { - metrics.emit("invalid.metric.name" as MetricName); - }).not.toThrow(); + expect(metrics.emit("invalid.metric.name" as MetricName)).toBeUndefined(); }); }); describe("invalid label values", () => { it("handles null relay label", () => { const metrics = createPlainMetrics(); - expect(() => { - metrics.emit("relay.connect", 1, { relay: null as unknown as string }); - }).not.toThrow(); + expect( + metrics.emit("relay.connect", 1, { relay: null as unknown as string }), + ).toBeUndefined(); }); it("handles undefined relay label", () => { const metrics = createPlainMetrics(); - expect(() => { - metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }); - }).not.toThrow(); + expect( + metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }), + ).toBeUndefined(); }); it("handles very long relay URL", () => { const metrics = createPlainMetrics(); const longUrl = "wss://" + "a".repeat(10000) + ".com"; - expect(() => { - metrics.emit("relay.connect", 1, { relay: longUrl }); - }).not.toThrow(); + expect(metrics.emit("relay.connect", 1, { relay: longUrl })).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(snapshot.relays[longUrl]).toEqual(expect.objectContaining({ connects: 1 })); @@ -299,7 +295,7 @@ describe("Metrics fuzz", () => { describe("extreme values", () => { it("handles NaN value", () => { const metrics = createPlainMetrics(); - expect(() => metrics.emit("event.received", Number.NaN)).not.toThrow(); + expect(metrics.emit("event.received", Number.NaN)).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(Number.isNaN(snapshot.eventsReceived)).toBe(true); @@ -307,7 +303,7 @@ describe("Metrics fuzz", () => { it("handles Infinity value", () => { const metrics = createPlainMetrics(); - expect(() => metrics.emit("event.received", Infinity)).not.toThrow(); + expect(metrics.emit("event.received", Infinity)).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsReceived).toBe(Infinity); diff --git a/extensions/nostr/src/nostr-bus.integration.test.ts b/extensions/nostr/src/nostr-bus.integration.test.ts index fcf01378d39..782e65a9da9 100644 --- a/extensions/nostr/src/nostr-bus.integration.test.ts +++ b/extensions/nostr/src/nostr-bus.integration.test.ts @@ -386,13 +386,11 @@ describe("Metrics", () => { }); describe("createNoopMetrics", () => { - it("does not throw on emit", () => { + it("ignores emitted metrics", () => { const metrics = createNoopMetrics(); - expect(() => { - metrics.emit("event.received"); - metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY }); - }).not.toThrow(); + expect(metrics.emit("event.received")).toBeUndefined(); + expect(metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY })).toBeUndefined(); }); it("returns empty snapshot", () => { From d3b47526bc8a94676b655bdae17b2b7c0eb764f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:48:05 +0100 Subject: [PATCH 207/806] test: clarify discord matrix assertions --- extensions/discord/src/internal/gateway.test.ts | 6 +++--- extensions/discord/src/monitor/provider.test.ts | 4 ++-- extensions/discord/src/voice/manager.e2e.test.ts | 10 +++++----- extensions/matrix/src/matrix/monitor/mentions.test.ts | 8 ++++---- extensions/matrix/src/matrix/sdk.test.ts | 3 +-- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/extensions/discord/src/internal/gateway.test.ts b/extensions/discord/src/internal/gateway.test.ts index ba37791f6d8..206bafccfbb 100644 --- a/extensions/discord/src/internal/gateway.test.ts +++ b/extensions/discord/src/internal/gateway.test.ts @@ -484,15 +484,15 @@ describe("GatewayPlugin", () => { expect(gateway.ws).toBeNull(); expect(gateway.firstHeartbeatTimeout).toBeUndefined(); expect(gateway.heartbeatInterval).toBeUndefined(); - expect(() => vi.advanceTimersByTime(20)).not.toThrow(); + vi.advanceTimersByTime(20); expect(send).not.toHaveBeenCalled(); - expect(() => + expect( ( gateway as unknown as { sendHeartbeat(): void; } ).sendHeartbeat(), - ).not.toThrow(); + ).toBeUndefined(); }); it("clears stale heartbeat timers before early reconnect exits", () => { diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index de5b6479562..c6390da5ccc 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -327,9 +327,9 @@ describe("monitorDiscordProvider", () => { expect(monitorLifecycleMock).not.toHaveBeenCalled(); expect(disconnect).toHaveBeenCalledTimes(1); - expect(() => + expect( emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1005")), - ).not.toThrow(); + ).toBe(true); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"), ); diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 59d178e238b..9e074017c70 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -412,7 +412,7 @@ describe("DiscordVoiceManager", () => { expectConnectedStatus(manager, "1002"); }); - it("does not throw when stale tracked voice connections are already destroyed", async () => { + it("skips destroying stale tracked voice connections that are already destroyed", async () => { const staleConnection = createConnectionMock(); staleConnection.state.status = "destroyed"; staleConnection.destroy.mockImplementation(() => { @@ -429,7 +429,7 @@ describe("DiscordVoiceManager", () => { expect(staleConnection.destroy).not.toHaveBeenCalled(); }); - it("does not throw when leaving an already destroyed voice connection", async () => { + it("skips destroying an already destroyed voice connection on leave", async () => { const connection = createConnectionMock(); connection.destroy.mockImplementation(() => { throw new Error("Cannot destroy VoiceConnection - it has already been destroyed"); @@ -1095,7 +1095,7 @@ describe("DiscordVoiceManager", () => { expect(agentCommandMock).toHaveBeenCalledTimes(1); }); - it("DiscordVoiceReadyListener: propagates autoJoin errors fire-and-forget without throwing", async () => { + it("DiscordVoiceReadyListener: starts autoJoin fire-and-forget on ready", async () => { const manager = createManager(); const autoJoinSpy = vi .spyOn(manager, "autoJoin") @@ -1104,7 +1104,7 @@ describe("DiscordVoiceManager", () => { const { DiscordVoiceReadyListener } = managerModule; const listener = new DiscordVoiceReadyListener(manager); - await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow(); + await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined(); expect(autoJoinSpy).toHaveBeenCalledTimes(1); }); @@ -1115,7 +1115,7 @@ describe("DiscordVoiceManager", () => { const { DiscordVoiceResumedListener } = managerModule; const listener = new DiscordVoiceResumedListener(manager); - await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow(); + await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined(); expect(autoJoinSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index 14aae68654e..3c18a690858 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -228,7 +228,7 @@ describe("resolveMentions", () => { }); it("ignores out-of-range hexadecimal HTML entities in visible labels", () => { - expect(() => + expect( resolveMentions({ content: { msgtype: "m.text", @@ -239,11 +239,11 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes: [], }), - ).not.toThrow(); + ).toEqual({ hasExplicitMention: false, wasMentioned: false }); }); it("ignores oversized decimal HTML entities in visible labels", () => { - expect(() => + expect( resolveMentions({ content: { msgtype: "m.text", @@ -255,7 +255,7 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes: [], }), - ).not.toThrow(); + ).toEqual({ hasExplicitMention: false, wasMentioned: false }); }); it("does not detect mention when displayName is spoofed", () => { diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 693e638e2ad..8026f7ffc82 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1573,8 +1573,7 @@ describe("MatrixClient crypto bootstrapping", () => { } ).cryptoBootstrapper.bootstrap = bootstrapSpy; - // start() must NOT throw even when the repair bootstrap fails - await expect(client.start()).resolves.not.toThrow(); + await expect(client.start()).resolves.toBeUndefined(); // repair was attempted expect(bootstrapSpy).toHaveBeenCalledTimes(2); From a973e3199d7f55651ae17612afb4504e8acef8d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:49:41 +0100 Subject: [PATCH 208/806] test: clarify telegram qa assertions --- .../telegram/telegram-live.runtime.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index da26cafa899..c2a01327962 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -496,7 +496,7 @@ describe("telegram live qa runtime", () => { }); it("asserts long Telegram final replies reuse the streamed preview message", () => { - expect(() => + expect( __testing.assertTelegramScenarioMessageSet({ expectedJoinedSutTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN", "TELEGRAM-LONG-FINAL-END"], expectedSutMessageCount: 2, @@ -534,7 +534,7 @@ describe("telegram live qa runtime", () => { }, ], }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => __testing.assertTelegramScenarioMessageSet({ @@ -591,7 +591,7 @@ describe("telegram live qa runtime", () => { }); it("accepts legitimate three-chunk Telegram final replies", () => { - expect(() => + expect( __testing.assertTelegramScenarioMessageSet({ expectedJoinedSutTextIncludes: [ "TELEGRAM-LONG-FINAL-3CHUNK-BEGIN", @@ -646,7 +646,7 @@ describe("telegram live qa runtime", () => { }, ], }), - ).not.toThrow(); + ).toBeUndefined(); }); it("matches scenario replies by thread or exact marker", () => { @@ -716,7 +716,7 @@ describe("telegram live qa runtime", () => { }); it("validates expected Telegram reply markers", () => { - expect(() => + expect( __testing.assertTelegramScenarioReply({ expectedTextIncludes: ["🧭 Identity", "Channel: telegram"], message: { @@ -733,7 +733,7 @@ describe("telegram live qa runtime", () => { mediaKinds: [], }, }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => __testing.assertTelegramScenarioReply({ expectedTextIncludes: ["Use /tools verbose for descriptions."], From 21c33bed3b54dad2c7299096a43cf794e8d0f0d4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 6 May 2026 17:35:38 +0530 Subject: [PATCH 209/806] fix(telegram): preserve tool-only duplicate suppression --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 35 +++++++++++ .../reply/dispatch-from-config.test.ts | 62 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 24 ++++--- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf15c68dbb3..225767eb45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -308,6 +308,7 @@ Docs: https://docs.openclaw.ai - Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight. - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. +- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu. - Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b634ec9cb8f..5f1e22d75af 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1162,6 +1162,41 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(editMessageTelegram).not.toHaveBeenCalled(); }); + it("does not add silent fallback when source delivery is message-tool-only", async () => { + setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(sendMessageTelegram).not.toHaveBeenCalled(); + }); + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { const statusReactionController = { setThinking: vi.fn(async () => {}), diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 7f9b4d9397c..0666fa655ae 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2727,6 +2727,68 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("keeps message-tool-only delivery mode on duplicate inbound returns", async () => { + setNoAbort(); + const cfg = emptyConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-only-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); + + const first = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + const duplicate = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(duplicate.sourceReplyDeliveryMode).toBe("message_tool_only"); + }); + + it("does not mark duplicate inbound returns as tool-only when message is unavailable", async () => { + setNoAbort(); + const cfg = { tools: { allow: ["read"] } } as OpenClawConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-unavailable-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "visible fallback" }) as ReplyPayload); + + const first = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + const duplicate = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBeUndefined(); + expect(duplicate.sourceReplyDeliveryMode).toBeUndefined(); + }); + it("keeps local discord exec approval tool prompts when the native runtime is inactive", async () => { setNoAbort(); const cfg = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f348181522b..bf8c2f1ae9d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -430,20 +430,10 @@ export async function dispatchReplyFromConfig( }); }; - const inboundDedupeClaim = claimInboundDedupe(ctx); - if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { - recordProcessed("skipped", { reason: "duplicate" }); - return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; - } let inboundDedupeReplayUnsafe = false; const markInboundDedupeReplayUnsafe = () => { inboundDedupeReplayUnsafe = true; }; - const commitInboundDedupeIfClaimed = () => { - if (inboundDedupeClaim.status === "claimed") { - commitInboundDedupe(inboundDedupeClaim.key); - } - }; const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg }); @@ -807,6 +797,20 @@ export async function dispatchReplyFromConfig( ? { ...result, sourceReplyDeliveryMode } : result; + const inboundDedupeClaim = claimInboundDedupe(ctx); + if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { + recordProcessed("skipped", { reason: "duplicate" }); + return attachSourceReplyDeliveryMode({ + queuedFinal: false, + counts: dispatcher.getQueuedCounts(), + }); + } + const commitInboundDedupeIfClaimed = () => { + if (inboundDedupeClaim.status === "claimed") { + commitInboundDedupe(inboundDedupeClaim.key); + } + }; + let pluginFallbackReason: | "plugin-bound-fallback-missing-plugin" | "plugin-bound-fallback-no-handler" From 67fa43d054e1ce6816197c704bdb659408535447 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 18:12:19 +0530 Subject: [PATCH 210/806] test(auto-reply): reuse duplicate dispatch helper --- .../reply/dispatch-from-config.test.ts | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 0666fa655ae..df747c9c61e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -727,14 +727,15 @@ function requireBlockReplyHandler( } async function dispatchTwiceWithFreshDispatchers(params: Omit) { - await dispatchReplyFromConfig({ + const first = await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); - await dispatchReplyFromConfig({ + const second = await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); + return [first, second] as const; } describe("dispatchReplyFromConfig", () => { @@ -2740,16 +2741,9 @@ describe("dispatchReplyFromConfig", () => { }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); - const first = await dispatchReplyFromConfig({ + const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({ ctx, cfg, - dispatcher: createDispatcher(), - replyResolver, - }); - const duplicate = await dispatchReplyFromConfig({ - ctx, - cfg, - dispatcher: createDispatcher(), replyResolver, }); @@ -2771,16 +2765,9 @@ describe("dispatchReplyFromConfig", () => { }); const replyResolver = vi.fn(async () => ({ text: "visible fallback" }) as ReplyPayload); - const first = await dispatchReplyFromConfig({ + const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({ ctx, cfg, - dispatcher: createDispatcher(), - replyResolver, - }); - const duplicate = await dispatchReplyFromConfig({ - ctx, - cfg, - dispatcher: createDispatcher(), replyResolver, }); From 81a34a260d544d35a4e932cb6322ed464f69e3b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:51:14 +0100 Subject: [PATCH 211/806] test: remove oc-path no-op pitfall smoke --- src/oc-path/tests/scenarios/pitfalls.test.ts | 28 +++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts index 0952c266e76..dc6276bbba4 100644 --- a/src/oc-path/tests/scenarios/pitfalls.test.ts +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -492,28 +492,18 @@ describe("wave-23 pitfalls — reserved characters", () => { }); }); -// ---------- Sentinel-redaction pitfall (P-036) --------------------------- - -describe("wave-23 pitfalls — redaction sentinel", () => { - // P-036 is fully covered by wave-21-sentinel-cross-kind. This is a - // smoke test asserting the link is intact. - it("P-036 sentinel guard activates at emit time (covered by wave-21)", () => { - expect(true).toBe(true); - }); -}); - // ---------- DEFERRED — documented limits --------------------------------- describe("wave-23 pitfalls — deferred (v0 limits)", () => { - it.skip("P-005 slash literal in key — v1: quoted segments", () => {}); - it.skip("P-006 dot literal in key — v1: quoted segments", () => {}); - it.skip("P-017 nested unions {a,{b,c}} — v1: parser stack", () => {}); - it.skip("P-019 wildcard inside wildcard — v1: pattern composition", () => {}); - it.skip("P-025 leading-zero numeric `01` — v1: explicit form", () => {}); - it.skip("P-027 `&` in segments — v1: percent-encoding", () => {}); - it.skip("P-028 percent-encoded segments — v1: rfc3986 layer", () => {}); - it.skip("P-034 ast mutation between resolve & consume — caller invariant", () => {}); - it.skip("P-035 stale paths from prior find — caller invariant", () => {}); + it.todo("P-005 slash literal in key — v1: quoted segments"); + it.todo("P-006 dot literal in key — v1: quoted segments"); + it.todo("P-017 nested unions {a,{b,c}} — v1: parser stack"); + it.todo("P-019 wildcard inside wildcard — v1: pattern composition"); + it.todo("P-025 leading-zero numeric `01` — v1: explicit form"); + it.todo("P-027 `&` in segments — v1: percent-encoding"); + it.todo("P-028 percent-encoded segments — v1: rfc3986 layer"); + it.todo("P-034 ast mutation between resolve & consume — caller invariant"); + it.todo("P-035 stale paths from prior find — caller invariant"); }); // ---------- Injection pitfalls (C12 / W12) ------------------------------- From bc720dedaf0e3c8809c5f92fa87e2975cca39f46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:54:10 +0100 Subject: [PATCH 212/806] test: clarify boolean membership assertions --- .../discord/src/monitor/model-picker.test.ts | 21 ++++++++++++++++--- .../commands/slash-commands-impl.test.ts | 14 +++++++++++-- src/agents/skills-status.test.ts | 14 +++++++++++-- src/auto-reply/reply/session-delivery.test.ts | 4 ++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index 468ec1c5e1f..9e1fcf5d5c1 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -495,7 +495,12 @@ describe("Discord model picker rendering", () => { throw new Error("models view did not render a provider select"); } expect(providerSelect.options?.length).toBe(2); - expect(providerSelect.options?.find((option) => option.value === "openai")?.default).toBe(true); + expect(providerSelect.options).toContainEqual( + expect.objectContaining({ + value: "openai", + default: true, + }), + ); const parsedProviderState = parseDiscordModelPickerCustomId(providerSelect.custom_id ?? ""); expect(parsedProviderState?.action).toBe("provider"); @@ -506,7 +511,12 @@ describe("Discord model picker rendering", () => { throw new Error("models view did not render a model select"); } expect(modelSelect.options?.length).toBe(3); - expect(modelSelect.options?.find((option) => option.value === "o3")?.default).toBe(true); + expect(modelSelect.options).toContainEqual( + expect.objectContaining({ + value: "o3", + default: true, + }), + ); const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect.custom_id ?? ""); expect(parsedModelSelectState?.action).toBe("model"); @@ -577,7 +587,12 @@ describe("Discord model picker rendering", () => { expect(runtimeSelect.options?.find((option) => option.value === "pi")?.label).toBe( "OpenClaw Pi Default", ); - expect(runtimeSelect.options?.find((option) => option.value === "codex")?.default).toBe(true); + expect(runtimeSelect.options).toContainEqual( + expect.objectContaining({ + value: "codex", + default: true, + }), + ); const submitButton = rows[3]?.components?.at(-1); const submitState = requireValue( diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts index 3c5d79b2ad0..45c1ee4e7d7 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts @@ -37,7 +37,12 @@ describe("QQBot framework slash commands", () => { expect.arrayContaining(["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]), ); for (const commandName of ["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]) { - expect(commands.find((command) => command.name === commandName)?.c2cOnly).toBe(true); + expect(commands).toContainEqual( + expect.objectContaining({ + name: commandName, + c2cOnly: true, + }), + ); } }); @@ -60,7 +65,12 @@ describe("QQBot framework slash commands", () => { const commands = registry.getFrameworkCommands(); expect(commands.map((command) => command.name)).toEqual(["private-admin", "shared-admin"]); - expect(commands.find((command) => command.name === "private-admin")?.c2cOnly).toBe(true); + expect(commands).toContainEqual( + expect.objectContaining({ + name: "private-admin", + c2cOnly: true, + }), + ); expect(commands.find((command) => command.name === "shared-admin")?.c2cOnly).toBeUndefined(); }); diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts index 8ce80855c96..e2db625c472 100644 --- a/src/agents/skills-status.test.ts +++ b/src/agents/skills-status.test.ts @@ -157,8 +157,18 @@ describe("buildWorkspaceSkillStatus", () => { expect(report.agentId).toBe("specialist"); expect(report.agentSkillFilter).toEqual(["alpha"]); expect(report.skills.find((skill) => skill.name === "alpha")?.blockedByAgentFilter).toBe(false); - expect(report.skills.find((skill) => skill.name === "alpha")?.modelVisible).toBe(true); - expect(report.skills.find((skill) => skill.name === "beta")?.blockedByAgentFilter).toBe(true); + expect(report.skills).toContainEqual( + expect.objectContaining({ + name: "alpha", + modelVisible: true, + }), + ); + expect(report.skills).toContainEqual( + expect.objectContaining({ + name: "beta", + blockedByAgentFilter: true, + }), + ); expect(report.skills.find((skill) => skill.name === "beta")?.modelVisible).toBe(false); }); diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index a7ef99166ed..5e66ba85a97 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -59,7 +59,7 @@ describe("inter-session lastRoute preservation (fixes #54441)", () => { isInterSession: true, }); // No external route existed — falls through to normal resolution (webchat or undefined). - expect(result === "webchat" || result === undefined).toBe(true); + expect(["webchat", undefined]).toContain(result); }); it("inter-session on session with no persisted lastTo preserves session route", () => { @@ -73,7 +73,7 @@ describe("inter-session lastRoute preservation (fixes #54441)", () => { isInterSession: true, }); // No external route — falls through to normal resolution - expect(result === "session:somekey" || result === undefined).toBe(true); + expect(["session:somekey", undefined]).toContain(result); }); }); From 270421f3dac63daac0c25e162da835292ad5b806 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:57:08 +0100 Subject: [PATCH 213/806] test: clarify secrets audit findings --- src/secrets/audit.test.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index fd38e7cecc3..43564a141fa 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -220,8 +220,12 @@ describe("secrets audit", () => { expect(report.status).toBe("findings"); expect(report.summary.plaintextCount).toBeGreaterThan(0); expect(report.summary.shadowedRefCount).toBeGreaterThan(0); - expect(hasFinding(report, (entry) => entry.code === "REF_SHADOWED")).toBe(true); - expect(hasFinding(report, (entry) => entry.code === "PLAINTEXT_FOUND")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "REF_SHADOWED" }), + expect.objectContaining({ code: "PLAINTEXT_FOUND" }), + ]), + ); }); it("does not mutate legacy auth.json during audit", async () => { @@ -234,7 +238,9 @@ describe("secrets audit", () => { }); const report = await runSecretsAudit({ env: fixture.env }); - expect(hasFinding(report, (entry) => entry.code === "LEGACY_RESIDUE")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "LEGACY_RESIDUE" })]), + ); const authJsonStat = await fs.stat(fixture.authJsonPath); expect(authJsonStat.isFile()).toBe(true); await expect(fs.stat(fixture.authStorePath)).rejects.toMatchObject({ code: "ENOENT" }); @@ -245,9 +251,13 @@ describe("secrets audit", () => { await fs.writeFile(fixture.authJsonPath, "{invalid-json", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); - expect(hasFinding(report, (entry) => entry.file === fixture.authStorePath)).toBe(true); - expect(hasFinding(report, (entry) => entry.file === fixture.authJsonPath)).toBe(true); - expect(hasFinding(report, (entry) => entry.code === "REF_UNRESOLVED")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: fixture.authStorePath }), + expect.objectContaining({ file: fixture.authJsonPath }), + expect.objectContaining({ code: "REF_UNRESOLVED" }), + ]), + ); }); it("skips exec ref resolution during audit unless explicitly allowed", async () => { From 4935ab1ff0ad3cf939ef5faaa4a181b7780b775c Mon Sep 17 00:00:00 2001 From: Ruben Cuevas Date: Sat, 25 Apr 2026 14:43:06 -0400 Subject: [PATCH 214/806] fix(telegram): log inbound gateway watch messages --- extensions/telegram/src/bot-message.test.ts | 82 +++++++++++++++++---- extensions/telegram/src/bot-message.ts | 39 +++++++++- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts index 56bf99da122..9743cbbf15d 100644 --- a/extensions/telegram/src/bot-message.test.ts +++ b/extensions/telegram/src/bot-message.test.ts @@ -3,10 +3,22 @@ import type { TelegramBotDeps } from "./bot-deps.js"; const buildTelegramMessageContext = vi.hoisted(() => vi.fn()); const dispatchTelegramMessage = vi.hoisted(() => vi.fn()); +const telegramInboundInfo = vi.hoisted(() => vi.fn()); const upsertChannelPairingRequest = vi.hoisted(() => vi.fn(async () => ({ code: "PAIRCODE", created: true })), ); +vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ + createSubsystemLogger: () => ({ + child: () => ({ + info: telegramInboundInfo, + }), + }), + danger: (message: string) => message, + logVerbose: vi.fn(), + shouldLogVerbose: () => false, +})); + vi.mock("./bot-message-context.js", () => ({ buildTelegramMessageContext, })); @@ -16,15 +28,18 @@ vi.mock("./bot-message-dispatch.js", () => ({ })); let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor; +let formatTelegramInboundLogLine: typeof import("./bot-message.js").formatTelegramInboundLogLine; describe("telegram bot message processor", () => { beforeAll(async () => { - ({ createTelegramMessageProcessor } = await import("./bot-message.js")); + ({ createTelegramMessageProcessor, formatTelegramInboundLogLine } = + await import("./bot-message.js")); }); beforeEach(() => { buildTelegramMessageContext.mockClear(); dispatchTelegramMessage.mockClear(); + telegramInboundInfo.mockClear(); upsertChannelPairingRequest.mockClear(); }); @@ -76,10 +91,7 @@ describe("telegram bot message processor", () => { sendMessage: ReturnType, ) { const runtimeError = vi.fn(); - buildTelegramMessageContext.mockResolvedValue({ - sendTyping: vi.fn().mockResolvedValue(undefined), - ...context, - }); + buildTelegramMessageContext.mockResolvedValue(createMessageContext(context)); dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded")); const processMessage = createTelegramMessageProcessor({ ...baseDeps, @@ -89,13 +101,29 @@ describe("telegram bot message processor", () => { return { processMessage, runtimeError }; } + function createMessageContext(context: Record = {}) { + return { + chatId: 123, + ctxPayload: { + From: "telegram:123", + To: "telegram:123", + ChatType: "direct", + RawBody: "hello there", + }, + primaryCtx: { me: { username: "openclaw_bot" } }, + route: { sessionKey: "agent:main:main" }, + sendTyping: vi.fn().mockResolvedValue(undefined), + ...context, + }; + } + it("dispatches when context is available", async () => { const sendTyping = vi.fn().mockResolvedValue(undefined); - buildTelegramMessageContext.mockResolvedValue({ - chatId: 123, - route: { sessionKey: "agent:main:main" }, - sendTyping, - }); + buildTelegramMessageContext.mockResolvedValue( + createMessageContext({ + sendTyping, + }), + ); const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); @@ -105,6 +133,9 @@ describe("telegram bot message processor", () => { expect(sendTyping.mock.invocationCallOrder[0]).toBeLessThan( dispatchTelegramMessage.mock.invocationCallOrder[0], ); + expect(telegramInboundInfo).toHaveBeenCalledWith( + "Inbound message telegram:123 -> @openclaw_bot (direct, 11 chars)", + ); }); it("skips dispatch when no context is produced", async () => { @@ -112,15 +143,36 @@ describe("telegram bot message processor", () => { const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); expect(dispatchTelegramMessage).not.toHaveBeenCalled(); + expect(telegramInboundInfo).not.toHaveBeenCalled(); + }); + + it("formats Telegram inbound summaries without message content", () => { + expect( + formatTelegramInboundLogLine({ + from: "telegram:123", + to: "@openclaw_bot", + chatType: "direct", + body: "secret message", + }), + ).toBe("Inbound message telegram:123 -> @openclaw_bot (direct, 14 chars)"); + expect( + formatTelegramInboundLogLine({ + from: "telegram:group:-100", + to: "@openclaw_bot", + chatType: "group", + body: "", + mediaType: "image/jpeg", + }), + ).toBe("Inbound message telegram:group:-100 -> @openclaw_bot (group, image/jpeg, 13 chars)"); }); it("keeps dispatch running when the early typing cue fails", async () => { const sendTyping = vi.fn().mockRejectedValue(new Error("typing failed")); - buildTelegramMessageContext.mockResolvedValue({ - chatId: 123, - route: { sessionKey: "agent:main:main" }, - sendTyping, - }); + buildTelegramMessageContext.mockResolvedValue( + createMessageContext({ + sendTyping, + }), + ); const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index f69d2c561b2..1cbf929f2f0 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -1,6 +1,11 @@ import type { ReplyToMode } from "openclaw/plugin-sdk/config-types"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-types"; -import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + createSubsystemLogger, + danger, + logVerbose, + shouldLogVerbose, +} from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramBotDeps } from "./bot-deps.js"; import { @@ -15,6 +20,23 @@ import { buildTelegramThreadParams } from "./bot/helpers.js"; import type { TelegramContext, TelegramStreamMode } from "./bot/types.js"; import type { TelegramReplyChainEntry } from "./message-cache.js"; +const telegramInboundLog = createSubsystemLogger("gateway/channels/telegram").child("inbound"); + +export function formatTelegramInboundLogLine(params: { + from?: string; + to?: string; + chatType?: string; + body?: string; + mediaType?: string; +}): string { + const from = params.from || "unknown"; + const to = params.to || "telegram"; + const chatType = params.chatType || "direct"; + const kindLabel = params.mediaType ? `, ${params.mediaType}` : ""; + const length = (params.body ?? "").length; + return `Inbound message ${from} -> ${to} (${chatType}${kindLabel}, ${length} chars)`; +} + /** Dependencies injected once when creating the message processor. */ type TelegramMessageProcessorDeps = Omit< BuildTelegramMessageContextParams, @@ -113,6 +135,21 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep void context.sendTyping().catch((err) => { logVerbose(`telegram early typing cue failed for chat ${context.chatId}: ${String(err)}`); }); + telegramInboundLog.info( + formatTelegramInboundLogLine({ + from: context.ctxPayload.From, + to: context.primaryCtx.me?.username + ? `@${context.primaryCtx.me.username}` + : context.ctxPayload.To, + chatType: context.ctxPayload.ChatType, + body: + context.ctxPayload.RawBody ?? + context.ctxPayload.BodyForCommands ?? + context.ctxPayload.BodyForAgent ?? + context.ctxPayload.Body, + mediaType: allMedia[0]?.contentType, + }), + ); try { await dispatchTelegramMessage({ context, From 227e252a58b3735792adda0b02cc5b675a04177f Mon Sep 17 00:00:00 2001 From: Ruben Cuevas Date: Sat, 2 May 2026 23:12:19 -0400 Subject: [PATCH 215/806] docs: add Telegram changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 225767eb45a..cd1bbd3815f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -869,6 +869,7 @@ Docs: https://docs.openclaw.ai - Agents/idle-timeout: add a cost-runaway breaker to the outer embedded-run retry loop that halts further attempts after 5 consecutive idle timeouts without completed model progress, so a wedged provider can no longer fan paid model calls out across the same run; completed text or tool-call progress resets the breaker, but partial tool-argument token dribbles do not. Fixes #76293. Thanks @ThePuma312. - Heartbeats/Codex: align structured heartbeat prompts with actual `heartbeat_respond` tool availability, stop sending legacy `HEARTBEAT_OK` when the tool exists, and keep tool-disabled commitment check-ins on the legacy ack path. Thanks @pashpashpash and @vincentkoc. - Agent runtimes: fail explicit plugin runtime selections honestly when the requested harness is unavailable instead of silently falling back to the embedded PI runtime. Thanks @pashpashpash. +- Telegram: log inbound gateway watch messages before dispatch so watch-mode diagnostics include incoming message summaries. Thanks @rubencu. - Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev. - Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79. - Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli. From 12e885da5f6935b88812bd6a910bf09d03e3457b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 18:17:45 +0530 Subject: [PATCH 216/806] refactor(telegram): simplify inbound watch log formatting --- .../src/bot-message-context.session.ts | 9 ++++++- extensions/telegram/src/bot-message.ts | 25 ++++++------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 930e5ce4b11..e7d7da22046 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -45,6 +45,13 @@ type FinalizedTelegramInboundContext = ReturnType< typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext >; +export type TelegramInboundContextPayload = FinalizedTelegramInboundContext & { + From: string; + To: string; + ChatType: string; + RawBody: string; +}; + type TelegramMessageContextSessionRuntime = typeof import("./bot-message-context.session.runtime.js"); @@ -175,7 +182,7 @@ export async function buildTelegramInboundContextPayload(params: { topicName?: string; sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides; }): Promise<{ - ctxPayload: FinalizedTelegramInboundContext; + ctxPayload: TelegramInboundContextPayload; skillFilter: string[] | undefined; turn: { storePath: string; diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 1cbf929f2f0..544ab337106 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -23,21 +23,16 @@ import type { TelegramReplyChainEntry } from "./message-cache.js"; const telegramInboundLog = createSubsystemLogger("gateway/channels/telegram").child("inbound"); export function formatTelegramInboundLogLine(params: { - from?: string; - to?: string; - chatType?: string; - body?: string; + from: string; + to: string; + chatType: string; + body: string; mediaType?: string; }): string { - const from = params.from || "unknown"; - const to = params.to || "telegram"; - const chatType = params.chatType || "direct"; const kindLabel = params.mediaType ? `, ${params.mediaType}` : ""; - const length = (params.body ?? "").length; - return `Inbound message ${from} -> ${to} (${chatType}${kindLabel}, ${length} chars)`; + return `Inbound message ${params.from} -> ${params.to} (${params.chatType}${kindLabel}, ${params.body.length} chars)`; } -/** Dependencies injected once when creating the message processor. */ type TelegramMessageProcessorDeps = Omit< BuildTelegramMessageContextParams, "primaryCtx" | "allMedia" | "storeAllowFrom" | "options" @@ -142,11 +137,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep ? `@${context.primaryCtx.me.username}` : context.ctxPayload.To, chatType: context.ctxPayload.ChatType, - body: - context.ctxPayload.RawBody ?? - context.ctxPayload.BodyForCommands ?? - context.ctxPayload.BodyForAgent ?? - context.ctxPayload.Body, + body: context.ctxPayload.RawBody, mediaType: allMedia[0]?.contentType, }), ); @@ -177,9 +168,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep "Something went wrong while processing your request. Please try again.", buildTelegramThreadParams(context.threadSpec), ); - } catch { - // Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid. - } + } catch {} } }; }; From 5c589673ec086949a450b8cf82b7fd48539a27bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:00:19 +0100 Subject: [PATCH 217/806] test: clarify loose boolean assertions --- extensions/acpx/src/codex-auth-bridge.test.ts | 6 +++++- src/agents/shell-utils.test.ts | 6 +++++- src/docker-build-cache.test.ts | 6 +++++- src/plugins/contracts/config-footprint-guardrails.test.ts | 4 +++- src/process/supervisor/supervisor.test.ts | 2 +- src/tui/tui.test.ts | 6 +++++- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 18176d39259..f2d926a93f8 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -69,7 +69,11 @@ function expectWrapperToContainPathSuffix(wrapper: string, pathSuffix: string[]) const nativeSuffix = pathSuffix.join(path.sep); const escapedNativeSuffix = JSON.stringify(nativeSuffix).slice(1, -1); const posixSuffix = pathSuffix.join("/"); - expect(wrapper.includes(escapedNativeSuffix) || wrapper.includes(posixSuffix)).toBe(true); + if (wrapper.includes(escapedNativeSuffix)) { + expect(wrapper).toContain(escapedNativeSuffix); + } else { + expect(wrapper).toContain(posixSuffix); + } } afterEach(async () => { diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 6a1926016b6..9cba4c43057 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -48,7 +48,11 @@ describe("getShellConfig", () => { it("uses PowerShell on Windows", () => { const { shell, args } = getShellConfig(); const normalized = shell.toLowerCase(); - expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true); + if (normalized.includes("powershell")) { + expect(normalized).toContain("powershell"); + } else { + expect(normalized).toContain("pwsh"); + } expect(args).toEqual(["-NoProfile", "-NonInteractive", "-Command"]); }); return; diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index f9933767984..3865312e61d 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -53,7 +53,11 @@ describe("docker build cache layout", () => { expect(installIndex).toBeGreaterThan(-1); expect(copyAllIndex).toBeGreaterThan(installIndex); - expect(scriptsCopyIndex === -1 || scriptsCopyIndex > installIndex).toBe(true); + if (scriptsCopyIndex === -1) { + expect(scriptsCopyIndex).toBe(-1); + } else { + expect(scriptsCopyIndex).toBeGreaterThan(installIndex); + } }); it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => { diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 6369d227d7d..5bda4273b13 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -53,7 +53,9 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { } function asRecord(value: unknown): Record { - expect(value && typeof value === "object" && !Array.isArray(value)).toBe(true); + expect(value).not.toBeNull(); + expect(typeof value).toBe("object"); + expect(Array.isArray(value)).toBe(false); return value as Record; } diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 2f72685b3df..74661876819 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -200,7 +200,7 @@ describe("process supervisor", () => { const firstExit = await firstRun.wait(); const secondExit = await secondRun.wait(); expect(first.killMock).toHaveBeenCalledWith("SIGKILL"); - expect(firstExit.reason === "manual-cancel" || firstExit.reason === "signal").toBe(true); + expect(["manual-cancel", "signal"]).toContain(firstExit.reason); expect(secondExit.reason).toBe("exit"); expect(secondExit.stdout).toBe("new"); }); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 45163e32ae5..f7542d8840c 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -395,7 +395,11 @@ describe("resolveCodexCliBin", () => { it("returns null or a valid path (never throws)", () => { const result = resolveCodexCliBin(); - expect(result === null || typeof result === "string").toBe(true); + if (result === null) { + expect(result).toBeNull(); + } else { + expect(typeof result).toBe("string"); + } }); }); From 30e079dd89b451ca22cb360ca887bd9367cc7939 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 18:09:15 +0530 Subject: [PATCH 218/806] fix(channels): honor reasoning defaults in previews (#71817) (thanks @anagnorisis2peripeteia) --- CHANGELOG.md | 1 + docs/tools/thinking.md | 2 +- extensions/feishu/src/agent-config.ts | 21 ++++++++ extensions/feishu/src/bot.ts | 4 ++ .../feishu/src/reasoning-preview.test.ts | 49 +++++++++++++++++++ extensions/feishu/src/reasoning-preview.ts | 18 +++++-- extensions/telegram/src/agent-config.ts | 21 ++++++++ .../telegram/src/bot-message-dispatch.test.ts | 37 ++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 10 ++-- 9 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 extensions/feishu/src/agent-config.ts create mode 100644 extensions/telegram/src/agent-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1bbd3815f..2cce549543a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. - Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 1ad5e01eac3..0968bf25ea4 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -106,7 +106,7 @@ title: "Thinking levels" - `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning. - Alias: `/reason`. - Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level. -- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then fallback (`off`). +- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then global default (`agents.defaults.reasoningDefault`), then fallback (`off`). Malformed local-model reasoning tags are handled conservatively. Closed `...` blocks stay hidden on normal replies, and unclosed reasoning after already visible text is also hidden. If a reply is fully wrapped in a single unclosed opening tag and would otherwise deliver as empty text, OpenClaw removes the malformed opening tag and delivers the remaining text. diff --git a/extensions/feishu/src/agent-config.ts b/extensions/feishu/src/agent-config.ts new file mode 100644 index 00000000000..ca5ab8ea810 --- /dev/null +++ b/extensions/feishu/src/agent-config.ts @@ -0,0 +1,21 @@ +import type { ClawdbotConfig } from "./bot-runtime-api.js"; + +type ReasoningDefault = "on" | "stream" | "off"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "").trim().toLowerCase(); + return normalized || DEFAULT_AGENT_ID; +} + +export function resolveFeishuConfigReasoningDefault( + cfg: ClawdbotConfig, + agentId: string, +): ReasoningDefault { + const id = normalizeAgentId(agentId); + const agentDefault = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry?.id) === id, + )?.reasoningDefault; + return agentDefault ?? cfg.agents?.defaults?.reasoningDefault ?? "off"; +} diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 4f7376cb55f..52920116c48 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1357,6 +1357,8 @@ export async function handleFeishuMessage(params: { }, }; const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId, storePath: agentStorePath, sessionKey: agentSessionKey, }); @@ -1532,6 +1534,8 @@ export async function handleFeishuMessage(params: { agentId: route.agentId, }); const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: route.agentId, storePath, sessionKey: route.sessionKey, }); diff --git a/extensions/feishu/src/reasoning-preview.test.ts b/extensions/feishu/src/reasoning-preview.test.ts index c6bf99c9b2a..49f6b8e798c 100644 --- a/extensions/feishu/src/reasoning-preview.test.ts +++ b/extensions/feishu/src/reasoning-preview.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "./bot-runtime-api.js"; import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js"; const { loadSessionStoreMock } = vi.hoisted(() => ({ @@ -20,6 +21,8 @@ afterAll(() => { }); describe("resolveFeishuReasoningPreviewEnabled", () => { + const emptyCfg: ClawdbotConfig = {}; + beforeEach(() => { vi.clearAllMocks(); }); @@ -32,12 +35,16 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_1", }), ).toBe(true); expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_2", }), @@ -51,14 +58,56 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_1", }), ).toBe(false); expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", }), ).toBe(false); }); + + it("falls back to configured stream defaults", () => { + loadSessionStoreMock.mockReturnValue({ + "agent:main:feishu:dm:ou_sender_1": {}, + "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" }, + }); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { reasoningDefault: "stream" }, + list: [{ id: "Ops", reasoningDefault: "off" }], + }, + }; + + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "main", + storePath: "/tmp/feishu-sessions.json", + sessionKey: "agent:main:feishu:dm:ou_sender_1", + }), + ).toBe(true); + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "ops", + storePath: "/tmp/feishu-sessions.json", + }), + ).toBe(false); + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "main", + storePath: "/tmp/feishu-sessions.json", + sessionKey: "agent:main:feishu:dm:ou_sender_2", + }), + ).toBe(false); + }); }); diff --git a/extensions/feishu/src/reasoning-preview.ts b/extensions/feishu/src/reasoning-preview.ts index 4f752b840a4..93ecccc4591 100644 --- a/extensions/feishu/src/reasoning-preview.ts +++ b/extensions/feishu/src/reasoning-preview.ts @@ -1,20 +1,28 @@ +import { resolveFeishuConfigReasoningDefault } from "./agent-config.js"; import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js"; +import type { ClawdbotConfig } from "./bot-runtime-api.js"; export function resolveFeishuReasoningPreviewEnabled(params: { + cfg: ClawdbotConfig; + agentId: string; storePath: string; sessionKey?: string; }): boolean { + const configDefault = resolveFeishuConfigReasoningDefault(params.cfg, params.agentId); + if (!params.sessionKey) { - return false; + return configDefault === "stream"; } try { const store = loadSessionStore(params.storePath, { skipCache: true }); - return ( - resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing - ?.reasoningLevel === "stream" - ); + const level = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing + ?.reasoningLevel; + if (level === "on" || level === "stream" || level === "off") { + return level === "stream"; + } } catch { return false; } + return configDefault === "stream"; } diff --git a/extensions/telegram/src/agent-config.ts b/extensions/telegram/src/agent-config.ts new file mode 100644 index 00000000000..74cb9da2a89 --- /dev/null +++ b/extensions/telegram/src/agent-config.ts @@ -0,0 +1,21 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; + +type ReasoningDefault = "on" | "stream" | "off"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "").trim().toLowerCase(); + return normalized || DEFAULT_AGENT_ID; +} + +export function resolveTelegramConfigReasoningDefault( + cfg: OpenClawConfig, + agentId: string, +): ReasoningDefault { + const id = normalizeAgentId(agentId); + const agentDefault = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry?.id) === id, + )?.reasoningDefault; + return agentDefault ?? cfg.agents?.defaults?.reasoningDefault ?? "off"; +} diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 5f1e22d75af..f824b407ca3 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -409,6 +409,16 @@ describe("dispatchTelegramMessage draft streaming", () => { }); } + function createReasoningDefaultContext(): TelegramMessageContext { + loadSessionStore.mockReturnValue({ + s1: {}, + }); + return createContext({ + ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], + route: { agentId: "ops" } as unknown as TelegramMessageContext["route"], + }); + } + it("streams drafts in private threads and forwards thread id", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); @@ -1149,6 +1159,33 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("streams reasoning from configured defaults", async () => { + const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ + answerMessageId: 2001, + reasoningMessageId: 3001, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onReasoningStream?.({ text: "Thinking" }); + await dispatcherOptions.deliver({ text: "Answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createReasoningDefaultContext(), + cfg: { + agents: { + defaults: { reasoningDefault: "off" }, + list: [{ id: "Ops", reasoningDefault: "stream" }], + }, + }, + }); + + expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_Thinking_"); + expect(answerDraftStream.update).toHaveBeenCalledWith("Answer"); + }); + it("suppresses reasoning-only finals without raw text fallback", async () => { setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a61bf47adf0..5cb0388ca5c 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -42,6 +42,7 @@ import { logVerbose, sleepWithAbort, } from "openclaw/plugin-sdk/runtime-env"; +import { resolveTelegramConfigReasoningDefault } from "./agent-config.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -214,8 +215,9 @@ function resolveTelegramReasoningLevel(params: { telegramDeps: TelegramBotDeps; }): TelegramReasoningLevel { const { cfg, sessionKey, agentId, telegramDeps } = params; + const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId); if (!sessionKey) { - return "off"; + return configDefault; } try { const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId }); @@ -224,13 +226,13 @@ function resolveTelegramReasoningLevel(params: { }); const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; const level = entry?.reasoningLevel; - if (level === "on" || level === "stream") { + if (level === "on" || level === "stream" || level === "off") { return level; } } catch { - // Fall through to default. + return "off"; } - return "off"; + return configDefault; } const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300; From 12aa508f98b6f8b022c0fde8c8df140aa718f786 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:02:55 +0100 Subject: [PATCH 219/806] test: clarify qa host env assertions --- extensions/qa-lab/src/gateway-child.test.ts | 14 +++++++++++--- .../host-env-security.reported-baseline.test.ts | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index b984e088621..8157ccca89e 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -590,7 +590,7 @@ describe("buildQaRuntimeEnv", () => { expect(processKill).toHaveBeenCalledWith(-12345, "SIGTERM"); expect(processKill).toHaveBeenCalledWith(-12345, "SIGKILL"); } - expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect([child.exitCode, child.signalCode]).not.toEqual([null, null]); }); it("treats bind collisions as retryable gateway startup errors", () => { @@ -999,7 +999,11 @@ describe("qa bundled plugin dir", () => { "shared-chunk-abc123.js", ), ); - expect(sharedChunkStat.isFile() || sharedChunkStat.isSymbolicLink()).toBe(true); + if (sharedChunkStat.isFile()) { + expect(sharedChunkStat.isFile()).toBe(true); + } else { + expect(sharedChunkStat.isSymbolicLink()).toBe(true); + } }); it("preserves dist-runtime-only root chunks when dist also exists", async () => { @@ -1074,7 +1078,11 @@ describe("qa bundled plugin dir", () => { "runtime-chunk.js", ), ); - expect(runtimeChunkStat.isFile() || runtimeChunkStat.isSymbolicLink()).toBe(true); + if (runtimeChunkStat.isFile()) { + expect(runtimeChunkStat.isFile()).toBe(true); + } else { + expect(runtimeChunkStat.isSymbolicLink()).toBe(true); + } }); it("rejects invalid bundled plugin ids before staging paths are built", async () => { diff --git a/src/infra/host-env-security.reported-baseline.test.ts b/src/infra/host-env-security.reported-baseline.test.ts index 99a818e265f..c9d8869cc6e 100644 --- a/src/infra/host-env-security.reported-baseline.test.ts +++ b/src/infra/host-env-security.reported-baseline.test.ts @@ -158,7 +158,9 @@ describe("host env reported baseline coverage", () => { for (const key of expectedAllowlistKeys) { expect(INHERITED_ALLOWLIST_RATIONALE[key].trim().length).toBeGreaterThan(0); expect(isDangerousHostInheritedEnvVarName(key)).toBe(false); - expect(isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)).toBe(true); + expect([isDangerousHostEnvVarName(key), isDangerousHostEnvOverrideVarName(key)]).toContain( + true, + ); const inheritedSanitized = sanitizeHostExecEnv({ baseEnv: { From 2340e2a58140c0b8c1d43c743e06410ebdfa45c4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 14:04:11 +0100 Subject: [PATCH 220/806] test: stabilize interactive respawn assertion --- src/entry.respawn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts index 42c635d58e3..8d5eb8b940f 100644 --- a/src/entry.respawn.test.ts +++ b/src/entry.respawn.test.ts @@ -60,7 +60,7 @@ describe("buildCliRespawnPlan", () => { expect( buildCliRespawnPlan({ argv: ["node", "openclaw", "tui"], - env: {}, + env: { [OPENCLAW_NODE_EXTRA_CA_CERTS_READY]: "1" }, execArgv: [], autoNodeExtraCaCerts: undefined, }), From 731814ca7e23e7156e1947d48145222d3caf7941 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 28 Apr 2026 21:01:40 -0700 Subject: [PATCH 221/806] fix(memory): preserve memory flush compaction count --- CHANGELOG.md | 1 + .../reply/agent-runner-memory.test.ts | 2 +- src/auto-reply/reply/agent-runner-memory.ts | 8 +++--- src/auto-reply/reply/reply-state.test.ts | 26 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cce549543a..ca15ebf17b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,7 @@ Docs: https://docs.openclaw.ai - Docs/Docker: document a local Compose override for Docker Desktop DNS failures in the shared-network `openclaw-cli` sidecar, keeping the default compose setup hardened while unblocking `openclaw plugins install` when users opt in. Fixes #79018. Thanks @Jason-Vaughan. - Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762. - Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews. +- Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590; carries forward #51421 and refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 795af1e4279..bacdd6fe975 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -180,7 +180,7 @@ describe("runMemoryFlushIfNeeded", () => { }; expect(persisted.main.sessionId).toBe("session-rotated"); expect(persisted.main.compactionCount).toBe(2); - expect(persisted.main.memoryFlushCompactionCount).toBe(2); + expect(persisted.main.memoryFlushCompactionCount).toBe(1); expect(persisted.main.memoryFlushAt).toBe(1_700_000_000_000); }); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 76c7f78af41..8c54ae38c8e 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -968,13 +968,13 @@ export async function runMemoryFlushIfNeeded(params: { return result; }, }); - let memoryFlushCompactionCount = + const memoryFlushCompactionCount = activeSessionEntry?.compactionCount ?? (params.sessionKey ? activeSessionStore?.[params.sessionKey]?.compactionCount : 0) ?? 0; if (memoryCompactionCompleted) { const previousSessionId = activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId; - const nextCount = await memoryDeps.incrementCompactionCount({ + await memoryDeps.incrementCompactionCount({ cfg: params.cfg, sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, @@ -1001,9 +1001,7 @@ export async function runMemoryFlushIfNeeded(params: { }); } } - if (typeof nextCount === "number") { - memoryFlushCompactionCount = nextCount; - } + // Persist the pre-increment count so the next compaction cycle remains eligible to flush. } if (params.storePath && params.sessionKey) { try { diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 35ef750db99..eccae974a2c 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -293,6 +293,32 @@ describe("shouldRunMemoryFlush", () => { ).toBe(true); }); + it("runs on consecutive compaction cycles when flush records the pre-increment count", () => { + const params = { + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }; + + expect( + shouldRunMemoryFlush({ entry: { totalTokens: 95_000, compactionCount: 1 }, ...params }), + ).toBe(true); + + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 95_000, compactionCount: 2, memoryFlushCompactionCount: 1 }, + ...params, + }), + ).toBe(true); + + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 95_000, compactionCount: 3, memoryFlushCompactionCount: 2 }, + ...params, + }), + ).toBe(true); + }); + it("ignores stale cached totals", () => { expect( shouldRunMemoryFlush({ From f2c813cb31a690502ef55ac1ca44f9f54658880d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 18:27:34 +0530 Subject: [PATCH 222/806] refactor(memory): simplify memory flush counter --- CHANGELOG.md | 2 +- src/auto-reply/reply/agent-runner-memory.ts | 5 ++--- src/auto-reply/reply/reply-state.test.ts | 24 ++++++--------------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca15ebf17b1..ab4d0a2cfa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,7 +231,7 @@ Docs: https://docs.openclaw.ai - Docs/Docker: document a local Compose override for Docker Desktop DNS failures in the shared-network `openclaw-cli` sidecar, keeping the default compose setup hardened while unblocking `openclaw plugins install` when users opt in. Fixes #79018. Thanks @Jason-Vaughan. - Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762. - Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews. -- Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590; carries forward #51421 and refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. +- Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590. Refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 8c54ae38c8e..4e821883566 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -968,7 +968,7 @@ export async function runMemoryFlushIfNeeded(params: { return result; }, }); - const memoryFlushCompactionCount = + const flushedCompactionCount = activeSessionEntry?.compactionCount ?? (params.sessionKey ? activeSessionStore?.[params.sessionKey]?.compactionCount : 0) ?? 0; @@ -1001,7 +1001,6 @@ export async function runMemoryFlushIfNeeded(params: { }); } } - // Persist the pre-increment count so the next compaction cycle remains eligible to flush. } if (params.storePath && params.sessionKey) { try { @@ -1010,7 +1009,7 @@ export async function runMemoryFlushIfNeeded(params: { sessionKey: params.sessionKey, update: async () => ({ memoryFlushAt: memoryDeps.now(), - memoryFlushCompactionCount, + memoryFlushCompactionCount: flushedCompactionCount, }), }); if (updatedEntry) { diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index eccae974a2c..4d242382927 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -300,23 +300,13 @@ describe("shouldRunMemoryFlush", () => { softThresholdTokens: 2_000, }; - expect( - shouldRunMemoryFlush({ entry: { totalTokens: 95_000, compactionCount: 1 }, ...params }), - ).toBe(true); - - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 95_000, compactionCount: 2, memoryFlushCompactionCount: 1 }, - ...params, - }), - ).toBe(true); - - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 95_000, compactionCount: 3, memoryFlushCompactionCount: 2 }, - ...params, - }), - ).toBe(true); + for (const entry of [ + { totalTokens: 95_000, compactionCount: 1 }, + { totalTokens: 95_000, compactionCount: 2, memoryFlushCompactionCount: 1 }, + { totalTokens: 95_000, compactionCount: 3, memoryFlushCompactionCount: 2 }, + ]) { + expect(shouldRunMemoryFlush({ entry, ...params })).toBe(true); + } }); it("ignores stale cached totals", () => { From be28fdcb606f30580799b22a2fab5dce7ee71839 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:05:55 +0100 Subject: [PATCH 223/806] test: clarify live loose boolean assertions --- src/agents/xai.live.test.ts | 3 ++- src/gateway/android-node.capabilities.live.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index b0ea88a9442..2201ccbdeee 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -130,7 +130,8 @@ describeLive("xai live", () => { : []; expect(payloadTools.length).toBeGreaterThan(0); const firstFunction = payloadTools[0]?.function; - expect(firstFunction && typeof firstFunction === "object").toBe(true); + expect(firstFunction).not.toBeNull(); + expect(typeof firstFunction).toBe("object"); expect([undefined, false]).toContain((firstFunction as Record).strict); }); }, 90_000); diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index e845365a07f..fdfe045538c 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -238,7 +238,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("sms.search", payload); - expect(typeof obj.count === "number" || typeof obj.count === "string").toBe(true); + expect(["number", "string"]).toContain(typeof obj.count); expect(Array.isArray(obj.messages)).toBe(true); }, }, From e7277b4e3a4bebc21ec603495a2604faf4c53c17 Mon Sep 17 00:00:00 2001 From: Super Zheng Date: Fri, 8 May 2026 21:08:21 +0800 Subject: [PATCH 224/806] refactor(agents): preserve raw reasoning stream and push formatting to edge (#78397) Merged via squash. Prepared head SHA: bb56f7ee000d6ea3334d378d6f5f209e086f7201 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0 --- CHANGELOG.md | 1 + .../monitor/message-handler.process.test.ts | 10 ++-- .../src/monitor/message-handler.process.ts | 11 ++++- .../feishu/src/reply-dispatcher.test.ts | 9 ++-- extensions/feishu/src/reply-dispatcher.ts | 7 ++- .../src/mattermost/reply-delivery.test.ts | 2 +- .../dispatch.preview-fallback.test.ts | 2 +- .../telegram/src/bot-message-dispatch.ts | 15 +++--- .../src/reasoning-lane-coordinator.ts | 9 +++- .../src/auto-reply/deliver-reply.test.ts | 2 +- .../monitor/inbound-dispatch.test.ts | 2 +- src/agents/pi-embedded-runner/run/payloads.ts | 3 +- ...pi-embedded-subscribe.handlers.messages.ts | 13 +++-- ...soning-as-separate-message-enabled.test.ts | 2 +- ...session.subscribeembeddedpisession.test.ts | 47 ++++++++++++++++++- src/agents/pi-embedded-subscribe.ts | 17 ++++--- .../reply/dispatch-from-config.test.ts | 6 +-- src/auto-reply/reply/reply-utils.test.ts | 4 +- src/auto-reply/reply/route-reply.test.ts | 2 +- src/daemon/service-env.test.ts | 4 +- .../server-methods/chat-webchat-media.test.ts | 2 +- .../chat.directive-tags.test.ts | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 14 ++++-- src/infra/heartbeat-runner.ts | 23 +++++++-- 24 files changed, 146 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4d0a2cfa9..3a7bbe85929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -648,6 +648,7 @@ Docs: https://docs.openclaw.ai - Browser/downloads: route explicit and managed browser download output directories through `fs-safe` validation before staging final files, so symlinked output roots are rejected before writes. (#78780) Thanks @jesse-merhi. - Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. - Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. +- Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. ## 2026.5.3-1 diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3f738cc3229..16343b8c7b4 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1725,8 +1725,8 @@ describe("processDiscordMessage draft streaming", () => { kind: "analysis", title: "Reasoning", }); - await params?.replyOptions?.onReasoningStream?.({ text: "Reading " }); - await params?.replyOptions?.onReasoningStream?.({ text: "the event projector" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Reading" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Reading the event projector" }); return createNoQueuedDispatchResult(); }); @@ -1744,7 +1744,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n🛠️ Exec\n• Reading the event projector", + "Clawing...\n🛠️ Exec\n• _Reading the event projector_", ); expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Reasoning")); }); @@ -1754,9 +1754,9 @@ describe("processDiscordMessage draft streaming", () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_Checking files_" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Checking files" }); await params?.replyOptions?.onReasoningStream?.({ - text: "Reasoning:\n_Checking files and tests_", + text: "Checking files and tests", }); return createNoQueuedDispatchResult(); }); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 2810dc5f5bf..65324c88774 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,4 +1,8 @@ -import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + formatReasoningMessage, + resolveAckReaction, + resolveHumanDelayConfig, +} from "openclaw/plugin-sdk/agent-runtime"; import { createStatusReactionController, DEFAULT_TIMING, @@ -665,7 +669,10 @@ export async function processDiscordMessage( draftPreview.suppressDefaultToolProgressMessages ? true : undefined, onReasoningStream: async (payload) => { await statusReactions.setThinking(); - await draftPreview.pushReasoningProgress(payload?.text); + const formattedText = payload?.text + ? formatReasoningMessage(payload.text) + : undefined; + await draftPreview.pushReasoningProgress(formattedText); }, onToolStart: async (payload) => { if (isProcessAborted(abortSignal)) { diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 85d36c61721..66560972291 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -888,10 +888,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - // Core agent sends pre-formatted text from formatReasoningMessage - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" }); + result.replyOptions.onReasoningStream?.({ text: "thinking step 1" }); result.replyOptions.onReasoningStream?.({ - text: "Reasoning:\n_thinking step 1_\n_step 2_", + text: "thinking step 1\nstep 2", }); result.replyOptions.onPartialReply?.({ text: "answer part" }); result.replyOptions.onReasoningEnd?.(); @@ -967,7 +966,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" }); + result.replyOptions.onReasoningStream?.({ text: "deep thought" }); result.replyOptions.onReasoningEnd?.(); await options.onIdle?.(); @@ -1005,7 +1004,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" }); + result.replyOptions.onReasoningStream?.({ text: "thought" }); result.replyOptions.onReasoningEnd?.(); await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" }); await options.onIdle?.(); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 2c11c3e546d..9c56f9fa17e 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { @@ -522,7 +523,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP await typingCallbacks?.onReplyStart?.(); }, deliver: async (payload: ReplyPayload, info) => { - const reply = resolveSendableOutboundReplyParts(payload); + const payloadText = + payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text; + const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText }); const text = reply.text; const hasText = reply.hasText; const hasMedia = reply.hasMedia; @@ -694,7 +697,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return; } startStreaming(); - queueReasoningUpdate(payload.text); + queueReasoningUpdate(formatReasoningMessage(payload.text)); } : undefined, onReasoningEnd: reasoningPreviewEnabled ? () => {} : undefined, diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts index 7714121c855..4d2828e20b1 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.test.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -46,7 +46,7 @@ describe("deliverMattermostReplyPayload", () => { await deliverMattermostReplyPayload({ core, cfg, - payload: { text: "Reasoning:\n_hidden_", isReasoning: true }, + payload: { text: "hidden", isReasoning: true }, to: "channel:town-square", accountId: "default", agentId: "agent-1", diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 689ee830b07..266df7bfd0b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -971,7 +971,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { it("suppresses reasoning payloads before Slack native streaming delivery", async () => { mockedNativeStreaming = true; mockedDispatchSequence = [ - { kind: "block", payload: { text: "Reasoning:\n_hidden_", isReasoning: true } }, + { kind: "block", payload: { text: "hidden", isReasoning: true } }, { kind: "final", payload: { text: FINAL_REPLY_TEXT } }, ]; diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 5cb0388ca5c..e5d9fb373c9 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -574,8 +574,11 @@ export const dispatchTelegramMessage = async ({ segments: SplitLaneSegment[]; suppressedReasoningOnly: boolean; }; - const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => { - const split = splitTelegramReasoningText(text); + const splitTextIntoLaneSegments = ( + text?: string, + isReasoning?: boolean, + ): SplitLaneSegmentsResult => { + const split = splitTelegramReasoningText(text, isReasoning); const segments: SplitLaneSegment[] = []; const suppressReasoning = resolvedReasoningLevel === "off"; if (split.reasoningText && !suppressReasoning) { @@ -637,8 +640,8 @@ export const dispatchTelegramMessage = async ({ lane.lastPartialText = text; laneStream.update(text); }; - const ingestDraftLaneSegments = async (text: string | undefined) => { - const split = splitTextIntoLaneSegments(text); + const ingestDraftLaneSegments = async (text: string | undefined, isReasoning?: boolean) => { + const split = splitTextIntoLaneSegments(text, isReasoning); for (const segment of split.segments) { if (segment.lane === "answer") { await prepareAnswerLaneForText(); @@ -1037,7 +1040,7 @@ export const dispatchTelegramMessage = async ({ | { buttons?: TelegramInlineButtons } | undefined )?.buttons; - const split = splitTextIntoLaneSegments(payload.text); + const split = splitTextIntoLaneSegments(payload.text, payload.isReasoning); const segments = split.segments; const reply = resolveSendableOutboundReplyParts(payload); const _hasMedia = reply.hasMedia; @@ -1192,7 +1195,7 @@ export const dispatchTelegramMessage = async ({ resetDraftLaneState(reasoningLane); splitReasoningOnNextStream = false; } - await ingestDraftLaneSegments(payload.text); + await ingestDraftLaneSegments(payload.text, true); }) : undefined, onAssistantMessageStart: answerLane.stream diff --git a/extensions/telegram/src/reasoning-lane-coordinator.ts b/extensions/telegram/src/reasoning-lane-coordinator.ts index 42acc89fb7f..b18e5f92db3 100644 --- a/extensions/telegram/src/reasoning-lane-coordinator.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.ts @@ -62,7 +62,10 @@ type TelegramReasoningSplit = { answerText?: string; }; -export function splitTelegramReasoningText(text?: string): TelegramReasoningSplit { +export function splitTelegramReasoningText( + text?: string, + isReasoning?: boolean, +): TelegramReasoningSplit { if (typeof text !== "string") { return {}; } @@ -81,6 +84,10 @@ export function splitTelegramReasoningText(text?: string): TelegramReasoningSpli const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text); const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + if (isReasoning === true) { + return { reasoningText: formatReasoningMessage(taggedReasoning || strippedAnswer || text) }; + } + if (!taggedReasoning && strippedAnswer === text) { return { answerText: text }; } diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index e745bbc7207..11cb361f0ac 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -161,7 +161,7 @@ describe("deliverWebReply", () => { }); it("suppresses payloads flagged as reasoning", async () => { - await expectReplySuppressed({ text: "Reasoning:\n_hidden_", isReasoning: true }); + await expectReplySuppressed({ text: "hidden", isReasoning: true }); }); it("suppresses payloads that start with reasoning prefix text", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 0a26be224b5..bea5848990e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -699,7 +699,7 @@ describe("whatsapp inbound dispatch", () => { const deliver = getCapturedDeliver(); expect(deliver).toBeTypeOf("function"); - await deliver?.({ text: "Reasoning:\n_hidden_", isReasoning: true }, { kind: "block" }); + await deliver?.({ text: "hidden", isReasoning: true }, { kind: "block" }); await deliver?.( { text: "🧹 Compacting context...", isCompactionNotice: true }, { kind: "block" }, diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 2499d83b9cd..ad7e014ebef 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -27,7 +27,6 @@ import type { ToolResultFormat } from "../../pi-embedded-subscribe.shared-types. import { extractAssistantThinking, extractAssistantVisibleText, - formatReasoningMessage, } from "../../pi-embedded-utils.js"; import { isExecLikeToolName, type ToolErrorSummary } from "../../tool-error-summary.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; @@ -283,7 +282,7 @@ export function buildEmbeddedRunPayloads(params: { const reasoningText = suppressAssistantArtifacts ? "" : params.lastAssistant && params.reasoningLevel === "on" && params.thinkingLevel !== "off" - ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) + ? extractAssistantThinking(params.lastAssistant) : ""; if (reasoningText) { replyItems.push({ text: reasoningText, isReasoning: true }); diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index a1c56e085ef..625e735bc15 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -34,7 +34,6 @@ import { extractAssistantVisibleText, extractThinkingFromTaggedStream, extractThinkingFromTaggedText, - formatReasoningMessage, promoteThinkingTagsToBlocks, } from "./pi-embedded-utils.js"; @@ -692,7 +691,7 @@ export function handleMessageEnd( ctx.state.includeReasoning || ctx.state.streamReasoning ? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText) : ""; - const formattedReasoning = rawThinking ? formatReasoningMessage(rawThinking) : ""; + const trimmedReasoning = rawThinking ? rawThinking.trim() : ""; const trimmedText = text.trim(); const parsedText = trimmedText ? parseReplyDirectives(splitTrailingDirective(trimmedText, { final: true }).text) @@ -770,18 +769,18 @@ export function handleMessageEnd( !ctx.params.silentExpected && !suppressDeterministicApprovalOutput && ctx.state.includeReasoning && - formattedReasoning && + trimmedReasoning && onBlockReply && - formattedReasoning !== ctx.state.lastReasoningSent, + trimmedReasoning !== ctx.state.lastReasoningSent, ); const shouldEmitReasoningBeforeAnswer = shouldEmitReasoning && ctx.state.blockReplyBreak === "message_end" && !addedDuringMessage; const maybeEmitReasoning = () => { - if (!shouldEmitReasoning || !formattedReasoning) { + if (!shouldEmitReasoning || !trimmedReasoning) { return; } - ctx.state.lastReasoningSent = formattedReasoning; - ctx.emitBlockReply({ text: formattedReasoning, isReasoning: true }); + ctx.state.lastReasoningSent = trimmedReasoning; + ctx.emitBlockReply({ text: trimmedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts index 3bceb2dd171..82a6e375b8e 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts @@ -26,7 +26,7 @@ describe("subscribeEmbeddedPiSession", () => { function expectReasoningAndAnswerCalls(onBlockReply: ReturnType) { expect(onBlockReply).toHaveBeenCalledTimes(2); - expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_"); + expect(onBlockReply.mock.calls[0][0].text).toBe("Because it helps"); expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer"); } diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index cdb23c295ff..26c4e845a7d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -1,5 +1,6 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import * as agentEvents from "../infra/agent-events.js"; import { THINKING_TAG_CASES, createSubscribedSessionHarness, @@ -216,7 +217,7 @@ describe("subscribeEmbeddedPiSession", () => { const streamTexts = onReasoningStream.mock.calls .map((call) => call[0]?.text) .filter((value): value is string => typeof value === "string"); - expect(streamTexts.at(-1)).toBe("Reasoning:\n_Because it helps_"); + expect(streamTexts.at(-1)).toBe("Because it helps"); expect(assistantMessage.content).toEqual([ { type: "thinking", thinking: "Because it helps" }, @@ -757,10 +758,52 @@ describe("subscribeEmbeddedPiSession", () => { const streamTexts = onReasoningStream.mock.calls .map((call) => call[0]?.text) .filter((value): value is string => typeof value === "string"); - expect(streamTexts.at(-1)).toBe("Reasoning:\n_Checking files done_"); + expect(streamTexts.at(-1)).toBe("Checking files done"); expect(onReasoningEnd).toHaveBeenCalledTimes(1); }); + it("extracts correct reasoning delta for incremental stream updates", () => { + const emitAgentEventSpy = vi.spyOn(agentEvents, "emitAgentEvent").mockImplementation(() => {}); + const { emit } = createSubscribedHarness({ + runId: "run", + reasoningMode: "stream", + onReasoningStream: vi.fn(), + }); + + emit({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Step 1" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: "Step 1", + }, + }); + + emit({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Step 1 and Step 2" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: " and Step 2", + }, + }); + + const thinkingEvents = emitAgentEventSpy.mock.calls + .map((call) => call[0]) + .filter((evt) => evt?.stream === "thinking"); + + expect(thinkingEvents.length).toBe(2); + expect(thinkingEvents[0]?.data?.delta).toBe("Step 1"); + expect(thinkingEvents[1]?.data?.delta).toBe(" and Step 2"); + emitAgentEventSpy.mockRestore(); + }); + it("emits reasoning end once when native and tagged reasoning end overlap", () => { const onReasoningEnd = vi.fn(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index b4431a0b6ab..51aaad9dc71 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -36,7 +36,6 @@ import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; import { filterToolResultMediaUrls } from "./pi-embedded-subscribe.tools.js"; import type { SubscribeEmbeddedPiSessionParams } from "./pi-embedded-subscribe.types.js"; import { - formatReasoningMessage, stripDowngradedToolCallText, THINKING_TAG_SCAN_RE, } from "./pi-embedded-utils.js"; @@ -831,31 +830,31 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar if (!state.streamReasoning || !params.onReasoningStream) { return; } - const formatted = formatReasoningMessage(text); - if (!formatted) { + const trimmed = text.trim(); + if (!trimmed) { return; } - if (formatted === state.lastStreamedReasoning) { + if (trimmed === state.lastStreamedReasoning) { return; } // Compute delta: new text since the last emitted reasoning. - // Guard against non-prefix changes (e.g. trim/format altering earlier content). + // Guard against non-prefix changes (e.g. trim altering earlier content). const prior = state.lastStreamedReasoning ?? ""; - const delta = formatted.startsWith(prior) ? formatted.slice(prior.length) : formatted; - state.lastStreamedReasoning = formatted; + const delta = trimmed.startsWith(prior) ? trimmed.slice(prior.length) : trimmed; + state.lastStreamedReasoning = trimmed; // Broadcast thinking event to WebSocket clients in real-time emitAgentEvent({ runId: params.runId, stream: "thinking", data: { - text: formatted, + text: trimmed, delta, }, }); void params.onReasoningStream({ - text: formatted, + text: trimmed, }); }; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index df747c9c61e..7fb857b4a02 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -3911,7 +3911,7 @@ describe("dispatchReplyFromConfig", () => { const ctx = buildTestCtx({ Provider: "whatsapp" }); const replyResolver = async () => [ - { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "thinking...", isReasoning: true }, { text: "The answer is 42" }, ] satisfies ReplyPayload[]; await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); @@ -3930,7 +3930,7 @@ describe("dispatchReplyFromConfig", () => { opts?: GetReplyOptions, ): Promise => { // Simulate block reply with reasoning payload - await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "thinking...", isReasoning: true }); await opts?.onBlockReply?.({ text: "The answer is 42" }); return { text: "The answer is 42" }; }; @@ -3944,7 +3944,7 @@ describe("dispatchReplyFromConfig", () => { }, ); await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); - expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).not.toContain("thinking..."); expect(blockReplySentTexts).toContain("The answer is 42"); }); diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 91b438462b3..b09d354ced9 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -850,12 +850,12 @@ describe("block reply coalescer", () => { }, }); - coalescer.enqueue({ text: "Reasoning:\n_hidden_", isReasoning: true }); + coalescer.enqueue({ text: "hidden", isReasoning: true }); coalescer.enqueue({ text: "Visible answer" }); await coalescer.flush({ force: true }); expect(flushes).toEqual([ - { text: "Reasoning:\n_hidden_", isReasoning: true }, + { text: "hidden", isReasoning: true }, { text: "Visible answer", isReasoning: undefined }, ]); coalescer.stop(); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 444bfc8671f..ffcb93cf3d4 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -204,7 +204,7 @@ describe("routeReply", () => { }); it("suppresses reasoning payloads", async () => { - await expectSlackNoDelivery({ text: "Reasoning:\n_step_", isReasoning: true }); + await expectSlackNoDelivery({ text: "step", isReasoning: true }); }); it("drops silent token payloads", async () => { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 3a709063304..c9691e57a89 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -964,7 +964,7 @@ describe("shared Node TLS env defaults focused", () => { }); it("defaults NODE_EXTRA_CA_CERTS on Linux when NVM_DIR is set", () => { - const expected = resolveLinuxSystemCaBundle(); + const expected = resolveLinuxSystemCaBundle({ platform: "linux" }); const env = buildServiceEnvironment({ env: { HOME: "/home/user", NVM_DIR: "/home/user/.nvm" }, port: 18789, @@ -975,7 +975,7 @@ describe("shared Node TLS env defaults focused", () => { }); it("defaults NODE_EXTRA_CA_CERTS on Linux when execPath is under nvm", () => { - const expected = resolveLinuxSystemCaBundle(); + const expected = resolveLinuxSystemCaBundle({ platform: "linux" }); const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user" }, platform: "linux", diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index af62b1a27db..c83cd97bcbd 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -52,7 +52,7 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( [ { - text: "Reasoning:\n_step_", + text: "step", mediaUrl: audioPath, trustedLocalMedia: true, isReasoning: true, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index bb54099cc85..22e1c23064a 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -927,7 +927,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.dispatchedReplies = [ { kind: "final", - payload: { text: "Reasoning:\n_step_", isReasoning: true }, + payload: { text: "step", isReasoning: true }, }, { kind: "final", diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 5a790370f53..c60762a9ff7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1134,19 +1134,25 @@ describe("runHeartbeatOnce", () => { typedCases<{ name: string; caseDir: string; - replies: Array<{ text: string }>; + replies: Array<{ text: string; isReasoning?: boolean }>; expectedTexts: string[]; }>([ { - name: "reasoning + final payload", + name: "legacy-prefixed reasoning + final payload", caseDir: "hb-reasoning", replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "Final alert" }], expectedTexts: ["Reasoning:\n_Because it helps_", "Final alert"], }, { - name: "reasoning + HEARTBEAT_OK", + name: "raw flagged reasoning + final payload", + caseDir: "hb-reasoning-raw", + replies: [{ text: "Because it helps", isReasoning: true }, { text: "Final alert" }], + expectedTexts: ["Reasoning:\n_Because it helps_", "Final alert"], + }, + { + name: "raw flagged reasoning + HEARTBEAT_OK", caseDir: "hb-reasoning-heartbeat-ok", - replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "HEARTBEAT_OK" }], + replies: [{ text: "Because it helps", isReasoning: true }, { text: "HEARTBEAT_OK" }], expectedTexts: ["Reasoning:\n_Because it helps_"], }, ]), diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 1e0dd20c75e..a82138c1f50 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -16,6 +16,7 @@ import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; import { isNestedAgentLane } from "../agents/lanes.js"; import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js"; import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js"; +import { formatReasoningMessage } from "../agents/pi-embedded-utils.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; import { @@ -639,10 +640,26 @@ function resolveHeartbeatReasoningPayloads( replyResult: ReplyPayload | ReplyPayload[] | undefined, ): ReplyPayload[] { const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : []; - return payloads.filter((payload) => { + const reasoningPayloads: ReplyPayload[] = []; + for (const payload of payloads) { const text = typeof payload.text === "string" ? payload.text : ""; - return text.trimStart().startsWith("Reasoning:"); - }); + const hasLegacyReasoningPrefix = text.trimStart().startsWith("Reasoning:"); + if (payload.isReasoning !== true && !hasLegacyReasoningPrefix) { + continue; + } + + const formattedText = hasLegacyReasoningPrefix ? text : formatReasoningMessage(text); + if (!formattedText.trim()) { + continue; + } + + const deliverablePayload: ReplyPayload = { ...payload, text: formattedText }; + delete deliverablePayload.isReasoning; + delete deliverablePayload.mediaUrl; + delete deliverablePayload.mediaUrls; + reasoningPayloads.push(deliverablePayload); + } + return reasoningPayloads; } async function restoreHeartbeatUpdatedAt(params: { From 190c07afe9ea54e3a630f1ad80e4e1667de80f26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:09:49 +0100 Subject: [PATCH 225/806] test: tighten generic matcher assertions --- extensions/nostr/src/nostr-profile.fuzz.test.ts | 2 +- extensions/voice-call/src/webhook-security.test.ts | 3 ++- extensions/zalouser/src/zalo-js.credentials.test.ts | 5 +++-- ui/src/i18n/test/translate.test.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/extensions/nostr/src/nostr-profile.fuzz.test.ts b/extensions/nostr/src/nostr-profile.fuzz.test.ts index fbd577479ee..f2da03faec8 100644 --- a/extensions/nostr/src/nostr-profile.fuzz.test.ts +++ b/extensions/nostr/src/nostr-profile.fuzz.test.ts @@ -58,7 +58,7 @@ describe("profile unicode attacks", () => { // UI should escape or handle this const sanitized = sanitizeProfileForDisplay(result.profile); - expect(sanitized.name).toEqual(expect.any(String)); + expect(sanitized.name).toBe("\u202Eevil\u202C"); }); it("handles bidi embedding in about", () => { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 9a7230fa3c2..ce4b2e192a4 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -443,7 +443,8 @@ describe("verifyPlivoWebhook", () => { ); expect(first.ok).toBe(true); - expect(first.verifiedRequestKey).toEqual(expect.any(String)); + expect(first.verifiedRequestKey).toBeTypeOf("string"); + expect(first.verifiedRequestKey).not.toBe(""); expect(second.ok).toBe(true); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(second.isReplay).toBe(true); diff --git a/extensions/zalouser/src/zalo-js.credentials.test.ts b/extensions/zalouser/src/zalo-js.credentials.test.ts index b10eaeb5315..3172b085161 100644 --- a/extensions/zalouser/src/zalo-js.credentials.test.ts +++ b/extensions/zalouser/src/zalo-js.credentials.test.ts @@ -8,6 +8,7 @@ import { LoginQRCallbackEventType } from "./zca-constants.js"; const createZaloMock = vi.hoisted(() => vi.fn()); const TEST_MTIME_TICK_MS = 20; +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; vi.mock("./zca-client.js", () => ({ createZalo: createZaloMock, @@ -197,7 +198,7 @@ describe("zalouser credential persistence", () => { const stored = await readStoredCredentials(stateDir, profile); expect(stored.cookie).toEqual(refreshedCookie); expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); - expect(stored.lastUsedAt).toEqual(expect.any(String)); + expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE); }); } finally { await rm(stateDir, { recursive: true, force: true }); @@ -262,7 +263,7 @@ describe("zalouser credential persistence", () => { const stored = await readStoredCredentials(stateDir, profile); expect(stored.cookie).toEqual(refreshedCookie); expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); - expect(stored.lastUsedAt).toEqual(expect.any(String)); + expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE); }); } finally { await rm(stateDir, { recursive: true, force: true }); diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index a8b73e53781..f7e83caf0c2 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -143,7 +143,7 @@ describe("i18n", () => { it("keeps the version label available in shipped locales", () => { for (const [locale, value] of Object.entries(shippedLocales)) { - expect((value.common as { version?: string }).version, locale).toEqual(expect.any(String)); + expect((value.common as { version?: string }).version, locale).toBeTypeOf("string"); expect((value.common as { version?: string }).version?.trim(), locale).not.toBe(""); } }); From c7cf34a9552ac85e4314973d6cb4343fb7da9153 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:11:26 +0100 Subject: [PATCH 226/806] test: tighten diffs artifact assertions --- extensions/diffs/src/tool-render-output.test.ts | 2 +- extensions/diffs/src/tool.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/diffs/src/tool-render-output.test.ts b/extensions/diffs/src/tool-render-output.test.ts index 2849986a9d3..2dadb1a3048 100644 --- a/extensions/diffs/src/tool-render-output.test.ts +++ b/extensions/diffs/src/tool-render-output.test.ts @@ -68,7 +68,7 @@ describe("diffs tool rendered output guards", () => { }); expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).filePath).toEqual(expect.any(String)); + expect((result?.details as Record).filePath).toMatch(/preview\.png$/); }); }); diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 1a5eab936e2..cefa9518278 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -190,8 +190,10 @@ describe("diffs tool", () => { }); expectArtifactOnlyFileResult(screenshotter, result); - expect((result?.details as Record).artifactId).toEqual(expect.any(String)); - expect((result?.details as Record).expiresAt).toEqual(expect.any(String)); + expect(requireString(readDetails(result).artifactId, "artifactId")).toMatch(/^[a-f0-9]{20}$/u); + expect(requireString(readDetails(result).expiresAt, "expiresAt")).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); }); it("honors ttlSeconds for artifact-only file output", async () => { From 10bbed8a6d303b2a61512dc7ca446863bca5429b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 18:14:32 +0530 Subject: [PATCH 227/806] fix(telegram): chain over-limit stream previews --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 32 +++++++ .../telegram/src/bot-message-dispatch.ts | 3 + extensions/telegram/src/draft-stream.test.ts | 57 +++++++++++ extensions/telegram/src/draft-stream.ts | 95 ++++++++++++++++--- 5 files changed, 177 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7bbe85929..2fc697f2200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: keep contributor diagnostics to a bounded top-three selection without sorting the full history. Thanks @shakkernerd. - Sessions/UI: avoid full-array sorting while selecting ACPX leases, Google Meet calendar events, and latest chat sessions. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. +- Telegram/streaming: continue over-limit draft previews in a new message instead of stopping when rendered preview text crosses Telegram's message limit. (#74508) Thanks @anagnorisis2peripeteia. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. - Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index f824b407ca3..b64f1e2bb24 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -468,6 +468,38 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("keeps retained overflow draft previews", async () => { + const draftStream = createDraftStream(); + const bot = createBot(); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Hello" }); + await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext(), bot }); + + const streamParams = createTelegramDraftStream.mock.calls[0]?.[0] as Parameters< + NonNullable + >[0]; + streamParams.onSupersededPreview?.({ + messageId: 17, + textSnapshot: "first page", + retain: true, + }); + expect(bot.api.deleteMessage).not.toHaveBeenCalled(); + + streamParams.onSupersededPreview?.({ + messageId: 18, + textSnapshot: "stale page", + }); + await vi.waitFor(() => expect(bot.api.deleteMessage).toHaveBeenCalledWith(123, 18)); + }); + it("queues final Telegram replies through outbound delivery when available", async () => { deliverInboundReplyWithMessageSendContext.mockResolvedValue({ status: "handled_visible", diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index e5d9fb373c9..d27460123a2 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -442,6 +442,9 @@ export const dispatchTelegramMessage = async ({ minInitialChars: draftMinInitialChars, renderText: renderStreamText, onSupersededPreview: (superseded) => { + if (superseded.retain) { + return; + } void bot.api.deleteMessage(chatId, superseded.messageId).catch((err: unknown) => { logVerbose( `telegram: superseded ${laneName} stream cleanup failed (${superseded.messageId}): ${String(err)}`, diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index 41002246a86..cad4fc4ab16 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -389,6 +389,63 @@ describe("createTelegramDraftStream", () => { }); }); + it("continues in a new message when rendered preview crosses maxChars", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const stream = createDraftStream(api, { maxChars: 20 }); + + stream.update("Hello world"); + await stream.flush(); + stream.update("Hello world foo bar baz qux"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello world", undefined); + expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "foo bar baz qux", undefined); + }); + + it("splits a first oversized rendered preview into chained messages", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const stream = createDraftStream(api, { maxChars: 10 }); + + stream.update("1234567890ABCDEFGHIJ"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "1234567890", undefined); + expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "ABCDEFGHIJ", undefined); + }); + + it("retains overflow preview pages", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const onSupersededPreview = vi.fn(); + const stream = createDraftStream(api, { + maxChars: 20, + onSupersededPreview, + }); + + stream.update("Hello world"); + await stream.flush(); + stream.update("Hello world foo bar baz qux"); + await stream.flush(); + + expect(onSupersededPreview).toHaveBeenCalledWith({ + messageId: 17, + textSnapshot: "Hello world", + parseMode: undefined, + visibleSinceMs: expect.any(Number), + retain: true, + }); + }); + it("enforces maxChars after renderText expansion", async () => { const api = createMockDraftApi(); const warn = vi.fn(); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index f9ab22c88d7..9a532fc5255 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -53,8 +53,38 @@ type SupersededTelegramPreview = { textSnapshot: string; parseMode?: "HTML"; visibleSinceMs?: number; + retain?: boolean; }; +function renderTelegramDraftPreview( + text: string, + renderText: ((text: string) => TelegramDraftPreview) | undefined, +): TelegramDraftPreview { + const trimmed = text.trimEnd(); + return renderText?.(trimmed) ?? { text: trimmed }; +} + +function findTelegramDraftChunkLength( + text: string, + maxChars: number, + renderText: ((text: string) => TelegramDraftPreview) | undefined, +): number { + let best = 0; + let low = 1; + let high = text.length; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const renderedText = renderTelegramDraftPreview(text.slice(0, mid), renderText).text.trimEnd(); + if (renderedText && renderedText.length <= maxChars) { + best = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} + export function createTelegramDraftStream(params: { api: Bot["api"]; chatId: Parameters[0]; @@ -98,6 +128,8 @@ export function createTelegramDraftStream(params: { let lastSentParseMode: "HTML" | undefined; let previewRevision = 0; let generation = 0; + let deliveredTextOffset = 0; + let resetStreamToNewMessage: (options?: { keepPending?: boolean; resetOffset?: boolean }) => void; type PreviewSendParams = { renderedText: string; renderedParseMode: "HTML" | undefined; @@ -198,13 +230,45 @@ export function createTelegramDraftStream(params: { if (!trimmed) { return false; } - const rendered = params.renderText?.(trimmed) ?? { text: trimmed }; + const currentText = trimmed.slice(deliveredTextOffset).trimStart(); + if (!currentText) { + return false; + } + const rendered = renderTelegramDraftPreview(currentText, params.renderText); const renderedText = rendered.text.trimEnd(); const renderedParseMode = rendered.parseMode; if (!renderedText) { return false; } if (renderedText.length > maxChars) { + if (lastDeliveredText.length > deliveredTextOffset) { + const supersededMessageId = streamMessageId; + const supersededTextSnapshot = lastSentText; + const supersededParseMode = lastSentParseMode; + const supersededVisibleSinceMs = streamVisibleSinceMs; + deliveredTextOffset = lastDeliveredText.length; + resetStreamToNewMessage({ keepPending: true, resetOffset: false }); + if (typeof supersededMessageId === "number") { + params.onSupersededPreview?.({ + messageId: supersededMessageId, + textSnapshot: supersededTextSnapshot, + parseMode: supersededParseMode, + visibleSinceMs: supersededVisibleSinceMs, + retain: true, + }); + } + return await sendOrEditStreamMessage(trimmed); + } + const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText); + if (chunkLength > 0) { + const sent = await sendOrEditStreamMessage( + trimmed.slice(0, deliveredTextOffset) + currentText.slice(0, chunkLength), + ); + if (!sent) { + return false; + } + return await sendOrEditStreamMessage(trimmed); + } streamState.stopped = true; params.warn?.( `telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`, @@ -248,6 +312,24 @@ export function createTelegramDraftStream(params: { sendOrEditStreamMessage, }); + resetStreamToNewMessage = (options) => { + streamState.stopped = false; + streamState.final = false; + generation += 1; + messageSendAttempted = false; + streamMessageId = undefined; + streamVisibleSinceMs = undefined; + lastSentText = ""; + lastSentParseMode = undefined; + if (options?.resetOffset !== false) { + deliveredTextOffset = 0; + } + if (!options?.keepPending) { + loop.resetPending(); + } + loop.resetThrottleWindow(); + }; + const clear = async () => { const messageId = await takeMessageIdAfterStop({ stopForClear, @@ -272,16 +354,7 @@ export function createTelegramDraftStream(params: { }; const forceNewMessage = () => { - streamState.stopped = false; - streamState.final = false; - generation += 1; - messageSendAttempted = false; - streamMessageId = undefined; - streamVisibleSinceMs = undefined; - lastSentText = ""; - lastSentParseMode = undefined; - loop.resetPending(); - loop.resetThrottleWindow(); + resetStreamToNewMessage(); }; const materialize = async (): Promise => { From 596aa452bffa7e3304fc197a0bc290d7a8873625 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:12:57 +0100 Subject: [PATCH 228/806] test: tighten ui controller assertions --- ui/src/ui/controllers/channels.test.ts | 2 +- ui/src/ui/controllers/chat.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts index 2db07d32ed4..f55e679dce1 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/ui/controllers/channels.test.ts @@ -95,7 +95,7 @@ describe("loadChannels", () => { expect(state.channelsLoading).toBe(false); expect(state.channelsSnapshot).toBe(next); - expect(state.channelsLastSuccess).toEqual(expect.any(Number)); + expect(state.channelsLastSuccess).toBeGreaterThan(10); } finally { vi.useRealTimers(); } diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 1f2b6faef25..85241cc0de8 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -13,6 +13,8 @@ import { type ChatState, } from "./chat.ts"; +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + function createState(overrides: Partial = {}): ChatState { return { chatAttachments: [], @@ -864,7 +866,7 @@ describe("sendChatMessage", () => { await loadChatHistory(state); const result = await sendChatMessage(state, "continue"); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(state.currentSessionId).toBe("session-before-reconnect"); expect(request).toHaveBeenLastCalledWith( "chat.send", @@ -892,7 +894,7 @@ describe("sendChatMessage", () => { }, ]); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(request).toHaveBeenCalledWith( "chat.send", expect.objectContaining({ @@ -944,7 +946,7 @@ describe("sendChatMessage", () => { const result = await sendChatMessage(state, "summarize", [attachment]); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(request).toHaveBeenCalledWith( "chat.send", expect.objectContaining({ From 0fad0a43ca15ce55857f52386af7b6d43548e11c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:14:21 +0100 Subject: [PATCH 229/806] test: tighten core timestamp assertions --- src/channels/message/lifecycle.test.ts | 3 ++- src/cron/service/store.load-missing-session-target.test.ts | 2 +- src/infra/diagnostics-timeline.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/channels/message/lifecycle.test.ts b/src/channels/message/lifecycle.test.ts index faeaa9e5d72..dfe14d72122 100644 --- a/src/channels/message/lifecycle.test.ts +++ b/src/channels/message/lifecycle.test.ts @@ -295,11 +295,12 @@ describe("message lifecycle primitives", () => { expect(ctx.shouldAckAfter("receive_record")).toBe(false); expect(ctx.shouldAckAfter("durable_send")).toBe(true); + const beforeAck = Date.now(); await ctx.ack(); await ctx.ack(); expect(onAck).toHaveBeenCalledTimes(1); expect(ctx.ackState).toBe("acked"); - expect(ctx.ackedAt).toEqual(expect.any(Number)); + expect(ctx.ackedAt).toBeGreaterThanOrEqual(beforeAck); await ctx.nack(new Error("offset failed")); expect(onNack).toHaveBeenCalledWith(expect.any(Error)); diff --git a/src/cron/service/store.load-missing-session-target.test.ts b/src/cron/service/store.load-missing-session-target.test.ts index 88a22044a27..b609ed3f172 100644 --- a/src/cron/service/store.load-missing-session-target.test.ts +++ b/src/cron/service/store.load-missing-session-target.test.ts @@ -61,7 +61,7 @@ describe("cron service store load: missing sessionTarget", () => { message: "watch dbus", toolsAllow: ["exec"], }); - expect(job.state.nextRunAtMs).toEqual(expect.any(Number)); + expect(job.state.nextRunAtMs).toBeGreaterThan(STORE_TEST_NOW); expect(assertSupportedJobSpec(job)).toBeUndefined(); }); diff --git a/src/infra/diagnostics-timeline.test.ts b/src/infra/diagnostics-timeline.test.ts index 97dbf47b594..64737028c47 100644 --- a/src/infra/diagnostics-timeline.test.ts +++ b/src/infra/diagnostics-timeline.test.ts @@ -134,8 +134,8 @@ describe("diagnostics timeline", () => { count: 2, }, }); - expect(event?.timestamp).toEqual(expect.any(String)); - expect(event?.pid).toEqual(expect.any(Number)); + expect(event?.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u); + expect(event?.pid).toBe(process.pid); expect((event?.attributes as Record).ignored).toBeUndefined(); }); @@ -168,7 +168,7 @@ describe("diagnostics timeline", () => { attributes: { pluginCount: 3 }, }); expect(events[1]?.spanId).toBe(events[0]?.spanId); - expect(events[1]?.durationMs).toEqual(expect.any(Number)); + expect(events[1]?.durationMs).toBeGreaterThanOrEqual(0); }); it("records span error events and rethrows failures", async () => { From a0274445296cf746307535187ad06f4c109f61e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:15:38 +0100 Subject: [PATCH 230/806] test: tighten irc discord string assertions --- extensions/discord/src/voice-message.test.ts | 2 +- extensions/irc/src/send.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index 006e2c60161..e00eb3f56cb 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -52,7 +52,7 @@ describe("ensureOggOpus", () => { }); function expectStagedFfmpegOutput(ffmpegOutputPath: string | undefined, finalPath: string) { - expect(ffmpegOutputPath).toEqual(expect.any(String)); + expect(ffmpegOutputPath).toBeTypeOf("string"); if (typeof ffmpegOutputPath !== "string") { throw new Error("missing ffmpeg output path"); } diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 35002ae90ea..9cc8d488847 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -132,7 +132,7 @@ describe("sendMessageIrc cfg threading", () => { direction: "outbound", }); expect(result.target).toBe("#room"); - expect(result.messageId).toEqual(expect.any(String)); + expect(result.messageId).toBeTypeOf("string"); expect(result.messageId.length).toBeGreaterThan(0); expect(result.receipt).toMatchObject({ primaryPlatformMessageId: "irc-msg-1", @@ -191,7 +191,7 @@ describe("sendMessageIrc cfg threading", () => { expect(hoisted.loadConfig).not.toHaveBeenCalled(); expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello"); expect(result.target).toBe("#room"); - expect(result.messageId).toEqual(expect.any(String)); + expect(result.messageId).toBeTypeOf("string"); expect(result.messageId.length).toBeGreaterThan(0); }); From 5457462e62670839b1b7d793e22f7f38a76b8b0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:16:04 -0400 Subject: [PATCH 231/806] feat(discord): add realtime voice modes Add Discord realtime voice modes with OpenAI realtime support, talk-buffer/bidi routing, per-turn speaker context enforcement, and lifecycle cleanup. --- CHANGELOG.md | 1 + docs/channels/discord.md | 57 +- extensions/discord/src/config-schema.test.ts | 41 ++ extensions/discord/src/config-ui-hints.ts | 31 +- extensions/discord/src/voice/audio.ts | 62 ++ extensions/discord/src/voice/ingress.ts | 119 +++ .../discord/src/voice/manager.e2e.test.ts | 693 +++++++++++++++++- extensions/discord/src/voice/manager.ts | 215 +++++- extensions/discord/src/voice/realtime.ts | 418 +++++++++++ extensions/discord/src/voice/segment.ts | 84 +-- extensions/discord/src/voice/session.ts | 29 + .../voice-call/src/realtime-agent-context.ts | 25 +- src/agents/cli-runner/prepare.test.ts | 35 + src/agents/cli-runner/prepare.ts | 5 + src/agents/cli-runner/types.ts | 2 + .../command/attempt-execution.cli.test.ts | 98 +++ src/agents/command/attempt-execution.ts | 2 + src/agents/command/types.ts | 2 + ...ndled-channel-config-metadata.generated.ts | 30 +- src/config/types.discord.ts | 29 + src/config/zod-schema.providers-core.ts | 17 + src/plugin-sdk/realtime-voice.ts | 1 + src/talk/agent-consult-tool.ts | 23 + src/talk/agent-talkback-runtime.test.ts | 53 ++ src/talk/agent-talkback-runtime.ts | 55 +- src/talk/provider-resolver.test.ts | 22 + src/talk/provider-resolver.ts | 10 +- 27 files changed, 2013 insertions(+), 146 deletions(-) create mode 100644 extensions/discord/src/voice/ingress.ts create mode 100644 extensions/discord/src/voice/realtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc697f2200..b59d9a184a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. - Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921) - Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel. +- Discord/voice: add realtime `/vc` modes so Discord voice channels can run as STT/TTS, a realtime talk buffer with the OpenClaw agent brain, or a bidi realtime session with `openclaw_agent_consult`. - Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply. - Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`. - Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index aec9b12eaba..e27675fa46a 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1172,6 +1172,7 @@ Auto-join example: discord: { voice: { enabled: true, + mode: "stt-tts", model: "openai/gpt-5.4-mini", autoJoin: [ { @@ -1199,8 +1200,10 @@ Auto-join example: Notes: - `voice.tts` overrides `messages.tts` for voice playback only. -- `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model. Do not set this to `gpt-realtime-2`; Discord voice channels use STT plus TTS playback, not the OpenAI Realtime session transport. -- STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- `voice.mode` controls the conversation path: `stt-tts` keeps the existing batch STT plus TTS flow, `talk-buffer` uses a realtime voice shell for turn timing/transcription/playback while the OpenClaw agent produces the answer, and `bidi` lets the realtime model converse directly while exposing `openclaw_agent_consult` for the OpenClaw brain. +- `voice.model` overrides the OpenClaw agent brain for Discord voice responses and realtime consults. Leave it unset to inherit the routed agent model. It is separate from `voice.realtime.model`. +- In `stt-tts` mode, STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- In realtime modes, `voice.realtime.provider`, `voice.realtime.model`, and `voice.realtime.voice` configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use `voice.realtime.model: "gpt-realtime-2"` and `voice.model: "openai-codex/gpt-5.5"`. - For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.openai.voice` or `voice.tts.providers.openai.voice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model. - Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel. - Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`). @@ -1211,7 +1214,7 @@ Notes: - `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. - `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`. - `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`. -- Voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. +- In `stt-tts` mode, voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. Realtime modes forward speaker starts as barge-in signals to the realtime provider. - `voice.captureSilenceGraceMs` controls how long OpenClaw waits after Discord reports a speaker has stopped before finalizing that audio segment for STT. Default: `2500`; raise this if Discord splits normal pauses into choppy partial transcripts. - When ElevenLabs is the selected TTS provider, Discord voice playback uses streaming TTS and starts from the provider response stream. Providers without streaming support fall back to the synthesized temp-file path. - OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. @@ -1219,7 +1222,7 @@ Notes: - `The operation was aborted` receive events are expected when OpenClaw finalizes a captured speaker segment; they are verbose diagnostics, not warnings. - Verbose Discord voice logs include a bounded one-line STT transcript preview for each accepted speaker segment, so debugging shows both the user side and the agent reply side without dumping unbounded transcript text. -Voice channel pipeline: +STT plus TTS pipeline: - Discord PCM capture is converted to a WAV temp file. - `tools.media.audio` handles STT, for example `openai/gpt-4o-mini-transcribe`. @@ -1227,7 +1230,51 @@ Voice channel pipeline: - `voice.model`, when set, overrides only the response LLM for this voice-channel turn. - `voice.tts` is merged over `messages.tts`; streaming-capable providers feed the player directly, otherwise the resulting audio file is played in the joined channel. -Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, and TTS auth for `messages.tts`/`voice.tts`. +Realtime talk-buffer example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + mode: "talk-buffer", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + }, + }, + }, + }, +} +``` + +Realtime bidi example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }, + }, +} +``` + +Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, TTS auth for `messages.tts`/`voice.tts`, and realtime provider auth for `voice.realtime.providers` or the provider's normal auth config. ### Voice messages diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 3373878bfa0..c160744c8dc 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -147,6 +147,47 @@ describe("discord config schema", () => { expect(cfg.voice?.model).toBe("openai/gpt-5.4-mini"); }); + it("accepts Discord realtime voice modes", () => { + const cfg = expectValidDiscordConfig({ + voice: { + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + providers: { + openai: { + apiKey: "sk-test", + voice: "marin", + }, + }, + }, + }, + }); + + expect(cfg.voice?.mode).toBe("bidi"); + expect(cfg.voice?.model).toBe("openai-codex/gpt-5.5"); + expect(cfg.voice?.realtime?.provider).toBe("openai"); + expect(cfg.voice?.realtime?.model).toBe("gpt-realtime-2"); + expect(cfg.voice?.realtime?.voice).toBe("cedar"); + expect(cfg.voice?.realtime?.toolPolicy).toBe("safe-read-only"); + expect(cfg.voice?.realtime?.consultPolicy).toBe("always"); + }); + + it("rejects invalid Discord realtime voice modes", () => { + for (const voice of [ + { mode: "realtime" }, + { mode: "bidi", realtime: { toolPolicy: "dangerous" } }, + { mode: "talk-buffer", realtime: { consultPolicy: "substantive" } }, + { mode: "talk-buffer", realtime: { debounceMs: 10_001 } }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("accepts Discord voice timing overrides", () => { const cfg = expectValidDiscordConfig({ voice: { diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index fee04e9cffc..3cd53564b31 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -179,7 +179,36 @@ export const discordChannelConfigUiHints = { }, "voice.model": { label: "Discord Voice Model", - help: "Optional LLM model override for Discord voice channel responses (for example openai/gpt-5.4-mini). Leave unset to inherit the routed agent model.", + help: "Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai-codex/gpt-5.5). Leave unset to inherit the routed agent model.", + }, + "voice.mode": { + label: "Discord Voice Mode", + help: "Conversation mode: stt-tts uses batch speech-to-text plus TTS, talk-buffer uses a realtime voice shell with the OpenClaw agent as the brain, and bidi lets the realtime provider converse directly with the OpenClaw consult tool.", + }, + "voice.realtime.provider": { + label: "Discord Realtime Provider", + help: "Realtime voice provider for talk-buffer or bidi Discord voice modes, such as openai.", + }, + "voice.realtime.model": { + label: "Discord Realtime Model", + help: "Provider realtime session model, such as gpt-realtime-2. This is separate from voice.model, which remains the OpenClaw agent brain model.", + }, + "voice.realtime.voice": { + label: "Discord Realtime Voice", + help: "Provider realtime output voice, such as cedar.", + }, + "voice.realtime.toolPolicy": { + label: "Discord Realtime Tool Policy", + help: "Tool policy for the OpenClaw agent consult tool in bidi mode: safe-read-only, owner, or none.", + }, + "voice.realtime.consultPolicy": { + label: "Discord Realtime Consult Policy", + help: "Use always to strongly prefer the OpenClaw agent brain for substantive bidi turns.", + }, + "voice.realtime.providers": { + label: "Discord Realtime Provider Settings", + help: "Provider-specific realtime voice settings keyed by provider id.", + advanced: true, }, "voice.autoJoin": { label: "Discord Voice Auto-Join", diff --git a/extensions/discord/src/voice/audio.ts b/extensions/discord/src/voice/audio.ts index fe305059295..1273382e1d9 100644 --- a/extensions/discord/src/voice/audio.ts +++ b/extensions/discord/src/voice/audio.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { createRequire } from "node:module"; import type { Readable } from "node:stream"; +import { resamplePcm } from "openclaw/plugin-sdk/realtime-voice"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; @@ -140,6 +141,67 @@ export async function decodeOpusStream( return chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0); } +export async function decodeOpusStreamChunks( + stream: Readable, + params: { + onChunk: (pcm48kStereo: Buffer) => void; + onVerbose: (message: string) => void; + onWarn: (message: string) => void; + }, +): Promise { + const selected = createOpusDecoder({ onWarn: params.onWarn }); + if (!selected) { + return; + } + params.onVerbose(`opus decoder: ${selected.name}`); + try { + for await (const chunk of stream) { + if (!chunk || !(chunk instanceof Buffer) || chunk.length === 0) { + continue; + } + const decoded = selected.decoder.decode(chunk); + if (decoded && decoded.length > 0) { + params.onChunk(Buffer.from(decoded)); + } + } + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`discord voice: opus decode failed: ${formatErrorMessage(err)}`); + } + } +} + +export function convertDiscordPcm48kStereoToRealtimePcm24kMono(pcm: Buffer): Buffer { + const frameCount = Math.floor(pcm.length / 4); + if (frameCount === 0) { + return Buffer.alloc(0); + } + const mono48k = Buffer.alloc(frameCount * 2); + for (let frame = 0; frame < frameCount; frame += 1) { + const offset = frame * 4; + const left = pcm.readInt16LE(offset); + const right = pcm.readInt16LE(offset + 2); + mono48k.writeInt16LE(Math.round((left + right) / 2), frame * 2); + } + return resamplePcm(mono48k, SAMPLE_RATE, 24_000); +} + +export function convertRealtimePcm24kMonoToDiscordPcm48kStereo(pcm: Buffer): Buffer { + const mono48k = resamplePcm(pcm, 24_000, SAMPLE_RATE); + const sampleCount = Math.floor(mono48k.length / 2); + if (sampleCount === 0) { + return Buffer.alloc(0); + } + const stereo = Buffer.alloc(sampleCount * 4); + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const sample = mono48k.readInt16LE(sampleIndex * 2); + const offset = sampleIndex * 4; + stereo.writeInt16LE(sample, offset); + stereo.writeInt16LE(sample, offset + 2); + } + return stereo; +} + function estimateDurationSeconds(pcm: Buffer): number { const bytesPerSample = (BIT_DEPTH / 8) * CHANNELS; if (bytesPerSample <= 0) { diff --git a/extensions/discord/src/voice/ingress.ts b/extensions/discord/src/voice/ingress.ts new file mode 100644 index 00000000000..33863383e9c --- /dev/null +++ b/extensions/discord/src/voice/ingress.ts @@ -0,0 +1,119 @@ +import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { formatMention } from "../mentions.js"; +import { normalizeDiscordSlug } from "../monitor/allow-list.js"; +import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; +import { authorizeDiscordVoiceIngress } from "./access.js"; +import type { VoiceSessionEntry } from "./session.js"; +import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; + +export const DISCORD_VOICE_MESSAGE_PROVIDER = "discord-voice"; + +export type DiscordVoiceIngressContext = { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; +}; + +export type DiscordVoiceAgentTurnResult = { + context: DiscordVoiceIngressContext; + text: string; +}; + +export async function resolveDiscordVoiceIngressContext(params: { + entry: VoiceSessionEntry; + userId: string; + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + ownerAllowFrom?: string[]; + fetchGuildName: (guildId: string) => Promise; + speakerContext: DiscordVoiceSpeakerContextResolver; +}): Promise { + const { entry, userId } = params; + if (!entry.guildName) { + entry.guildName = await params.fetchGuildName(entry.guildId); + } + const speaker = await params.speakerContext.resolveContext(entry.guildId, userId); + const speakerIdentity = await params.speakerContext.resolveIdentity(entry.guildId, userId); + const access = await authorizeDiscordVoiceIngress({ + cfg: params.cfg, + discordConfig: params.discordConfig, + guildName: entry.guildName, + guildId: entry.guildId, + channelId: entry.channelId, + channelName: entry.channelName, + channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", + channelLabel: formatMention({ channelId: entry.channelId }), + memberRoleIds: speakerIdentity.memberRoleIds, + ownerAllowFrom: params.ownerAllowFrom, + sender: { + id: speakerIdentity.id, + name: speakerIdentity.name, + tag: speakerIdentity.tag, + }, + }); + if (!access.ok) { + return null; + } + return { + extraSystemPrompt: buildDiscordGroupSystemPrompt(access.channelConfig), + senderIsOwner: speaker.senderIsOwner, + speakerLabel: speaker.label, + }; +} + +export async function runDiscordVoiceAgentTurn(params: { + entry: VoiceSessionEntry; + userId: string; + message: string; + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + runtime: RuntimeEnv; + context?: DiscordVoiceIngressContext; + toolsAllow?: string[]; + ownerAllowFrom?: string[]; + fetchGuildName: (guildId: string) => Promise; + speakerContext: DiscordVoiceSpeakerContextResolver; +}): Promise { + const context = + params.context ?? + (await resolveDiscordVoiceIngressContext({ + entry: params.entry, + userId: params.userId, + cfg: params.cfg, + discordConfig: params.discordConfig, + ownerAllowFrom: params.ownerAllowFrom, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, + })); + if (!context) { + return null; + } + const voiceModel = normalizeOptionalString(params.discordConfig.voice?.model); + const result = await agentCommandFromIngress( + { + message: params.message, + sessionKey: params.entry.route.sessionKey, + agentId: params.entry.route.agentId, + messageChannel: "discord", + messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, + extraSystemPrompt: context.extraSystemPrompt, + senderIsOwner: context.senderIsOwner, + allowModelOverride: Boolean(voiceModel), + model: voiceModel, + toolsAllow: params.toolsAllow, + deliver: false, + }, + params.runtime, + ); + return { + context, + text: (result.payloads ?? []) + .map((payload) => payload.text) + .filter((text) => typeof text === "string" && text.trim()) + .join("\n") + .trim(), + }; +} diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 9e074017c70..5859198d9cb 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -17,6 +17,10 @@ const { textToSpeechStreamMock, textToSpeechMock, logVerboseMock, + resolveConfiguredRealtimeVoiceProviderMock, + createRealtimeVoiceBridgeSessionMock, + realtimeSessionMock, + decodeOpusStreamChunksMock, } = vi.hoisted(() => { type EventHandler = (...args: unknown[]) => unknown; type MockConnection = { @@ -90,6 +94,19 @@ const { const getVoiceConnectionMock = vi.fn((): MockConnection | undefined => undefined); + const realtimeSessionMock = { + bridge: { supportsToolResultContinuation: true }, + acknowledgeMark: vi.fn(), + close: vi.fn(), + connect: vi.fn(async () => undefined), + sendAudio: vi.fn(), + sendUserMessage: vi.fn(), + handleBargeIn: vi.fn(), + setMediaTimestamp: vi.fn(), + submitToolResult: vi.fn(), + triggerGreeting: vi.fn(), + }; + return { createConnectionMock, getVoiceConnectionMock, @@ -106,13 +123,25 @@ const { state: { status: "idle" }, })), resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), - agentCommandMock: vi.fn(async (_opts?: unknown, _runtime?: unknown) => ({ payloads: [] })), + agentCommandMock: vi.fn( + async ( + _opts?: unknown, + _runtime?: unknown, + ): Promise<{ payloads?: Array<{ text?: string }> }> => ({ payloads: [] }), + ), transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })), textToSpeechStreamMock: vi.fn( async (): Promise => ({ success: false, error: "stream unavailable" }), ), textToSpeechMock: vi.fn(async () => ({ success: true, audioPath: "/tmp/voice.mp3" })), logVerboseMock: vi.fn(), + resolveConfiguredRealtimeVoiceProviderMock: vi.fn(() => ({ + provider: { id: "openai" }, + providerConfig: { model: "gpt-realtime-2", voice: "cedar" }, + })), + createRealtimeVoiceBridgeSessionMock: vi.fn((_params?: unknown) => realtimeSessionMock), + realtimeSessionMock, + decodeOpusStreamChunksMock: vi.fn(), }; }); @@ -121,6 +150,7 @@ vi.mock("./sdk-runtime.js", () => ({ AudioPlayerStatus: { Playing: "playing", Idle: "idle" }, EndBehaviorType: { AfterSilence: "AfterSilence", Manual: "Manual" }, NetworkingStatusCode: { Ready: "networking-ready", Resuming: "networking-resuming" }, + StreamType: { Raw: "raw" }, VoiceConnectionStatus: { Ready: "ready", Disconnected: "disconnected", @@ -166,6 +196,25 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => { }; }); +vi.mock("openclaw/plugin-sdk/realtime-voice", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/realtime-voice", + ); + return { + ...actual, + createRealtimeVoiceBridgeSession: createRealtimeVoiceBridgeSessionMock, + resolveConfiguredRealtimeVoiceProvider: resolveConfiguredRealtimeVoiceProviderMock, + }; +}); + +vi.mock("./audio.js", async () => { + const actual = await vi.importActual("./audio.js"); + return { + ...actual, + decodeOpusStreamChunks: decodeOpusStreamChunksMock, + }; +}); + vi.mock("../runtime.js", () => ({ getDiscordRuntime: () => ({ mediaUnderstanding: { @@ -232,6 +281,21 @@ describe("DiscordVoiceManager", () => { textToSpeechMock.mockResolvedValue({ success: true, audioPath: "/tmp/voice.mp3" }); logVerboseMock.mockClear(); createAudioResourceMock.mockClear(); + realtimeSessionMock.close.mockClear(); + realtimeSessionMock.connect.mockClear(); + realtimeSessionMock.sendAudio.mockClear(); + realtimeSessionMock.sendUserMessage.mockClear(); + realtimeSessionMock.handleBargeIn.mockClear(); + realtimeSessionMock.submitToolResult.mockClear(); + createRealtimeVoiceBridgeSessionMock.mockClear(); + createRealtimeVoiceBridgeSessionMock.mockReturnValue(realtimeSessionMock); + resolveConfiguredRealtimeVoiceProviderMock.mockClear(); + resolveConfiguredRealtimeVoiceProviderMock.mockReturnValue({ + provider: { id: "openai" }, + providerConfig: { model: "gpt-realtime-2", voice: "cedar" }, + }); + decodeOpusStreamChunksMock.mockReset(); + decodeOpusStreamChunksMock.mockResolvedValue(undefined); }); const createManager = ( @@ -276,7 +340,12 @@ describe("DiscordVoiceManager", () => { const getLastAudioPlayer = () => { const player = createAudioPlayerMock.mock.results.at(-1)?.value as - | { state: { status: string }; stop: ReturnType } + | { + on: ReturnType; + play: ReturnType; + state: { status: string }; + stop: ReturnType; + } | undefined; if (!player) { throw new Error("expected Discord voice audio player to be created"); @@ -558,6 +627,577 @@ describe("DiscordVoiceManager", () => { expect(manager.status()).toEqual([]); }); + it("closes realtime sessions when disconnected recovery destroys the connection", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); + }); + + it("closes realtime sessions when Discord destroys the connection", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + const destroyed = connection.handlers.get("destroyed"); + expect(destroyed).toBeTypeOf("function"); + destroyed?.(); + + expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1); + expect(connection.destroy).not.toHaveBeenCalled(); + expect(manager.status()).toEqual([]); + }); + + it("starts Discord realtime voice in talk-buffer mode", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "buffered brain answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + debounceMs: 1, + }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuredProviderId: "openai", + defaultModel: "gpt-realtime-2", + providerConfigOverrides: { model: "gpt-realtime-2", voice: "cedar" }, + }), + ); + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + autoRespondToAudio?: boolean; + tools?: unknown[]; + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + expect(bridgeParams?.autoRespondToAudio).toBe(false); + expect(bridgeParams?.tools).toEqual([]); + + bridgeParams?.onTranscript?.("user", "what did I ask?", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: "openai-codex/gpt-5.5", + messageProvider: "discord-voice", + }), + expect.anything(), + ); + expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledWith( + expect.stringContaining("buffered brain answer"), + ); + }); + + it("creates a fresh realtime output stream after the Discord player idles", async () => { + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + const player = getLastAudioPlayer() as { + on: ReturnType; + play: ReturnType; + }; + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + audioSink?: { + sendAudio: (audio: Buffer) => void; + }; + } + | undefined; + + bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480)); + expect(createAudioResourceMock).toHaveBeenCalledTimes(1); + expect(player.play).toHaveBeenCalledTimes(1); + + const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as + | (() => void) + | undefined; + expect(idleHandler).toBeTypeOf("function"); + idleHandler?.(); + + bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480)); + expect(createAudioResourceMock).toHaveBeenCalledTimes(2); + expect(player.play).toHaveBeenCalledTimes(2); + }); + + it("applies Discord realtime model and voice overrides during provider auto-selection", async () => { + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { + model: "gpt-realtime-2", + voice: "cedar", + providers: { + openai: { model: "provider-default", voice: "marin" }, + }, + }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuredProviderId: undefined, + defaultModel: "gpt-realtime-2", + providerConfigs: expect.objectContaining({ + openai: { model: "provider-default", voice: "marin" }, + }), + providerConfigOverrides: { model: "gpt-realtime-2", voice: "cedar" }, + }), + ); + }); + + it("keeps talk-buffer realtime transcripts on the audio turn speaker context", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "non-owner answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai", debounceMs: 1 }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + nonOwnerTurn?.sendInputAudio(Buffer.alloc(8)); + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + bridgeParams?.onTranscript?.("user", "non-owner question", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + }), + expect.anything(), + ); + }); + + it("expires closed talk-buffer turns before later speaker audio", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai", debounceMs: 1 }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager) as { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + }; + const ownerTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + ownerTurn?.close(); + const guestTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + guestTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + bridgeParams?.onTranscript?.("user", "guest question", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + }), + expect.anything(), + ); + }); + + it("starts Discord realtime voice in bidi mode with the consult tool", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + autoRespondToAudio?: boolean; + instructions?: string; + tools?: Array<{ name: string }>; + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + expect(bridgeParams?.autoRespondToAudio).toBe(true); + expect(bridgeParams?.instructions).toContain("Call openclaw_agent_consult"); + expect(bridgeParams?.tools?.map((tool) => tool.name)).toContain("openclaw_agent_consult"); + + bridgeParams?.onToolCall?.( + { + itemId: "item-1", + callId: "call-1", + name: "openclaw_agent_consult", + args: { question: "check my Discord" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith( + "call-1", + expect.objectContaining({ status: "working" }), + { willContinue: true }, + ); + expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-1", { + text: "consult answer", + }); + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: true, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("keeps bidi realtime consults on the audio turn speaker context", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + nonOwnerTurn?.sendInputAudio(Buffer.alloc(8)); + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + bridgeParams?.onToolCall?.( + { + itemId: "item-guest", + callId: "call-guest", + name: "openclaw_agent_consult", + args: { question: "guest question" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("expires closed bidi turns before later speaker consults", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager) as { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + }; + const ownerTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + ownerTurn?.close(); + const guestTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + guestTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + bridgeParams?.onToolCall?.( + { + itemId: "item-guest", + callId: "call-guest", + name: "openclaw_agent_consult", + args: { question: "guest question" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("authorizes realtime speakers before subscribing receiver streams", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const client = createClient(); + client.fetchMember.mockResolvedValue({ + nickname: "Denied Speaker", + roles: [], + user: { + id: "u-denied", + username: "denied", + globalName: "Denied", + discriminator: "3333", + }, + }); + const manager = createManager( + { + groupPolicy: "allowlist", + guilds: { + g1: { + channels: { + "1001": { + roles: ["role:voice-allowed"], + }, + }, + }, + }, + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + }, + }, + }, + client, + ); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + player: { state: { status: string } }; + } + | undefined; + expect(entry).toBeDefined(); + if (entry) { + entry.player.state.status = "playing"; + } + + await ( + manager as unknown as { + handleSpeakingStart: (entry: unknown, userId: string) => Promise; + } + ).handleSpeakingStart(entry, "u-denied"); + + expect(connection.receiver.subscribe).not.toHaveBeenCalled(); + expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled(); + expect(client.fetchMember).toHaveBeenCalledWith("g1", "u-denied"); + }); + it("stores guild metadata on joined voice sessions", async () => { const manager = createManager(); @@ -599,6 +1239,55 @@ describe("DiscordVoiceManager", () => { expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); }); + it("resets DAVE receive recovery after realtime audio decodes", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + decodeOpusStreamChunksMock.mockImplementationOnce( + async ( + _stream: Readable, + params: { + onChunk: (pcm48kStereo: Buffer) => void; + }, + ) => { + params.onChunk(Buffer.alloc(8)); + }, + ); + const manager = createManager({ + groupPolicy: "open", + allowFrom: ["discord:u-speaker"], + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + emitDecryptFailure(manager); + emitDecryptFailure(manager); + const entry = getSessionEntry(manager) as { + receiveRecovery: { decryptFailureCount: number; lastDecryptFailureAt: number }; + }; + expect(entry.receiveRecovery.decryptFailureCount).toBe(2); + const stream = { + on: vi.fn(), + destroy: vi.fn(), + async *[Symbol.asyncIterator]() {}, + }; + connection.receiver.subscribe.mockReturnValueOnce(stream); + + await ( + manager as unknown as { + handleSpeakingStart: (entry: unknown, userId: string) => Promise; + } + ).handleSpeakingStart(entry, "u-speaker"); + + expect(decodeOpusStreamChunksMock).toHaveBeenCalledTimes(1); + expect(entry.receiveRecovery.decryptFailureCount).toBe(0); + expect(entry.receiveRecovery.lastDecryptFailureAt).toBe(0); + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1); + }); + it("allows the same speaker to restart after finalize fires", async () => { vi.useFakeTimers(); try { diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 426dd406a13..5c29c1dfc6d 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -8,7 +8,7 @@ import { resolveDiscordAccountAllowFrom } from "../accounts.js"; import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; -import { decodeOpusStream, writeVoiceWavFile } from "./audio.js"; +import { decodeOpusStream, decodeOpusStreamChunks, writeVoiceWavFile } from "./audio.js"; import { beginVoiceCapture, clearVoiceCaptureFinalizeTimer, @@ -20,6 +20,16 @@ import { stopVoiceCaptureState, } from "./capture-state.js"; import { resolveDiscordVoiceEnabled } from "./config.js"; +import { + type DiscordVoiceIngressContext, + resolveDiscordVoiceIngressContext, + runDiscordVoiceAgentTurn, +} from "./ingress.js"; +import { + DiscordRealtimeVoiceSession, + isDiscordRealtimeVoiceMode, + resolveDiscordVoiceMode, +} from "./realtime.js"; import { analyzeVoiceReceiveError, createVoiceReceiveRecoveryState, @@ -224,6 +234,7 @@ export class DiscordVoiceManager { } const voiceConfig = this.params.discordConfig.voice; + const voiceMode = resolveDiscordVoiceMode(voiceConfig); const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); const daveEncryption = voiceConfig?.daveEncryption; const decryptionFailureTolerance = voiceConfig?.decryptionFailureTolerance; @@ -307,12 +318,48 @@ export class DiscordVoiceManager { let disconnectedHandler: (() => Promise) | undefined; let destroyedHandler: (() => void) | undefined; let playerErrorHandler: ((err: Error) => void) | undefined; + let stopped = false; const clearSessionIfCurrent = () => { const active = this.sessions.get(guildId); if (active?.connection === connection) { this.sessions.delete(guildId); } }; + const stopEntry = ( + entry: VoiceSessionEntry, + options: { destroyConnection: boolean; reason: string }, + ) => { + if (stopped) { + return; + } + stopped = true; + if (speakingHandler) { + connection.receiver.speaking.off("start", speakingHandler); + } + if (speakingEndHandler) { + connection.receiver.speaking.off("end", speakingEndHandler); + } + stopVoiceCaptureState(entry.capture); + if (disconnectedHandler) { + connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); + } + if (destroyedHandler) { + connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); + } + if (playerErrorHandler) { + player.off("error", playerErrorHandler); + } + entry.realtime?.close(); + entry.realtime = undefined; + player.stop(); + if (options.destroyConnection) { + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: options.reason, + }); + } + }; const entry: VoiceSessionEntry = { guildId, @@ -337,31 +384,40 @@ export class DiscordVoiceManager { capture: createVoiceCaptureState(), receiveRecovery: createVoiceReceiveRecoveryState(), stop: () => { - if (speakingHandler) { - connection.receiver.speaking.off("start", speakingHandler); - } - if (speakingEndHandler) { - connection.receiver.speaking.off("end", speakingEndHandler); - } - stopVoiceCaptureState(entry.capture); - if (disconnectedHandler) { - connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); - } - if (destroyedHandler) { - connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); - } - if (playerErrorHandler) { - player.off("error", playerErrorHandler); - } - player.stop(); - destroyVoiceConnectionSafely({ - connection, - voiceSdk, + stopEntry(entry, { + destroyConnection: true, reason: `stop guild ${guildId} channel ${channelId}`, }); }, }; + if (voiceMode !== "stt-tts") { + entry.realtime = new DiscordRealtimeVoiceSession({ + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + entry, + mode: voiceMode, + runAgentTurn: ({ context, message, toolsAllow, userId }) => + this.runDiscordRealtimeAgentTurn({ context, entry, message, toolsAllow, userId }), + }); + try { + await entry.realtime.connect(); + } catch (err) { + entry.realtime.close(); + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: `realtime setup failed guild ${guildId} channel ${channelId}`, + }); + return { + ok: false, + message: `Failed to start Discord realtime voice: ${formatErrorMessage(err)}`, + guildId, + channelId, + }; + } + } + speakingHandler = (userId: string) => { void this.handleSpeakingStart(entry, userId).catch((err) => { logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`); @@ -394,15 +450,18 @@ export class DiscordVoiceManager { `discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`, ); clearSessionIfCurrent(); - destroyVoiceConnectionSafely({ - connection, - voiceSdk, + stopEntry(entry, { + destroyConnection: true, reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`, }); } }; destroyedHandler = () => { clearSessionIfCurrent(); + stopEntry(entry, { + destroyConnection: false, + reason: `destroyed guild ${guildId} channel ${channelId}`, + }); }; playerErrorHandler = (err: Error) => { logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`); @@ -511,12 +570,30 @@ export class DiscordVoiceManager { `capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); const voiceSdk = loadDiscordVoiceSdk(); - if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing) { + const voiceMode = resolveDiscordVoiceMode(this.params.discordConfig.voice); + const realtime = + entry.realtime && isDiscordRealtimeVoiceMode(voiceMode) ? entry.realtime : undefined; + if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && !realtime) { logVoiceVerbose( `capture ignored during playback: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); return; } + const realtimeIngress = realtime + ? await this.resolveDiscordVoiceIngressContext(entry, userId) + : undefined; + if (realtime && !realtimeIngress) { + logVoiceVerbose( + `realtime capture unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && realtime) { + logVoiceVerbose( + `realtime barge-in: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + realtime.handleBargeIn(); + } this.enableDaveReceivePassthrough( entry, `speaker ${userId} start`, @@ -535,6 +612,15 @@ export class DiscordVoiceManager { }); try { + if (realtime && realtimeIngress) { + const turn = realtime.beginSpeakerTurn(realtimeIngress, userId); + try { + await this.processRealtimeAudioCapture({ entry, stream, turn }); + } finally { + turn.close(); + } + return; + } const pcm = await decodeOpusStream(stream, { onVerbose: logVoiceVerbose, onWarn: (message) => logger.warn(message), @@ -565,6 +651,85 @@ export class DiscordVoiceManager { } } + private async processRealtimeAudioCapture(params: { + entry: VoiceSessionEntry; + stream: import("node:stream").Readable; + turn: import("./session.js").VoiceRealtimeSpeakerTurn; + }): Promise { + const { entry, stream, turn } = params; + let resetReceiveRecovery = false; + await decodeOpusStreamChunks(stream, { + onChunk: (pcm) => { + if (!resetReceiveRecovery && pcm.length > 0) { + resetReceiveRecovery = true; + this.resetDecryptFailureState(entry); + } + turn.sendInputAudio(pcm); + }, + onVerbose: logVoiceVerbose, + onWarn: (message) => logger.warn(message), + }); + } + + private async resolveDiscordVoiceIngressContext( + entry: VoiceSessionEntry, + userId: string, + ): Promise { + return await resolveDiscordVoiceIngressContext({ + entry, + userId, + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + ownerAllowFrom: this.ownerAllowFrom, + fetchGuildName: async (guildId) => { + const guild = await this.params.client.fetchGuild(guildId).catch(() => null); + return guild && typeof guild.name === "string" && guild.name.trim() + ? guild.name + : undefined; + }, + speakerContext: this.speakerContext, + }); + } + + private async runDiscordRealtimeAgentTurn(params: { + context: { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; + }; + entry: VoiceSessionEntry; + message: string; + toolsAllow?: string[]; + userId: string; + }): Promise { + const { context, entry, message, toolsAllow, userId } = params; + const turn = await runDiscordVoiceAgentTurn({ + entry, + userId, + message, + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + runtime: this.params.runtime, + context, + toolsAllow, + ownerAllowFrom: this.ownerAllowFrom, + fetchGuildName: async (guildId) => { + const guild = await this.params.client.fetchGuild(guildId).catch(() => null); + return guild && typeof guild.name === "string" && guild.name.trim() + ? guild.name + : undefined; + }, + speakerContext: this.speakerContext, + }); + if (!turn) { + logVoiceVerbose( + `realtime agent unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return ""; + } + return turn.text; + } + private async processSegment(params: { entry: VoiceSessionEntry; wavPath: string; diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts new file mode 100644 index 00000000000..2849f9de345 --- /dev/null +++ b/extensions/discord/src/voice/realtime.ts @@ -0,0 +1,418 @@ +import { PassThrough } from "node:stream"; +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { + buildRealtimeVoiceAgentConsultChatMessage, + buildRealtimeVoiceAgentConsultPolicyInstructions, + buildRealtimeVoiceAgentConsultWorkingResponse, + createRealtimeVoiceAgentTalkbackQueue, + createRealtimeVoiceBridgeSession, + REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + resolveConfiguredRealtimeVoiceProvider, + resolveRealtimeVoiceAgentConsultToolPolicy, + resolveRealtimeVoiceAgentConsultTools, + resolveRealtimeVoiceAgentConsultToolsAllow, + type RealtimeVoiceAgentTalkbackQueue, + type RealtimeVoiceAgentConsultToolPolicy, + type RealtimeVoiceBridgeSession, + type RealtimeVoiceProviderConfig, + type RealtimeVoiceToolCallEvent, +} from "openclaw/plugin-sdk/realtime-voice"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + convertDiscordPcm48kStereoToRealtimePcm24kMono, + convertRealtimePcm24kMonoToDiscordPcm48kStereo, +} from "./audio.js"; +import { formatVoiceIngressPrompt } from "./prompt.js"; +import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; +import { + logVoiceVerbose, + type VoiceRealtimeAgentTurnParams, + type VoiceRealtimeSession, + type VoiceRealtimeSpeakerContext, + type VoiceRealtimeSpeakerTurn, + type VoiceSessionEntry, +} from "./session.js"; + +const logger = createSubsystemLogger("discord/voice"); +const DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS = 350; +const DISCORD_REALTIME_FALLBACK_TEXT = "I hit an error while checking that. Please try again."; +const DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT = 32; + +export type DiscordVoiceMode = "stt-tts" | "talk-buffer" | "bidi"; + +type DiscordRealtimeSpeakerContext = VoiceRealtimeSpeakerContext & { userId: string }; + +type DiscordRealtimeVoiceConfig = NonNullable["realtime"]; + +type PendingSpeakerTurn = { + context: DiscordRealtimeSpeakerContext; + hasAudio: boolean; + closed: boolean; +}; + +export function resolveDiscordVoiceMode(voice: DiscordAccountConfig["voice"]): DiscordVoiceMode { + const mode = voice?.mode; + return mode === "talk-buffer" || mode === "bidi" ? mode : "stt-tts"; +} + +export function isDiscordRealtimeVoiceMode(mode: DiscordVoiceMode): boolean { + return mode === "talk-buffer" || mode === "bidi"; +} + +export function buildDiscordSpeakExactUserMessage(text: string): string { + return [ + "Speak this exact OpenClaw answer to the Discord voice channel, without adding, removing, or rephrasing words.", + `Answer: ${JSON.stringify(text)}`, + ].join("\n"); +} + +export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { + private bridge: RealtimeVoiceBridgeSession | null = null; + private outputStream: PassThrough | null = null; + private readonly talkback: RealtimeVoiceAgentTalkbackQueue; + private stopped = false; + private consultToolPolicy: RealtimeVoiceAgentConsultToolPolicy = "safe-read-only"; + private consultToolsAllow: string[] | undefined; + private readonly pendingSpeakerTurns: PendingSpeakerTurn[] = []; + private readonly playerIdleHandler = () => { + this.resetOutputStream(); + }; + + constructor( + private readonly params: { + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + entry: VoiceSessionEntry; + mode: Exclude; + runAgentTurn: (params: VoiceRealtimeAgentTurnParams) => Promise; + }, + ) { + this.talkback = createRealtimeVoiceAgentTalkbackQueue({ + debounceMs: this.realtimeConfig?.debounceMs ?? DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS, + isStopped: () => this.stopped, + logger, + logPrefix: "[discord] realtime agent", + responseStyle: "Brief, natural spoken answer for a Discord voice channel.", + fallbackText: DISCORD_REALTIME_FALLBACK_TEXT, + consult: async ({ question, responseStyle, metadata }) => { + const context = isDiscordRealtimeSpeakerContext(metadata) ? metadata : undefined; + return { + text: await this.runAgentTurn({ + context, + message: formatVoiceIngressPrompt( + [question, responseStyle ? `Spoken style: ${responseStyle}` : undefined] + .filter(Boolean) + .join("\n\n"), + context?.speakerLabel ?? "Discord voice speaker", + ), + }), + }; + }, + deliver: (text) => this.bridge?.sendUserMessage(buildDiscordSpeakExactUserMessage(text)), + }); + } + + async connect(): Promise { + const resolved = resolveConfiguredRealtimeVoiceProvider({ + configuredProviderId: this.realtimeConfig?.provider, + providerConfigs: buildProviderConfigs(this.realtimeConfig), + providerConfigOverrides: buildProviderConfigOverrides(this.realtimeConfig), + cfg: this.params.cfg, + defaultModel: this.realtimeConfig?.model, + noRegisteredProviderMessage: "No configured realtime voice provider registered", + }); + const toolPolicy = resolveRealtimeVoiceAgentConsultToolPolicy( + this.realtimeConfig?.toolPolicy, + "safe-read-only", + ); + this.consultToolPolicy = toolPolicy; + this.consultToolsAllow = resolveRealtimeVoiceAgentConsultToolsAllow(toolPolicy); + const consultPolicy = this.realtimeConfig?.consultPolicy ?? "auto"; + const instructions = buildDiscordRealtimeInstructions({ + mode: this.params.mode, + instructions: this.realtimeConfig?.instructions, + toolPolicy, + consultPolicy, + }); + this.bridge = createRealtimeVoiceBridgeSession({ + provider: resolved.provider, + providerConfig: resolved.providerConfig, + audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + instructions, + autoRespondToAudio: this.params.mode === "bidi", + markStrategy: "ack-immediately", + tools: this.params.mode === "bidi" ? resolveRealtimeVoiceAgentConsultTools(toolPolicy) : [], + audioSink: { + isOpen: () => !this.stopped, + sendAudio: (audio) => this.sendOutputAudio(audio), + clearAudio: () => this.clearOutputAudio(), + }, + onTranscript: (role, text, isFinal) => { + if (!isFinal || role !== "user" || this.params.mode !== "talk-buffer") { + return; + } + this.talkback.enqueue(text, this.consumePendingSpeakerContext()); + }, + onToolCall: (event, session) => this.handleToolCall(event, session), + onEvent: (event) => { + const detail = event.detail ? ` ${event.detail}` : ""; + logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`); + }, + onError: (error) => + logger.warn(`discord voice: realtime error: ${formatErrorMessage(error)}`), + onClose: (reason) => logVoiceVerbose(`realtime closed: ${reason}`), + }); + logVoiceVerbose( + `realtime voice bridge starting: mode=${this.params.mode} provider=${resolved.provider.id}`, + ); + const voiceSdk = loadDiscordVoiceSdk(); + this.params.entry.player.on(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler); + await this.bridge.connect(); + } + + close(): void { + this.stopped = true; + this.talkback.close(); + this.pendingSpeakerTurns.length = 0; + this.clearOutputAudio(); + this.bridge?.close(); + this.bridge = null; + const voiceSdk = loadDiscordVoiceSdk(); + this.params.entry.player.off(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler); + } + + beginSpeakerTurn(context: VoiceRealtimeSpeakerContext, userId: string): VoiceRealtimeSpeakerTurn { + const turn: PendingSpeakerTurn = { + context: { ...context, userId }, + hasAudio: false, + closed: false, + }; + this.pendingSpeakerTurns.push(turn); + this.prunePendingSpeakerTurns(); + return { + sendInputAudio: (discordPcm48kStereo) => + this.sendInputAudioForTurn(turn, discordPcm48kStereo), + close: () => { + turn.closed = true; + this.prunePendingSpeakerTurns(); + }, + }; + } + + private sendInputAudioForTurn(turn: PendingSpeakerTurn, discordPcm48kStereo: Buffer): void { + if (!this.bridge || this.stopped) { + return; + } + turn.hasAudio = true; + const realtimePcm = convertDiscordPcm48kStereoToRealtimePcm24kMono(discordPcm48kStereo); + if (realtimePcm.length > 0) { + this.bridge.sendAudio(realtimePcm); + } + } + + handleBargeIn(): void { + this.bridge?.handleBargeIn({ audioPlaybackActive: Boolean(this.outputStream) }); + this.clearOutputAudio(); + } + + private get realtimeConfig(): DiscordRealtimeVoiceConfig { + return this.params.discordConfig.voice?.realtime; + } + + private sendOutputAudio(realtimePcm24kMono: Buffer): void { + const discordPcm = convertRealtimePcm24kMonoToDiscordPcm48kStereo(realtimePcm24kMono); + if (discordPcm.length === 0) { + return; + } + const stream = this.ensureOutputStream(); + stream.write(discordPcm); + } + + private ensureOutputStream(): PassThrough { + if (this.outputStream && !this.outputStream.destroyed) { + return this.outputStream; + } + const voiceSdk = loadDiscordVoiceSdk(); + const stream = new PassThrough(); + this.outputStream = stream; + stream.once("close", () => { + if (this.outputStream === stream) { + this.outputStream = null; + } + }); + const resource = voiceSdk.createAudioResource(stream, { + inputType: voiceSdk.StreamType.Raw, + }); + this.params.entry.player.play(resource); + return stream; + } + + private clearOutputAudio(): void { + this.resetOutputStream(); + this.params.entry.player.stop(true); + } + + private resetOutputStream(): void { + const stream = this.outputStream; + this.outputStream = null; + stream?.end(); + stream?.destroy(); + } + + private handleToolCall( + event: RealtimeVoiceToolCallEvent, + session: RealtimeVoiceBridgeSession, + ): void { + const callId = event.callId || event.itemId; + if (this.params.mode !== "bidi") { + session.submitToolResult(callId, { + error: `Tool "${event.name}" is only available in bidi Discord voice mode`, + }); + return; + } + if (event.name !== REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) { + session.submitToolResult(callId, { error: `Tool "${event.name}" not available` }); + return; + } + if (this.consultToolPolicy === "none") { + session.submitToolResult(callId, { error: `Tool "${event.name}" not available` }); + return; + } + if (session.bridge.supportsToolResultContinuation) { + session.submitToolResult(callId, buildRealtimeVoiceAgentConsultWorkingResponse("speaker"), { + willContinue: true, + }); + } + const context = this.consumePendingSpeakerContext(); + if (!context) { + session.submitToolResult(callId, { error: "No Discord speaker context available" }); + return; + } + void this.runAgentTurn({ + context, + message: buildRealtimeVoiceAgentConsultChatMessage(event.args), + }) + .then((text) => { + session.submitToolResult(callId, { text }); + }) + .catch((error: unknown) => { + session.submitToolResult(callId, { error: formatErrorMessage(error) }); + }); + } + + private async runAgentTurn(params: { + context?: DiscordRealtimeSpeakerContext; + message: string; + }): Promise { + const context = params.context; + if (!context) { + return ""; + } + return this.params.runAgentTurn({ + context, + message: params.message, + toolsAllow: this.params.mode === "bidi" ? this.consultToolsAllow : undefined, + userId: context.userId, + }); + } + + private consumePendingSpeakerContext(): DiscordRealtimeSpeakerContext | undefined { + this.prunePendingSpeakerTurns(); + this.expireClosedSpeakerTurnsBeforeLaterAudio(); + const index = this.pendingSpeakerTurns.findIndex((turn) => turn.hasAudio); + if (index < 0) { + return undefined; + } + const [turn] = this.pendingSpeakerTurns.splice(index, 1); + this.prunePendingSpeakerTurns(); + return turn?.context; + } + + private prunePendingSpeakerTurns(): void { + for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) { + const turn = this.pendingSpeakerTurns[index]; + if (turn?.closed && !turn.hasAudio) { + this.pendingSpeakerTurns.splice(index, 1); + } + } + while (this.pendingSpeakerTurns.length > DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT) { + const completedIndex = this.pendingSpeakerTurns.findIndex((turn) => turn.closed); + this.pendingSpeakerTurns.splice(Math.max(completedIndex, 0), 1); + } + } + + private expireClosedSpeakerTurnsBeforeLaterAudio(): void { + let hasLaterAudio = false; + for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) { + const turn = this.pendingSpeakerTurns[index]; + if (!turn?.hasAudio) { + continue; + } + if (turn.closed && hasLaterAudio) { + this.pendingSpeakerTurns.splice(index, 1); + continue; + } + hasLaterAudio = true; + } + } +} + +function isDiscordRealtimeSpeakerContext(value: unknown): value is DiscordRealtimeSpeakerContext { + return ( + Boolean(value) && + typeof value === "object" && + typeof (value as { userId?: unknown }).userId === "string" && + typeof (value as { senderIsOwner?: unknown }).senderIsOwner === "boolean" && + typeof (value as { speakerLabel?: unknown }).speakerLabel === "string" + ); +} + +function buildProviderConfigs( + realtimeConfig: DiscordRealtimeVoiceConfig, +): Record | undefined { + const configs = realtimeConfig?.providers; + return configs && Object.keys(configs).length > 0 ? { ...configs } : undefined; +} + +function buildProviderConfigOverrides( + realtimeConfig: DiscordRealtimeVoiceConfig, +): RealtimeVoiceProviderConfig | undefined { + const overrides = { + ...(realtimeConfig?.model ? { model: realtimeConfig.model } : {}), + ...(realtimeConfig?.voice ? { voice: realtimeConfig.voice } : {}), + }; + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function buildDiscordRealtimeInstructions(params: { + mode: Exclude; + instructions?: string; + toolPolicy: RealtimeVoiceAgentConsultToolPolicy; + consultPolicy: "auto" | "always"; +}): string { + const base = + params.instructions ?? + [ + "You are OpenClaw's Discord voice interface.", + "Keep spoken replies concise, natural, and suitable for a live Discord voice channel.", + ].join("\n"); + if (params.mode === "talk-buffer") { + return [ + base, + "Mode: buffered OpenClaw agent talkback.", + "Use audio input only to transcribe the speaker. Do not answer user speech by yourself.", + "When OpenClaw sends an exact answer to speak, say only that answer.", + ].join("\n\n"); + } + return [ + base, + buildRealtimeVoiceAgentConsultPolicyInstructions({ + toolPolicy: params.toolPolicy, + consultPolicy: params.consultPolicy, + }), + ] + .filter(Boolean) + .join("\n\n"); +} diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts index fabefce8f34..b21aea6a936 100644 --- a/extensions/discord/src/voice/segment.ts +++ b/extensions/discord/src/voice/segment.ts @@ -1,14 +1,9 @@ import path from "node:path"; import { Readable } from "node:stream"; -import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { formatMention } from "../mentions.js"; -import { normalizeDiscordSlug } from "../monitor/allow-list.js"; -import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; -import { authorizeDiscordVoiceIngress } from "./access.js"; +import { resolveDiscordVoiceIngressContext, runDiscordVoiceAgentTurn } from "./ingress.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; import { @@ -20,7 +15,6 @@ import { import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js"; -const DISCORD_VOICE_MESSAGE_PROVIDER = "discord-voice"; const VOICE_TRANSCRIPT_LOG_PREVIEW_CHARS = 500; const logger = createSubsystemLogger("discord/voice"); @@ -49,31 +43,18 @@ export async function processDiscordVoiceSegment(params: { logVoiceVerbose( `segment processing (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId}`, ); - if (!entry.guildName) { - entry.guildName = await params.fetchGuildName(entry.guildId); - } - const speaker = await params.speakerContext.resolveContext(entry.guildId, userId); - const speakerIdentity = await params.speakerContext.resolveIdentity(entry.guildId, userId); - const access = await authorizeDiscordVoiceIngress({ + const ingress = await resolveDiscordVoiceIngressContext({ + entry, + userId, cfg: params.cfg, discordConfig: params.discordConfig, - guildName: entry.guildName, - guildId: entry.guildId, - channelId: entry.channelId, - channelName: entry.channelName, - channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", - channelLabel: formatMention({ channelId: entry.channelId }), - memberRoleIds: speakerIdentity.memberRoleIds, ownerAllowFrom: params.ownerAllowFrom, - sender: { - id: speakerIdentity.id, - name: speakerIdentity.name, - tag: speakerIdentity.tag, - }, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, }); - if (!access.ok) { + if (!ingress) { logVoiceVerbose( - `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${access.message}`, + `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); return; } @@ -92,34 +73,29 @@ export async function processDiscordVoiceSegment(params: { `transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, ); logVoiceVerbose( - `transcript from ${speaker.label} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`, + `transcript from ${ingress.speakerLabel} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`, ); - const prompt = formatVoiceIngressPrompt(transcript, speaker.label); - const extraSystemPrompt = buildDiscordGroupSystemPrompt(access.channelConfig); - const modelOverride = normalizeOptionalString(params.discordConfig.voice?.model); - - const result = await agentCommandFromIngress( - { - message: prompt, - sessionKey: entry.route.sessionKey, - agentId: entry.route.agentId, - messageChannel: "discord", - messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, - extraSystemPrompt, - senderIsOwner: speaker.senderIsOwner, - allowModelOverride: Boolean(modelOverride), - model: modelOverride, - deliver: false, - }, - params.runtime, - ); - - const replyText = (result.payloads ?? []) - .map((payload) => payload.text) - .filter((text) => typeof text === "string" && text.trim()) - .join("\n") - .trim(); + const prompt = formatVoiceIngressPrompt(transcript, ingress.speakerLabel); + const turn = await runDiscordVoiceAgentTurn({ + entry, + userId, + message: prompt, + cfg: params.cfg, + discordConfig: params.discordConfig, + runtime: params.runtime, + context: ingress, + ownerAllowFrom: params.ownerAllowFrom, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, + }); + if (!turn) { + logVoiceVerbose( + `segment unauthorized before agent turn: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + const replyText = turn.text; if (!replyText) { logVoiceVerbose( @@ -135,7 +111,7 @@ export async function processDiscordVoiceSegment(params: { cfg: params.cfg, override: params.discordConfig.voice?.tts, replyText, - speakerLabel: speaker.label, + speakerLabel: ingress.speakerLabel, }); if (voiceReplyAudio.status === "empty") { logVoiceVerbose( diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts index 9960deb194a..cc161e015f1 100644 --- a/extensions/discord/src/voice/session.ts +++ b/extensions/discord/src/voice/session.ts @@ -25,6 +25,34 @@ export type VoiceOperationResult = { guildId?: string; }; +export type VoiceRealtimeSpeakerContext = { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; +}; + +export type VoiceRealtimeAgentTurnParams = { + context: VoiceRealtimeSpeakerContext; + message: string; + toolsAllow?: string[]; + userId: string; +}; + +export type VoiceRealtimeSpeakerTurn = { + close: () => void; + sendInputAudio: (discordPcm48kStereo: Buffer) => void; +}; + +export type VoiceRealtimeSession = { + beginSpeakerTurn: ( + context: VoiceRealtimeSpeakerContext, + userId: string, + ) => VoiceRealtimeSpeakerTurn; + close: () => void; + connect: () => Promise; + handleBargeIn: () => void; +}; + export type VoiceSessionEntry = { guildId: string; guildName?: string; @@ -37,6 +65,7 @@ export type VoiceSessionEntry = { playbackQueue: Promise; processingQueue: Promise; capture: VoiceCaptureState; + realtime?: VoiceRealtimeSession; receiveRecovery: VoiceReceiveRecoveryState; stop: () => void; }; diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index 3d4630658ae..169fa11f7d1 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice"; import { root } from "openclaw/plugin-sdk/security-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; @@ -76,28 +77,6 @@ async function readWorkspaceVoiceContextFiles(params: { return sections; } -function buildConsultPolicyGuidance( - config: Pick, -): string | undefined { - if (config.toolPolicy === "none" || config.consultPolicy === "auto") { - return undefined; - } - if (config.consultPolicy === "always") { - return [ - "Consult behavior:", - "- Call openclaw_agent_consult before every substantive answer.", - "- You may answer directly only for greetings, acknowledgements, brief latency tests, or filler while waiting for the consult result.", - "- After the consult result arrives, speak that result concisely.", - ].join("\n"); - } - return [ - "Consult behavior:", - "- Answer directly for greetings, acknowledgements, simple conversational glue, and brief latency tests.", - "- Call openclaw_agent_consult before answering requests that need facts, memory, current information, tools, workspace state, or the user's OpenClaw-specific context.", - "- Keep spoken replies concise and natural.", - ].join("\n"); -} - export async function buildRealtimeVoiceInstructions(params: { baseInstructions: string; config: VoiceCallConfig; @@ -106,7 +85,7 @@ export async function buildRealtimeVoiceInstructions(params: { }): Promise { const { config } = params; const sections: string[] = [params.baseInstructions]; - const consultGuidance = buildConsultPolicyGuidance(config.realtime); + const consultGuidance = buildRealtimeVoiceAgentConsultPolicyInstructions(config.realtime); if (consultGuidance) { sections.push(consultGuidance); } diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index ef966e2787c..f797471efea 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -619,6 +619,41 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => { + const { dir, sessionFile } = createSessionFile(); + try { + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "owner-token", + nonOwnerToken: "non-owner-token", + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + }); + + await expect( + prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-tools-allow", + config: createCliBackendConfig({ bundleMcp: true }), + toolsAllow: ["read", "web_search"], + }), + ).rejects.toThrow( + "CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy", + ); + + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("fails closed for native tool-capable CLI backends when tools are disabled", async () => { const { dir, sessionFile } = createSessionFile(); try { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 226899880a9..730a4266572 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -113,6 +113,11 @@ export async function prepareCliRunContext( if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } + if (params.toolsAllow !== undefined) { + throw new Error( + `CLI backend ${backendResolved.id} cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy`, + ); + } if (params.disableTools === true && backendResolved.nativeToolMode === "always-on") { throw new Error( `CLI backend ${backendResolved.id} cannot run with tools disabled because it exposes native tools`, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 810a8221ccc..b699db87218 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -50,6 +50,8 @@ export type RunCliAgentParams = { messageProvider?: string; agentAccountId?: string; senderIsOwner?: boolean; + /** Runtime tool allow-list. CLI harnesses fail closed when this is set. */ + toolsAllow?: string[]; disableTools?: boolean; abortSignal?: AbortSignal; onExecutionStarted?: () => void; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index ebb71c0c3d4..7c0c134a825 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -636,6 +636,57 @@ describe("CLI attempt execution", () => { ); }); + it("forwards runtime toolsAllow into CLI attempts so the CLI harness can fail closed", async () => { + const sessionKey = "agent:main:direct:claude-tools-allow"; + const sessionEntry: SessionEntry = { + sessionId: "openclaw-session-cli-tools-allow", + updatedAt: Date.now(), + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + runCliAgentMock.mockResolvedValueOnce(makeCliResult("restricted cli")); + + await runAgentAttempt({ + providerOverride: "claude-cli", + originalProvider: "claude-cli", + modelOverride: "opus", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey, + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "route this", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-cli-tools-allow", + opts: { + senderIsOwner: true, + toolsAllow: ["read", "web_search"], + } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: "discord", + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "claude-cli", + sessionStore, + storePath, + sessionHasHistory: false, + }); + + expect(runCliAgentMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "claude-cli", + toolsAllow: ["read", "web_search"], + }), + ); + }); + it("routes canonical Anthropic models through the configured Claude CLI runtime", async () => { const sessionKey = "agent:main:direct:canonical-claude-cli"; const sessionEntry: SessionEntry = { @@ -983,6 +1034,53 @@ describe("embedded attempt harness pinning", () => { ); }); + it("forwards runtime toolsAllow into embedded attempts", async () => { + const sessionEntry: SessionEntry = { + sessionId: "tools-allow-session", + updatedAt: Date.now(), + }; + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedPiRunResult); + + await runAgentAttempt({ + providerOverride: "openai", + originalProvider: "openai", + modelOverride: "gpt-5.4", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "read only", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-tools-allow", + opts: { + senderIsOwner: true, + toolsAllow: ["read", "web_search"], + } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai", + sessionHasHistory: false, + }); + + expect(runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + toolsAllow: ["read", "web_search"], + }), + ); + }); + it("lets provider/model runtime policy choose Codex without storing a session harness pin", async () => { const sessionEntry: SessionEntry = { sessionId: "codex-history-session", diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index c45db13622b..7cda6fcbf80 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -521,6 +521,7 @@ export function runAgentAttempt(params: { messageProvider: params.opts.messageProvider ?? params.messageChannel, agentAccountId: params.runContext.accountId, senderIsOwner: params.opts.senderIsOwner, + toolsAllow: params.opts.toolsAllow, cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd, cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd, }); @@ -624,6 +625,7 @@ export function runAgentAttempt(params: { extraSystemPrompt: params.opts.extraSystemPrompt, bootstrapContextMode: params.opts.bootstrapContextMode, bootstrapContextRunKind: params.opts.bootstrapContextRunKind, + toolsAllow: params.opts.toolsAllow, internalEvents: params.opts.internalEvents, inputProvenance: params.opts.inputProvenance, streamParams: params.opts.streamParams, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 7371a973bf7..389b45265ac 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -83,6 +83,8 @@ export type AgentCommandOpts = { senderIsOwner?: boolean; /** Whether this caller is authorized to use provider/model per-run overrides. */ allowModelOverride?: boolean; + /** Optional runtime tool allow-list; when set, only these tools are exposed for this run. */ + toolsAllow?: string[]; /** Group/spawn metadata for subagent policy inheritance and routing context. */ groupId?: SpawnedRunMetadata["groupId"]; groupChannel?: SpawnedRunMetadata["groupChannel"]; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 722081005db..03dab470221 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -11,21 +11,21 @@ type BundledChannelConfigMetadata = { }; const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ - '[{"pluginId":"discord","channelId":"discord","label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"model":{"type":"string","minLength":1},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eve', - 'ntQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"model":{"type":"string","minLength":1},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const"', - ':4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"dm.policy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"] (legacy: channels.discord.dm.allowFrom)."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Discord Retry Attempts","help":"Max retry attempts for outbound Discord API calls (default: 3)."},"retry.minDelayMs":{"label":"Discord Retry Min Delay (ms)","help":"Minimum retry delay in ms for Discord outbound calls."},"retry.maxDelayMs":{"label":"Discord Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Discord outbound calls."},"retry.jitter":{"label":"Discord Retry Jitter","help":"Jitter factor (0-1) applied to Discord retry delays."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"eventQueue.listenerTimeout":{"label":"Discord EventQueue Listener Timeout (ms)","help":"Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout."},"eventQueue.maxQueueSize":{"label":"Discord EventQueue Max Queue Size","help":"Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize."},"eventQueue.maxConcurrency":{"label":"Discord EventQueue Max Concurrency","help":"Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"gatewayInfoTimeoutMs":{"label":"Discord Gateway Metadata Timeout (ms)","help":"Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset."},"gatewayReadyTimeoutMs":{"label":"Discord Gateway READY Timeout (ms)","help":"Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset."},"gatewayRuntimeReadyTimeoutMs":{"label":"Discord Gateway Runtime READY Timeout (ms)","help":"Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses (for example openai/gpt-5.4-mini). Leave unset to inherit the routed agent model."},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationId":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pa', - 'ttern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"required":["domain","connectionMode","webhookPath","dmPolicy","groupPolicy","reactionNotifications","typingIndicator","resolveSenderNames"],"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockS', - 'treaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{', - '"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"n', - 'umber"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentenc', - 'e"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLengt', - 'h":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":fal', - 'se},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provide', - 'r":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":', - '"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"', - 'type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)",', - '"help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":', - '{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + '[{"pluginId":"discord","channelId":"discord","label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","talk-buffer","bidi"]},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"voice":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"i', + 'nteger","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","talk-buffer","bidi"]},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"voice":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source",', + '"provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"dm.policy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"] (legacy: channels.discord.dm.allowFrom)."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Discord Retry Attempts","help":"Max retry attempts for outbound Discord API calls (default: 3)."},"retry.minDelayMs":{"label":"Discord Retry Min Delay (ms)","help":"Minimum retry delay in ms for Discord outbound calls."},"retry.maxDelayMs":{"label":"Discord Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Discord outbound calls."},"retry.jitter":{"label":"Discord Retry Jitter","help":"Jitter factor (0-1) applied to Discord retry delays."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"eventQueue.listenerTimeout":{"label":"Discord EventQueue Listener Timeout (ms)","help":"Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout."},"eventQueue.maxQueueSize":{"label":"Discord EventQueue Max Queue Size","help":"Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize."},"eventQueue.maxConcurrency":{"label":"Discord EventQueue Max Concurrency","help":"Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"gatewayInfoTimeoutMs":{"label":"Discord Gateway Metadata Timeout (ms)","help":"Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset."},"gatewayReadyTimeoutMs":{"label":"Discord Gateway READY Timeout (ms)","help":"Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset."},"gatewayRuntimeReadyTimeoutMs":{"label":"Discord Gateway Runtime READY Timeout (ms)","help":"Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai-codex/gpt-5.5). Leave unset to inherit the routed agent model."},"voice.mode":{"label":"Discord Voice Mode","help":"Conversation mode: stt-tts uses batch speech-to-text plus TTS, talk-buffer uses a realtime voice shell with the OpenClaw agent as the brain, and bidi lets the realtime provider converse directly with the OpenClaw consult tool."},"voice.realtime.provider":{"label":"Discord Realtime Provider","help":"Realtime voice provider for talk-buffer or bidi Discord voice modes, such as openai."},"voice.realtime.model":{"label":"Discord Realtime Model","help":"Provider realtime session model, such as gpt-realtime-2. This is separate from voice.model, which remains the OpenClaw agent brain model."},"voice.realtime.voice":{"label":"Discord Realtime Voice","help":"Provider realtime output voice, such as cedar."},"voice.realtime.toolPolicy":{"label":"Discord Realtime Tool Policy","help":"Tool policy for the OpenClaw agent consult tool in bidi mode: safe-read-only, owner, or none."},"voice.realtime.consultPolicy":{"label":"Discord Realtime Consult Policy","help":"Use always to strongly prefer the OpenClaw agent brain for substantive bidi turns."},"voice.realtime.providers":{"label":"Discord Realtime Provider Settings","help":"Provider-specific realtime voice settings keyed by provider id.","advanced":true},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationI', + 'd":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"required":["domain","connectionMode","webhookPath","dmPolicy","groupPolicy","reactionNotifications","typingIndicator","resolveSenderNames"],"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{', + '"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":[', + '"dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"ty', + 'pe":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enable', + 'd":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"ma', + 'ximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"', + 'string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"addit', + 'ionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to', + ' keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additio', + 'nalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred ', + 'breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)","help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVis', + 'ibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 8a7a0039a02..d69ebd37b01 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -129,11 +129,40 @@ export type DiscordVoiceAutoJoinConfig = { channelId: string; }; +export type DiscordVoiceMode = "stt-tts" | "talk-buffer" | "bidi"; + +export type DiscordVoiceRealtimeConsultPolicy = "auto" | "always"; + +export type DiscordVoiceRealtimeToolPolicy = "safe-read-only" | "owner" | "none"; + +export type DiscordVoiceRealtimeConfig = { + /** Realtime voice provider id, for example "openai". */ + provider?: string; + /** Provider realtime session model, for example "gpt-realtime-2". */ + model?: string; + /** Provider realtime output voice, for example "cedar". */ + voice?: string; + /** System instructions passed to the realtime provider. */ + instructions?: string; + /** Tool policy for bidi realtime consult calls. */ + toolPolicy?: DiscordVoiceRealtimeToolPolicy; + /** Whether bidi should force the OpenClaw agent brain for every substantive turn. */ + consultPolicy?: DiscordVoiceRealtimeConsultPolicy; + /** Debounce window before buffered transcripts are sent to the OpenClaw agent. */ + debounceMs?: number; + /** Provider-specific realtime voice config keyed by provider id. */ + providers?: Record | undefined>; +}; + export type DiscordVoiceConfig = { /** Enable Discord voice channel conversations (default: true). */ enabled?: boolean; + /** Voice conversation mode. Default: stt-tts. */ + mode?: DiscordVoiceMode; /** Optional LLM model override for Discord voice channel responses. */ model?: string; + /** Realtime provider settings for talk-buffer or bidi modes. */ + realtime?: DiscordVoiceRealtimeConfig; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3a6b59e8ca1..efd0e86e698 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -540,10 +540,27 @@ const DiscordVoiceAutoJoinSchema = z }) .strict(); +const DiscordVoiceRealtimeToolPolicySchema = z.enum(["safe-read-only", "owner", "none"]); +const DiscordVoiceRealtimeConsultPolicySchema = z.enum(["auto", "always"]); +const DiscordVoiceRealtimeSchema = z + .object({ + provider: z.string().min(1).optional(), + model: z.string().min(1).optional(), + voice: z.string().min(1).optional(), + instructions: z.string().min(1).optional(), + toolPolicy: DiscordVoiceRealtimeToolPolicySchema.optional(), + consultPolicy: DiscordVoiceRealtimeConsultPolicySchema.optional(), + debounceMs: z.number().int().positive().max(10_000).optional(), + providers: z.record(z.string(), z.record(z.string(), z.unknown()).optional()).optional(), + }) + .strict(); + const DiscordVoiceSchema = z .object({ enabled: z.boolean().optional(), + mode: z.enum(["stt-tts", "talk-buffer", "bidi"]).optional(), model: z.string().min(1).optional(), + realtime: DiscordVoiceRealtimeSchema.optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), diff --git a/src/plugin-sdk/realtime-voice.ts b/src/plugin-sdk/realtime-voice.ts index f74feb68287..0d6b9053dff 100644 --- a/src/plugin-sdk/realtime-voice.ts +++ b/src/plugin-sdk/realtime-voice.ts @@ -52,6 +52,7 @@ export { } from "../talk/talk-session-controller.js"; export { buildRealtimeVoiceAgentConsultChatMessage, + buildRealtimeVoiceAgentConsultPolicyInstructions, buildRealtimeVoiceAgentConsultPrompt, buildRealtimeVoiceAgentConsultWorkingResponse, collectRealtimeVoiceAgentConsultVisibleText, diff --git a/src/talk/agent-consult-tool.ts b/src/talk/agent-consult-tool.ts index b379397bea0..6e2861d6b2a 100644 --- a/src/talk/agent-consult-tool.ts +++ b/src/talk/agent-consult-tool.ts @@ -113,6 +113,29 @@ export function resolveRealtimeVoiceAgentConsultToolsAllow( return []; } +export function buildRealtimeVoiceAgentConsultPolicyInstructions(config: { + toolPolicy: RealtimeVoiceAgentConsultToolPolicy; + consultPolicy?: "auto" | "substantive" | "always"; +}): string | undefined { + if (config.toolPolicy === "none" || !config.consultPolicy || config.consultPolicy === "auto") { + return undefined; + } + if (config.consultPolicy === "always") { + return [ + "Consult behavior:", + "- Call openclaw_agent_consult before every substantive answer.", + "- You may answer directly only for greetings, acknowledgements, brief latency tests, or filler while waiting for the consult result.", + "- After the consult result arrives, speak that result concisely.", + ].join("\n"); + } + return [ + "Consult behavior:", + "- Answer directly for greetings, acknowledgements, simple conversational glue, and brief latency tests.", + "- Call openclaw_agent_consult before answering requests that need facts, memory, current information, tools, workspace state, or the user's OpenClaw-specific context.", + "- Keep spoken replies concise and natural.", + ].join("\n"); +} + export function parseRealtimeVoiceAgentConsultArgs(args: unknown): RealtimeVoiceAgentConsultArgs { const question = readConsultStringArg(args, "question") ?? diff --git a/src/talk/agent-talkback-runtime.test.ts b/src/talk/agent-talkback-runtime.test.ts index cea54ed7e66..59b80107e87 100644 --- a/src/talk/agent-talkback-runtime.test.ts +++ b/src/talk/agent-talkback-runtime.test.ts @@ -86,6 +86,59 @@ describe("realtime voice agent talkback queue", () => { vi.useRealTimers(); }); + it("keeps active pending questions split by metadata", async () => { + vi.useFakeTimers(); + const logger = makeLogger(); + const ownerMetadata = { senderIsOwner: true }; + const guestMetadata = { senderIsOwner: false }; + let finishFirst: ((value: { text: string }) => void) | undefined; + const consult = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ text: string }>((resolve) => { + finishFirst = resolve; + }), + ) + .mockResolvedValueOnce({ text: "owner-answer" }) + .mockResolvedValueOnce({ text: "guest-answer" }); + const deliver = vi.fn(); + const queue = createRealtimeVoiceAgentTalkbackQueue({ + debounceMs: 10, + isStopped: () => false, + logger, + logPrefix: "[test]", + responseStyle: "brief", + fallbackText: "fallback", + consult, + deliver, + }); + + queue.enqueue("first"); + await vi.advanceTimersByTimeAsync(10); + queue.enqueue("owner", ownerMetadata); + queue.enqueue("guest", guestMetadata); + await vi.advanceTimersByTimeAsync(10); + finishFirst?.({ text: "first-answer" }); + await vi.runAllTimersAsync(); + + expect(consult).toHaveBeenNthCalledWith(2, { + question: "owner", + metadata: ownerMetadata, + responseStyle: "brief", + signal: expect.any(AbortSignal), + }); + expect(consult).toHaveBeenNthCalledWith(3, { + question: "guest", + metadata: guestMetadata, + responseStyle: "brief", + signal: expect.any(AbortSignal), + }); + expect(deliver).toHaveBeenCalledWith("owner-answer"); + expect(deliver).toHaveBeenCalledWith("guest-answer"); + vi.useRealTimers(); + }); + it("delivers fallback text when consult fails", async () => { vi.useFakeTimers(); const logger = makeLogger(); diff --git a/src/talk/agent-talkback-runtime.ts b/src/talk/agent-talkback-runtime.ts index 2d2f1399459..f1ba3c4160e 100644 --- a/src/talk/agent-talkback-runtime.ts +++ b/src/talk/agent-talkback-runtime.ts @@ -6,7 +6,7 @@ export type RealtimeVoiceAgentTalkbackResult = { export type RealtimeVoiceAgentTalkbackQueue = { close(): void; - enqueue(question: string): void; + enqueue(question: string, metadata?: unknown): void; }; export type RealtimeVoiceAgentTalkbackQueueParams = { @@ -18,17 +18,23 @@ export type RealtimeVoiceAgentTalkbackQueueParams = { fallbackText: string; consult: (args: { question: string; + metadata?: unknown; responseStyle: string; signal: AbortSignal; }) => Promise; deliver: (text: string) => void; }; +type PendingQuestion = { + question: string; + metadata?: unknown; +}; + export function createRealtimeVoiceAgentTalkbackQueue( params: RealtimeVoiceAgentTalkbackQueueParams, ): RealtimeVoiceAgentTalkbackQueue { let active = false; - let pendingQuestion: string | undefined; + let pendingQuestions: PendingQuestion[] = []; let debounceTimer: ReturnType | undefined; let activeAbortController: AbortController | undefined; @@ -40,29 +46,35 @@ export function createRealtimeVoiceAgentTalkbackQueue( debounceTimer = undefined; }; - const run = async (question: string): Promise => { - const trimmed = question.trim(); + const run = async (pending: PendingQuestion): Promise => { + const trimmed = pending.question.trim(); if (!trimmed || params.isStopped()) { return; } if (active) { - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { + question: trimmed, + metadata: pending.metadata, + }); return; } active = true; - let nextQuestion: string | undefined = trimmed; + let nextQuestion: PendingQuestion | undefined = { + question: trimmed, + metadata: pending.metadata, + }; try { while (nextQuestion) { if (params.isStopped()) { return; } const currentQuestion = nextQuestion; - pendingQuestion = undefined; - params.logger.info(`${params.logPrefix} consult: chars=${currentQuestion.length}`); + params.logger.info(`${params.logPrefix} consult: chars=${currentQuestion.question.length}`); activeAbortController = new AbortController(); const result = await params.consult({ - question: currentQuestion, + question: currentQuestion.question, + metadata: currentQuestion.metadata, responseStyle: params.responseStyle, signal: activeAbortController.signal, }); @@ -71,7 +83,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( if (!params.isStopped() && text) { params.deliver(text); } - nextQuestion = pendingQuestion; + nextQuestion = pendingQuestions.shift(); } } catch (error) { activeAbortController = undefined; @@ -83,8 +95,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( params.deliver(params.fallbackText); } finally { active = false; - const queuedQuestion = pendingQuestion; - pendingQuestion = undefined; + const queuedQuestion = pendingQuestions.shift(); if (queuedQuestion && !params.isStopped()) { void run(queuedQuestion); } @@ -94,25 +105,24 @@ export function createRealtimeVoiceAgentTalkbackQueue( return { close: () => { clearDebounceTimer(); - pendingQuestion = undefined; + pendingQuestions = []; activeAbortController?.abort(); }, - enqueue: (question) => { + enqueue: (question, metadata) => { const trimmed = question.trim(); if (!trimmed || params.isStopped()) { return; } if (active) { - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { question: trimmed, metadata }); clearDebounceTimer(); return; } - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { question: trimmed, metadata }); clearDebounceTimer(); debounceTimer = setTimeout(() => { debounceTimer = undefined; - const queuedQuestion = pendingQuestion; - pendingQuestion = undefined; + const queuedQuestion = pendingQuestions.shift(); if (queuedQuestion && !params.isStopped()) { void run(queuedQuestion); } @@ -122,8 +132,13 @@ export function createRealtimeVoiceAgentTalkbackQueue( }; } -function appendPendingQuestion(current: string | undefined, next: string): string { - return current ? `${current}\n${next}` : next; +function appendPendingQuestion(queue: PendingQuestion[], next: PendingQuestion): void { + const current = queue.at(-1); + if (current && Object.is(current.metadata, next.metadata)) { + current.question = `${current.question}\n${next.question}`; + return; + } + queue.push(next); } function isAbortError(error: unknown): boolean { diff --git a/src/talk/provider-resolver.test.ts b/src/talk/provider-resolver.test.ts index 5ce495229d7..0c38ed1271c 100644 --- a/src/talk/provider-resolver.test.ts +++ b/src/talk/provider-resolver.test.ts @@ -77,6 +77,28 @@ describe("realtime voice provider resolver", () => { }); }); + it("applies caller overrides to the auto-selected realtime voice provider", () => { + const resolution = resolveConfiguredRealtimeVoiceProvider({ + cfg: {}, + defaultModel: "gpt-realtime", + providerConfigOverrides: { + model: "gpt-realtime-2", + voice: "cedar", + }, + providers, + providerConfigs: { + second: { enabled: true, model: "provider-default", voice: "marin" }, + }, + }); + + expect(resolution.providerConfig).toMatchObject({ + enabled: true, + model: "gpt-realtime-2", + voice: "cedar", + resolved: true, + }); + }); + it("throws a caller-specified message when no providers exist", () => { expect(() => resolveConfiguredRealtimeVoiceProvider({ diff --git a/src/talk/provider-resolver.ts b/src/talk/provider-resolver.ts index e749568b364..693c473d8d5 100644 --- a/src/talk/provider-resolver.ts +++ b/src/talk/provider-resolver.ts @@ -12,6 +12,7 @@ export type ResolvedRealtimeVoiceProvider = { export type ResolveConfiguredRealtimeVoiceProviderParams = { configuredProviderId?: string; providerConfigs?: Record | undefined>; + providerConfigOverrides?: Record; cfg?: OpenClawConfig; cfgForResolve?: OpenClawConfig; providers?: RealtimeVoiceProviderPlugin[]; @@ -38,7 +39,14 @@ export function resolveConfiguredRealtimeVoiceProvider( params.defaultModel && rawConfig.model === undefined ? { ...rawConfig, model: params.defaultModel } : rawConfig; - return provider.resolveConfig?.({ cfg, rawConfig: rawConfigWithModel }) ?? rawConfigWithModel; + const rawConfigWithOverrides = { + ...rawConfigWithModel, + ...params.providerConfigOverrides, + }; + return ( + provider.resolveConfig?.({ cfg, rawConfig: rawConfigWithOverrides }) ?? + rawConfigWithOverrides + ); }, isProviderConfigured: ({ provider, cfg, providerConfig }) => provider.isConfigured({ cfg, providerConfig }), From 6da9e7e158f7243b10273b80aa7468ca1f16413f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:17:47 +0100 Subject: [PATCH 232/806] test: tighten mixed matcher helpers --- extensions/acpx/src/manifest.test.ts | 3 ++- extensions/memory-wiki/src/tool.test.ts | 3 ++- src/commands/status.test.ts | 2 +- src/infra/net/fetch-guard.ssrf.test.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts index 3e4a84ca107..57a1051714d 100644 --- a/extensions/acpx/src/manifest.test.ts +++ b/extensions/acpx/src/manifest.test.ts @@ -12,7 +12,8 @@ describe("acpx package manifest", () => { fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as AcpxPackageManifest; - expect(packageJson.dependencies?.acpx).toEqual(expect.any(String)); + expect(packageJson.dependencies?.acpx).toBeTypeOf("string"); + expect(packageJson.dependencies?.acpx).not.toBe(""); expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.13.0"); expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.32.0"); expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined(); diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index 6d3c2447825..e6469b964d9 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,7 +3,8 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { - expect(value).toEqual(expect.any(Object)); + expect(value).toBeTypeOf("object"); + expect(value).not.toBeNull(); return value as Record; } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 990d19e6eeb..2637f544b97 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -974,7 +974,7 @@ describe("statusCommand", () => { expect(payload.memoryPlugin.slot).toBe("memory-core"); expect(payload.sessions.count).toBe(1); expect(payload.sessions.paths).toContain("/tmp/sessions.json"); - expect(payload.sessions.defaults.model).toEqual(expect.any(String)); + expect(payload.sessions.defaults.model).toBe("pi:opus"); expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0); expect(payload.sessions.recent[0].percentUsed).toBe(50); expect(payload.sessions.recent[0].cacheRead).toBe(2_000); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 352354dc62c..1246142abf2 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -74,7 +74,8 @@ function getDispatcherClassName(value: unknown): string | null { } function expectDispatcherAttached(value: unknown): void { - expect(value).toEqual(expect.any(Object)); + expect(value).toBeTypeOf("object"); + expect(value).not.toBeNull(); } function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { From 49f1f712d6cb68915f7c370f091f5ce2468b8ce8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:19:48 +0100 Subject: [PATCH 233/806] test: tighten telegram string assertions --- extensions/telegram/src/bot.test.ts | 3 ++- extensions/telegram/src/bot/delivery.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 13a6949c5a9..cfc7e180bd6 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1766,7 +1766,8 @@ describe("createTelegramBot", () => { mediaRef: "telegram:file/root-photo-1", }), ]); - expect(payload.ReplyChain?.[1]?.mediaPath).toEqual(expect.any(String)); + expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string"); + expect(payload.ReplyChain?.[1]?.mediaPath).not.toBe(""); expect(getFileSpy).toHaveBeenCalledWith("root-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); }); diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 0526e72894a..20028fe6e4e 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -427,7 +427,8 @@ describe("deliverReplies", () => { }); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]).toBeTypeOf("string"); + expect(sendMessage.mock.calls[0]?.[1]).not.toBe(""); expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); }); @@ -445,7 +446,8 @@ describe("deliverReplies", () => { }); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]).toBeTypeOf("string"); + expect(sendMessage.mock.calls[0]?.[1]).not.toBe(""); expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); }); From aefba95dbad8a66a692529ca5f9c87e56a915a34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:21:44 +0100 Subject: [PATCH 234/806] test: tighten extension shape assertions --- extensions/line/src/reply-payload-transform.test.ts | 3 ++- .../matrix/src/matrix/client/file-sync-store.test.ts | 12 +++++++++++- extensions/nostr/src/nostr-profile-http.test.ts | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts index 6f3ba9cc7f4..cea461a88fe 100644 --- a/extensions/line/src/reply-payload-transform.test.ts +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -272,7 +272,8 @@ describe("parseLineDirectives", () => { expect(flexMessage.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0); } if ("expectBodyContents" in testCase && testCase.expectBodyContents) { - expect(flexMessage.contents?.body?.contents, testCase.name).toEqual(expect.any(Array)); + expect(Array.isArray(flexMessage.contents?.body?.contents), testCase.name).toBe(true); + expect(flexMessage.contents?.body?.contents?.length, testCase.name).toBeGreaterThan(0); } } }); diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 9b87eb6820b..a5b7053f94a 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -96,7 +96,17 @@ describe("FileBackedMatrixSyncStore", () => { type: "com.openclaw.test", }, ]); - expect(savedSync?.roomsData.join?.["!room:example.org"]).toEqual(expect.any(Object)); + expect(savedSync?.roomsData.join?.["!room:example.org"]).toMatchObject({ + timeline: { + events: [ + { + event_id: "$message", + sender: "@user:example.org", + type: "m.room.message", + }, + ], + }, + }); expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false); }); diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 126f3496b89..90cba862337 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -429,7 +429,7 @@ describe("nostr-profile-http", () => { const data = expectBadRequestResponse(res); // The schema validation catches non-https URLs before SSRF check expect(data.error).toBe("Validation failed"); - expect(data.details).toEqual(expect.any(Array)); + expect(Array.isArray(data.details)).toBe(true); expect(data.details).toEqual(expect.arrayContaining([expect.stringContaining("https")])); }); From fa15090ead367056385c3455170b9f81ac9cd914 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:23:34 +0100 Subject: [PATCH 235/806] test: tighten core flow config assertions --- src/config/io.write-config.test.ts | 4 +++- src/cron/service/ops.regression.test.ts | 2 +- src/tasks/task-executor.test.ts | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 9fd1399ff4b..8979f89c275 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1328,7 +1328,9 @@ describe("config io write", () => { expect(postWriteSnapshot.valid).toBe(true); expect(observedSources).toEqual([postWriteSnapshot.sourceConfig]); expect(getRuntimeConfigSourceSnapshot()).toEqual(postWriteSnapshot.sourceConfig); - expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toEqual(expect.any(String)); + expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); expect(postWriteSnapshot.sourceConfig.plugins?.entries?.demo?.config).toEqual({}); } finally { unsubscribe(); diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts index dd85daf66e4..5f7502f127c 100644 --- a/src/cron/service/ops.regression.test.ts +++ b/src/cron/service/ops.regression.test.ts @@ -55,7 +55,7 @@ describe("cron service ops regressions", () => { }; await expect(start(state)).resolves.toBeUndefined(); - expect(state.store.jobs[0]?.state).toEqual(expect.any(Object)); + expect(state.store.jobs[0]?.state).toMatchObject({ nextRunAtMs: scheduledAt }); }); it("skips forced manual runs while a timer-triggered run is in progress", async () => { diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index e3bb5bba46c..feaee1de3bb 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -289,7 +289,9 @@ describe("task-executor", () => { deliveryStatus: "pending", }); - expect(created.parentFlowId).toEqual(expect.any(String)); + expect(created.parentFlowId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ flowId: created.parentFlowId, ownerKey: "agent:main:main", From 067ceb38b7ea9decbc809f0143074ffb53d963d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:25:43 +0100 Subject: [PATCH 236/806] test: tighten session proxy assertions --- src/cli/proxy-cli.runtime.test.ts | 3 ++- src/commands/agent.runtime-config.test.ts | 4 ++-- src/cron/isolated-agent.session-identity.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index e1784420b83..04f66fa110b 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -464,6 +464,7 @@ describe("proxy cli runtime", () => { const { runDebugProxyRunCommand } = await import("./proxy-cli.runtime.js"); const { getDebugProxyCaptureStore } = await import("../proxy-capture/store.sqlite.js"); + const beforeRun = Date.now(); await expect( runDebugProxyRunCommand({ commandArgs: ["does-not-exist"], @@ -478,6 +479,6 @@ describe("proxy cli runtime", () => { ); const [session] = store.listSessions(5); expect(session?.mode).toBe("proxy-run"); - expect(session?.endedAt).toEqual(expect.any(Number)); + expect(session?.endedAt).toBeGreaterThanOrEqual(beforeRun); }); }); diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 9aa9a5df94b..c622c0a861c 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -207,13 +207,13 @@ describe("agentCommand runtime config", () => { const resolved = resolveSession({ cfg, to: "+1555" }); expect(resolved.storePath).toBe(store); - expect(resolved.sessionKey).toEqual(expect.any(String)); + expect(resolved.sessionKey).toBeTypeOf("string"); const sessionKey = resolved.sessionKey; if (!sessionKey) { throw new Error("expected session key"); } expect(sessionKey.length).toBeGreaterThan(0); - expect(resolved.sessionId).toEqual(expect.any(String)); + expect(resolved.sessionId).toBeTypeOf("string"); expect(resolved.sessionId.length).toBeGreaterThan(0); expect(resolved.isNewSession).toBe(true); }); diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index dd3b7e00650..97f0f52e98c 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -141,8 +141,8 @@ describe("runCronIsolatedAgentTurn session identity", () => { const first = (await runPingTurn()).res; const second = (await runPingTurn()).res; - expect(first.sessionId).toEqual(expect.any(String)); - expect(second.sessionId).toEqual(expect.any(String)); + expect(first.sessionId).toBeTypeOf("string"); + expect(second.sessionId).toBeTypeOf("string"); expect(second.sessionId).not.toBe(first.sessionId); expect(first.sessionKey).toMatch(/^agent:main:cron:job-1:run:/); expect(second.sessionKey).toMatch(/^agent:main:cron:job-1:run:/); From 60f1b1f8d9e869fa50a6d8ca2f6100cf72a2e60c Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 7 May 2026 19:39:02 +0200 Subject: [PATCH 237/806] fix(gateway): preserve external Tailscale Funnel routes in serve mode Adds opt-in `gateway.tailscale.preserveFunnel`. When `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw checks `tailscale funnel status --json` before re-applying `tailscale serve` and skips both Serve and the `resetOnExit` teardown for that run, preserving operator-managed Funnel exposure across gateway restarts. The Funnel-status parser handles every documented Tailscale target scheme (http, https, https+insecure) via an RFC 3986 scheme strip, plus loopback hostnames (127.0.0.1, localhost, ::1) and bare-port forms. AllowFunnel-disabled hosts and other-port routes are ignored. Closes #57241. --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 4 + docs/gateway/tailscale.md | 5 + src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.gateway.ts | 7 ++ src/config/zod-schema.ts | 1 + .../server-startup-post-attach.test.ts | 1 + src/gateway/server-startup-post-attach.ts | 2 + src/gateway/server-tailscale.test.ts | 115 ++++++++++++++++++ src/gateway/server-tailscale.ts | 17 +++ src/gateway/server.impl.ts | 1 + src/gateway/startup-auth.test.ts | 12 ++ src/gateway/startup-auth.ts | 3 + src/infra/tailscale.test.ts | 90 ++++++++++++++ src/infra/tailscale.ts | 91 ++++++++++++++ 16 files changed, 353 insertions(+) create mode 100644 src/gateway/server-tailscale.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b59d9a184a9..7a5a3e4ba58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. +- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b2c722d8ca7..e5d869b3a05 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -510,6 +510,10 @@ See [Inferred commitments](/concepts/commitments). value, so repeated failures from one localhost origin do not automatically lock out a different origin. - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `tailscale.preserveFunnel`: when `true` and `tailscale.mode = "serve"`, OpenClaw + checks `tailscale funnel status` before re-applying Serve at startup and skips + it if an externally configured Funnel route already covers the gateway port. + Default `false`. - `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. - `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index a1a3a69240a..93c58ed5270 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -116,6 +116,11 @@ openclaw gateway --tailscale funnel --auth password - `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure. - Set `gateway.tailscale.resetOnExit` if you want OpenClaw to undo `tailscale serve` or `tailscale funnel` configuration on shutdown. +- Set `gateway.tailscale.preserveFunnel: true` to keep an externally configured + `tailscale funnel` route alive across gateway restarts. When enabled and the + gateway runs in `mode: "serve"`, OpenClaw checks `tailscale funnel status` + before re-applying Serve and skips it when a Funnel route already covers the + gateway port. The OpenClaw-managed Funnel password-only policy is unchanged. - `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel). - `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only. - Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index eded1129a48..a8139ab2916 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -109,6 +109,8 @@ export const FIELD_HELP: Record = { 'Tailscale publish mode: "off", "serve", or "funnel" for private or public exposure paths. Use "serve" for tailnet-only access and "funnel" only when public internet reachability is required.', "gateway.tailscale.resetOnExit": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", + "gateway.tailscale.preserveFunnel": + "When mode='serve' and an externally configured Tailscale Funnel route already covers the gateway port, skip re-applying tailscale serve on startup. Lets operators keep Funnel exposure managed outside OpenClaw without losing it across gateway restarts.", "gateway.remote": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "gateway.remote.transport": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 531f818795d..6a24d598275 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -129,6 +129,7 @@ export const FIELD_LABELS: Record = { "gateway.tailscale": "Gateway Tailscale", "gateway.tailscale.mode": "Gateway Tailscale Mode", "gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit", + "gateway.tailscale.preserveFunnel": "Gateway Tailscale Preserve External Funnel", "gateway.remote": "Remote Gateway", "gateway.remote.transport": "Remote Gateway Transport", "gateway.reload": "Config Reload", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 830fd49d795..fdbc89e97a5 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -196,6 +196,13 @@ export type GatewayTailscaleConfig = { mode?: GatewayTailscaleMode; /** Reset serve/funnel configuration on shutdown. */ resetOnExit?: boolean; + /** + * When `mode="serve"` and an externally configured Tailscale Funnel route + * already covers the gateway port, skip re-applying `tailscale serve` on + * startup. Lets operators manage Funnel exposure outside OpenClaw without + * losing it across gateway restarts. + */ + preserveFunnel?: boolean; }; export type GatewayRemoteConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 13326011a66..d39ca664192 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -911,6 +911,7 @@ export const OpenClawSchema = z .object({ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), resetOnExit: z.boolean().optional(), + preserveFunnel: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 15e1412f527..7c83a763222 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -916,6 +916,7 @@ function createPostAttachParams(overrides: Partial = {}): Post broadcast: vi.fn(), tailscaleMode: "off", resetOnExit: false, + preserveFunnel: false, controlUiBasePath: "/", logTailscale: { info: vi.fn(), diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 1a14ba681f7..db28e5ced4b 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -673,6 +673,7 @@ export async function startGatewayPostAttachRuntime( broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; tailscaleMode: GatewayTailscaleMode; resetOnExit: boolean; + preserveFunnel: boolean; controlUiBasePath: string; logTailscale: { info: (msg: string) => void; @@ -757,6 +758,7 @@ export async function startGatewayPostAttachRuntime( runtimeDeps.startGatewayTailscaleExposure({ tailscaleMode: params.tailscaleMode, resetOnExit: params.resetOnExit, + preserveFunnel: params.preserveFunnel, port: params.port, controlUiBasePath: params.controlUiBasePath, logTailscale: params.logTailscale, diff --git a/src/gateway/server-tailscale.test.ts b/src/gateway/server-tailscale.test.ts new file mode 100644 index 00000000000..2bb340602f4 --- /dev/null +++ b/src/gateway/server-tailscale.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enableTailscaleServe: vi.fn(async (_port: number) => undefined), + disableTailscaleServe: vi.fn(async () => undefined), + enableTailscaleFunnel: vi.fn(async (_port: number) => undefined), + disableTailscaleFunnel: vi.fn(async () => undefined), + getTailnetHostname: vi.fn(async () => null), + hasTailscaleFunnelRouteForPort: vi.fn(async (_port: number) => false), +})); + +vi.mock("../infra/tailscale.js", () => ({ + enableTailscaleServe: mocks.enableTailscaleServe, + disableTailscaleServe: mocks.disableTailscaleServe, + enableTailscaleFunnel: mocks.enableTailscaleFunnel, + disableTailscaleFunnel: mocks.disableTailscaleFunnel, + getTailnetHostname: mocks.getTailnetHostname, + hasTailscaleFunnelRouteForPort: mocks.hasTailscaleFunnelRouteForPort, +})); + +import { startGatewayTailscaleExposure } from "./server-tailscale.js"; + +function createLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +afterEach(() => { + for (const fn of Object.values(mocks)) { + fn.mockReset(); + } + mocks.enableTailscaleServe.mockResolvedValue(undefined); + mocks.disableTailscaleServe.mockResolvedValue(undefined); + mocks.enableTailscaleFunnel.mockResolvedValue(undefined); + mocks.disableTailscaleFunnel.mockResolvedValue(undefined); + mocks.getTailnetHostname.mockResolvedValue(null); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); +}); + +describe("startGatewayTailscaleExposure preserveFunnel", () => { + it("calls enableTailscaleServe in serve mode when preserveFunnel is unset", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + }); + + it("skips enableTailscaleServe when preserveFunnel is true and a Funnel route covers the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith(expect.stringMatching(/preserv/i)); + }); + + it("notes resetOnExit is a no-op when preserveFunnel skips Serve", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + resetOnExit: true, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith( + expect.stringMatching(/resetOnExit is a no-op/i), + ); + }); + + it("falls back to enableTailscaleServe when preserveFunnel is true but no Funnel route exists for the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + }); + + it("never consults the Funnel route helper when running in funnel mode", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "funnel", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + expect(mocks.enableTailscaleFunnel).toHaveBeenCalledWith(18789); + }); +}); diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts index 9d09f12c4f9..ebf0a6383c9 100644 --- a/src/gateway/server-tailscale.ts +++ b/src/gateway/server-tailscale.ts @@ -5,12 +5,14 @@ import { enableTailscaleFunnel, enableTailscaleServe, getTailnetHostname, + hasTailscaleFunnelRouteForPort, } from "../infra/tailscale.js"; export async function startGatewayTailscaleExposure(params: { tailscaleMode: "off" | "serve" | "funnel"; resetOnExit?: boolean; port: number; + preserveFunnel?: boolean; controlUiBasePath?: string; logTailscale: { info: (msg: string) => void; warn: (msg: string) => void }; }): Promise<(() => Promise) | null> { @@ -20,6 +22,21 @@ export async function startGatewayTailscaleExposure(params: { try { if (params.tailscaleMode === "serve") { + if (params.preserveFunnel === true) { + const funnelCovers = await hasTailscaleFunnelRouteForPort(params.port); + if (funnelCovers) { + const resetSuffix = params.resetOnExit + ? "; resetOnExit is a no-op because no Serve route was applied this run" + : ""; + params.logTailscale.info( + `serve skipped: preserving externally configured Tailscale Funnel for port ${params.port}${resetSuffix}`, + ); + // Skip the resetOnExit teardown deliberately: the Funnel route is + // owned by an external operator, so we must not run + // disableTailscaleServe on shutdown either. + return null; + } + } await enableTailscaleServe(params.port); } else { await enableTailscaleFunnel(params.port); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8b3cc5214af..2d5f71d5463 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1393,6 +1393,7 @@ export async function startGatewayServer( broadcast, tailscaleMode, resetOnExit: tailscaleConfig.resetOnExit ?? false, + preserveFunnel: tailscaleConfig.preserveFunnel ?? false, controlUiBasePath, logTailscale, gatewayPluginConfigAtStart, diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 4524cbbbb9e..0470f00d922 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -5,6 +5,7 @@ import { assertGatewayAuthNotKnownWeak, assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth, + mergeGatewayTailscaleConfig, } from "./startup-auth.js"; const mocks = vi.hoisted(() => ({ @@ -23,6 +24,17 @@ vi.mock("../config/mutate.js", async () => { }; }); +describe("mergeGatewayTailscaleConfig", () => { + it("preserves explicit preserveFunnel overrides", () => { + expect( + mergeGatewayTailscaleConfig( + { mode: "serve", resetOnExit: false, preserveFunnel: false }, + { preserveFunnel: true }, + ), + ).toEqual({ mode: "serve", resetOnExit: false, preserveFunnel: true }); + }); +}); + describe("ensureGatewayStartupAuth", () => { async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { const result = await ensureGatewayStartupAuth({ diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index 46ee8556f92..b885b653108 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -61,6 +61,9 @@ export function mergeGatewayTailscaleConfig( if (override.resetOnExit !== undefined) { merged.resetOnExit = override.resetOnExit; } + if (override.preserveFunnel !== undefined) { + merged.preserveFunnel = override.preserveFunnel; + } return merged; } diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 09839a6e881..b7480af1410 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -10,6 +10,7 @@ const { enableTailscaleServe, disableTailscaleServe, ensureFunnel, + tailscaleFunnelStatusCoversPort, } = tailscale; const tailscaleBin = expect.stringMatching(/tailscale$/i); @@ -236,3 +237,92 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenCalledTimes(2); }); }); + +describe("tailscaleFunnelStatusCoversPort", () => { + function buildFunnelStatus(handlers: Record) { + const host = "device.tailnet.ts.net:443"; + return { + AllowFunnel: { [host]: true }, + Web: { + [host]: { Handlers: handlers }, + }, + } as Record; + } + + it("matches a Funnel route whose Proxy is a full http URL", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a trailing slash", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789/" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a longer path", () => { + const status = buildFunnelStatus({ "/api": { Proxy: "http://127.0.0.1:18789/api" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the localhost loopback alias", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://localhost:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches an IPv6 loopback Proxy", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://[::1]:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the documented https+insecure target scheme", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://localhost:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches https+insecure with a trailing path", () => { + const status = buildFunnelStatus({ + "/api": { Proxy: "https+insecure://127.0.0.1:18789/api" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match https+insecure on a non-loopback host", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://10.0.0.5:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("matches a bare port form", () => { + const status = buildFunnelStatus({ "/": { Proxy: "18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match a Proxy on a different port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:9000" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("does not match a non-loopback host on the right port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://10.0.0.5:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("ignores Web entries whose host is not in AllowFunnel", () => { + const status = { + AllowFunnel: { "device.tailnet.ts.net:443": false }, + Web: { + "device.tailnet.ts.net:443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + }, + } as Record; + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("returns false on an empty status payload", () => { + expect(tailscaleFunnelStatusCoversPort({}, 18789)).toBe(false); + }); +}); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 8857ece2f88..60c894c1dd6 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -402,6 +402,97 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = }); } +export async function hasTailscaleFunnelRouteForPort( + port: number, + exec: typeof runExec = runExec, +): Promise { + try { + const tailscaleBin = await getTailscaleBinary(); + const { stdout } = await exec(tailscaleBin, ["funnel", "status", "--json"], { + maxBuffer: 200_000, + timeoutMs: 5_000, + }); + const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; + return tailscaleFunnelStatusCoversPort(parsed, port); + } catch { + return false; + } +} + +const TAILSCALE_LOOPBACK_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +export function tailscaleFunnelStatusCoversPort( + status: Record, + port: number, +): boolean { + for (const proxy of funnelStatusBackendsForPort(status)) { + if (tailscaleProxyMatchesLoopbackPort(proxy, port)) { + return true; + } + } + return false; +} + +function tailscaleProxyMatchesLoopbackPort(proxy: string, port: number): boolean { + // Tailscale stores the Proxy field as a full URL string (e.g. + // "http://127.0.0.1:18789", "http://127.0.0.1:18789/", + // "https+insecure://localhost:18789/api"), or as the bare forms accepted + // by `tailscale funnel/serve` ("localhost:18789", "18789"). Strip any + // RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) "://") and + // any trailing path before host/port match — covers documented Tailscale + // target schemes such as `http`, `https`, and `https+insecure`. + const stripped = proxy.replace(/^[a-z][a-z0-9+\-.]*:\/\//i, "").replace(/\/.*$/, ""); + if (stripped === String(port)) { + return true; + } + const sep = stripped.lastIndexOf(":"); + if (sep < 0) { + return false; + } + const host = stripped.slice(0, sep); + const portStr = stripped.slice(sep + 1); + if (portStr !== String(port)) { + return false; + } + return TAILSCALE_LOOPBACK_PROXY_HOSTS.has(host); +} + +function funnelStatusBackendsForPort(status: Record): Set { + const backends = new Set(); + const allowFunnel = (status as { AllowFunnel?: Record }).AllowFunnel ?? {}; + const enabledHosts = new Set( + Object.entries(allowFunnel) + .filter(([, value]) => value === true) + .map(([host]) => host), + ); + if (enabledHosts.size === 0) { + return backends; + } + const web = (status as { Web?: Record }).Web; + if (!web || typeof web !== "object") { + return backends; + } + for (const [host, handlers] of Object.entries(web)) { + if (!enabledHosts.has(host)) { + continue; + } + if (!handlers || typeof handlers !== "object") { + continue; + } + const handlerEntries = (handlers as { Handlers?: Record }).Handlers; + if (!handlerEntries || typeof handlerEntries !== "object") { + continue; + } + for (const handler of Object.values(handlerEntries)) { + const proxy = (handler as { Proxy?: unknown })?.Proxy; + if (typeof proxy === "string" && proxy.length > 0) { + backends.add(proxy); + } + } + } + return backends; +} + export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], { From a44021ce17047edf4f211bb9e26340d9d853ad18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:33:12 +0100 Subject: [PATCH 238/806] test: tighten plugin contract assertions --- src/plugins/bundled-plugin-metadata.test.ts | 3 ++- .../contracts/extension-runtime-dependencies.contract.test.ts | 3 ++- .../contracts/plugin-sdk-package-contract-guardrails.test.ts | 3 ++- src/plugins/install.test.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index cd3c092a0ce..0d1dcf7c07a 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -454,7 +454,8 @@ describe("bundled plugin metadata", () => { it("keeps config schemas on all bundled plugin manifests", () => { for (const entry of listRepoBundledPluginMetadata()) { - expect(entry.manifest.configSchema).toEqual(expect.any(Object)); + expect(entry.manifest.configSchema).toBeTypeOf("object"); + expect(entry.manifest.configSchema).not.toBeNull(); } }); diff --git a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts index f530a32e93a..16b0e1027d4 100644 --- a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts +++ b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts @@ -228,7 +228,8 @@ describe("extension runtime dependency manifests", () => { it("keeps json5 in memory-core for packaged runtime config parsing", () => { const manifest = readPackageManifest("extensions/memory-core/package.json"); - expect(manifest.dependencies?.json5).toEqual(expect.any(String)); + expect(manifest.dependencies?.json5).toBeTypeOf("string"); + expect(manifest.dependencies?.json5).not.toBe(""); }); for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) { diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index fe04e913cfc..82f70f9e0ac 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -659,7 +659,8 @@ describe("plugin-sdk package contract guardrails", () => { "fake-indexeddb", "matrix-js-sdk", ]) { - expect(matrixRuntimeDeps.get(dep)).toEqual(expect.any(String)); + expect(matrixRuntimeDeps.get(dep)).toBeTypeOf("string"); + expect(matrixRuntimeDeps.get(dep)).not.toBe(""); expect(rootRuntimeDeps.has(dep)).toBe(false); } expect(rootRuntimeDeps.has("@openclaw/plugin-package-contract")).toBe(false); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 4094500ea6c..00d3cadcb7b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -301,7 +301,7 @@ function expectFailedInstallResult< if (params.code) { expect(params.result.code).toBe(params.code); } - expect(params.result.error).toEqual(expect.any(String)); + expect(params.result.error).toBeTypeOf("string"); params.messageIncludes.forEach((fragment) => { expect(params.result.error).toContain(fragment); }); From 2806e22caa010fce9bb7794340207b4bd818231e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:35:32 +0100 Subject: [PATCH 239/806] test: tighten gateway logging string assertions --- src/gateway/managed-image-attachments.test.ts | 2 +- src/gateway/server.talk-config.test.ts | 2 +- src/logging/logger-redaction-behavior.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index c5967b6fa21..1f2f83071e9 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -73,7 +73,7 @@ async function createNoisyPngBuffer(width: number, height: number): Promise { const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n"); const record = JSON.parse(line ?? "{}") as Record; - expect(record.hostname).toEqual(expect.any(String)); + expect(record.hostname).toBeTypeOf("string"); expect(record.hostname).not.toBe(""); expect(record.message).toBe("request completed"); }); From ad526120081d64ad9b14bf9cc8342366367eb734 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:37:25 +0100 Subject: [PATCH 240/806] test: tighten docs config task assertions --- src/cli/config-cli.test.ts | 3 ++- src/commands/tasks.test.ts | 4 ++-- src/docs/clawhub-plugin-docs.test.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 4cd920117d2..3500d7cfb11 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -673,7 +673,8 @@ describe("config cli", () => { properties?: Record; }; expect(payload.properties?.$schema).toEqual({ type: "string" }); - expect(payload.properties?.channels).toEqual(expect.any(Object)); + expect(payload.properties?.channels).toBeTypeOf("object"); + expect(payload.properties?.channels).not.toBeNull(); expect(payload.properties?.plugins).toBeUndefined(); expect(mockError).not.toHaveBeenCalled(); }); diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index a3eda024aa1..4b99ed596ce 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -150,10 +150,10 @@ describe("tasks commands", () => { expect(payload.mode).toBe("preview"); expect(payload.maintenance.taskFlows.pruned).toBe(1); - expect(payload.auditBefore.byCode).toEqual(expect.any(Object)); + expect(payload.auditBefore.byCode).toBeTypeOf("object"); expect(Array.isArray(payload.auditBefore.byCode)).toBe(false); expect(payload.auditBefore.taskFlows.byCode.stale_running).toBe(0); - expect(payload.auditAfter.byCode).toEqual(expect.any(Object)); + expect(payload.auditAfter.byCode).toBeTypeOf("object"); expect(Array.isArray(payload.auditAfter.byCode)).toBe(false); expect(payload.auditAfter.taskFlows.byCode.stale_running).toBe(0); }); diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index c6909cb1a5f..968b6beca17 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -42,7 +42,8 @@ describe("ClawHub plugin docs", () => { expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]); expect(typeof pluginManifest.id).toBe("string"); - expect(pluginManifest.configSchema).toEqual(expect.any(Object)); + expect(pluginManifest.configSchema).toBeTypeOf("object"); + expect(pluginManifest.configSchema).not.toBeNull(); }); it("does not tell plugin authors to use bare clawhub publish", async () => { From 1a34ef451601fdc8eb1bca0de6b2ddcf11acb9de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:39:02 +0100 Subject: [PATCH 241/806] test: tighten gateway id assertions --- src/gateway/chat-abort.test.ts | 2 +- src/gateway/plugin-node-capability.test.ts | 3 ++- src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts | 2 +- src/gateway/server.tools-catalog.test.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index e1a20047bdd..3e376f9c366 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -101,7 +101,7 @@ describe("abortChatRunById", () => { content: [{ type: "text", text: " Partial reply " }], }), ); - expect((payload.message as { timestamp?: unknown }).timestamp).toEqual(expect.any(Number)); + expect((payload.message as { timestamp?: unknown }).timestamp).toBeGreaterThan(0); expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload); }); diff --git a/src/gateway/plugin-node-capability.test.ts b/src/gateway/plugin-node-capability.test.ts index 5520a9e27bf..76ddbc25698 100644 --- a/src/gateway/plugin-node-capability.test.ts +++ b/src/gateway/plugin-node-capability.test.ts @@ -141,7 +141,8 @@ describe("plugin node capability helpers", () => { }); expect(refreshed?.surface).toBe("canvas"); expect(refreshed?.expiresAtMs).toBe(1_100); - expect(refreshed?.capability).toEqual(expect.any(String)); + expect(refreshed?.capability).toBeTypeOf("string"); + expect(refreshed?.capability).not.toBe(""); expect(refreshed?.scopedUrl).toContain("/__openclaw__/cap/"); expect(refreshed?.scopedUrl).not.toContain("old-token/__openclaw__/cap/"); expect(client.pluginSurfaceUrls?.canvas).toBe(refreshed?.scopedUrl); diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index f2c68bd32cd..d4b5ce1d262 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -443,7 +443,7 @@ describe("gateway silent scope-upgrade reconnect", () => { expect(res.ok).toBe(false); expect(res.error?.message).toBe("pairing required: device is not approved yet"); - expect(replacementRequestId).toEqual(expect.any(String)); + expect(replacementRequestId).toBeTypeOf("string"); expect(replacementRequestId.length).toBeGreaterThan(0); expect( (res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId, diff --git a/src/gateway/server.tools-catalog.test.ts b/src/gateway/server.tools-catalog.test.ts index 26ecd13716c..bf711e1509a 100644 --- a/src/gateway/server.tools-catalog.test.ts +++ b/src/gateway/server.tools-catalog.test.ts @@ -18,7 +18,7 @@ describe("gateway tools.catalog", () => { }>(ws, "tools.catalog", {}); expect(res.ok).toBe(true); - expect(res.payload?.agentId).toEqual(expect.any(String)); + expect(res.payload?.agentId).toBeTypeOf("string"); expect(res.payload?.agentId).not.toBe(""); const mediaGroup = res.payload?.groups?.find((group) => group.id === "media"); expect(mediaGroup?.tools?.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain( From d056715007d2385eeddd080e9e57e2fe8d9db919 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:41:41 +0100 Subject: [PATCH 242/806] test: tighten gateway session id assertions --- src/gateway/server-methods/chat.inject.parentid.test.ts | 4 ++-- src/gateway/server.agent.gateway-server-agent-b.test.ts | 3 ++- src/gateway/server.sessions.reset-hooks.test.ts | 3 ++- src/gateway/session-utils.fs.test.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index b901304087e..31c41f23a94 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -18,7 +18,7 @@ describe("gateway chat.inject transcript writes", () => { message: "hello", }); expect(appended.ok).toBe(true); - expect(appended.messageId).toEqual(expect.any(String)); + expect(appended.messageId).toBeTypeOf("string"); const messageId = appended.messageId; if (!messageId) { throw new Error("expected appended message id"); @@ -65,7 +65,7 @@ describe("gateway chat.inject transcript writes", () => { message: "hello", }); expect(appended.ok).toBe(true); - expect(appended.messageId).toEqual(expect.any(String)); + expect(appended.messageId).toBeTypeOf("string"); const messageId = appended.messageId; if (!messageId) { throw new Error("expected appended message id"); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 3ae47e22277..e73067a0e46 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -529,7 +529,8 @@ describe("gateway server agent", () => { if (!ackPayload || !finalPayload) { throw new Error("missing websocket payload"); } - expect(ackPayload.runId).toEqual(expect.any(String)); + expect(ackPayload.runId).toBeTypeOf("string"); + expect(ackPayload.runId).not.toBe(""); expect(finalPayload.runId).toBe(ackPayload.runId); expect(finalPayload.status).toBe("ok"); }); diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index ee1c8ade4ba..f0efa97cda8 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -403,7 +403,8 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga expect(startEvent).toMatchObject({ resumedFrom: "sess-parent-hooks", }); - expect((startEvent as { sessionId?: string } | undefined)?.sessionId).toEqual(expect.any(String)); + expect((startEvent as { sessionId?: string } | undefined)?.sessionId).toBeTypeOf("string"); + expect((startEvent as { sessionId?: string } | undefined)?.sessionId).not.toBe(""); expect((startEvent as { sessionKey?: string } | undefined)?.sessionKey).toMatch( /^agent:main:dashboard:/, ); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index f4239e65484..7a3b7bd9fce 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1302,7 +1302,7 @@ describe("readSessionMessages", () => { pluginId: "hitl-test-hooks", }); - expect(messageId).toEqual(expect.any(String)); + expect(messageId).toBeTypeOf("string"); expect(messageId.length).toBeGreaterThan(0); const out = readSessionMessages(sessionId, storePath, sessionFile); expect( From c238a51f59d5622e6ac1e77b588acdd77810b6b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:44:58 +0100 Subject: [PATCH 243/806] fix(config): keep Gemini 3.1 model writes canonical --- src/config/io.write-prepare.test.ts | 38 +++++++++++++++++++++++++++++ src/config/io.write-prepare.ts | 28 ++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 248c4872e13..65454b15ca2 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -131,6 +131,44 @@ describe("config io write prepare", () => { expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]); }); + it("preserves authored Google model params under normalized config keys", () => { + const sourceConfig: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "google/gemini-3-pro-preview" }, + models: { + "google/gemini-3-pro-preview": { + alias: "Gemini", + params: { thinking: { level: "high" } }, + }, + }, + }, + }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + agents: { + defaults: { + model: { primary: "google/gemini-3.1-pro-preview" }, + models: { + "google/gemini-3.1-pro-preview": {}, + }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + }); + expect(persisted.agents?.defaults?.models).not.toHaveProperty("google/gemini-3-pro-preview"); + expect(persisted.agents?.defaults?.models?.["google/gemini-3.1-pro-preview"]).toEqual({ + params: { thinking: { level: "high" } }, + }); + }); + it("allows explicit unsets to remove authored agent provider params", () => { const sourceConfig: OpenClawConfig = { agents: { diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 94ca27d9af6..d82076da61a 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import { isRecord } from "../utils.js"; import { applyMergePatch } from "./merge-patch.js"; +import { normalizeAgentModelRefForConfig } from "./model-input.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; import type { OpenClawConfig } from "./types.js"; @@ -153,6 +154,23 @@ function setPathValueCreatingParents(value: unknown, path: string[], nextValue: }; } +function deletePathValue(value: unknown, path: string[]): unknown { + if (path.length === 0 || !isRecord(value)) { + return value; + } + const [head, ...tail] = path; + if (!Object.prototype.hasOwnProperty.call(value, head)) { + return value; + } + const next: Record = { ...value }; + if (tail.length === 0) { + delete next[head]; + return next; + } + next[head] = deletePathValue(value[head], tail); + return next; +} + function preserveSourceValueAtPath(params: { persistedCandidate: unknown; sourceConfig: unknown; @@ -211,8 +229,16 @@ function preserveAuthoredAgentParams(params: { if (!isRecord(modelEntry) || !Object.prototype.hasOwnProperty.call(modelEntry, "params")) { continue; } - const modelPath = ["agents", "defaults", "models", modelId]; + const modelPath = [ + "agents", + "defaults", + "models", + normalizeAgentModelRefForConfig(modelId) || modelId, + ]; const paramsPath = [...modelPath, "params"]; + if (modelPath.at(-1) !== modelId) { + next = deletePathValue(next, ["agents", "defaults", "models", modelId]); + } if (getPathValue(next, modelPath) === undefined) { next = preserveSourceValueAtPath({ ...params, From 164714d36a7bcf94041ded0c1c1ca1215a28d0d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:46:19 +0100 Subject: [PATCH 244/806] test: tighten acp lifecycle assertions --- src/acp/translator.lifecycle.test.ts | 18 ++++++++++++++---- src/acp/translator.stop-reason.test.ts | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 3d8c13df641..1b4b3bb2745 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -187,7 +187,8 @@ describe("acp translator stable lifecycle handlers", () => { "agent:main:a2", ]); expect(first.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]); - expect(first.nextCursor).toEqual(expect.any(String)); + expect(first.nextCursor).toBeTypeOf("string"); + expect(first.nextCursor).not.toBe(""); expect(second.sessions.map((session) => session.sessionId)).toEqual([ "agent:main:a3", "agent:main:a4", @@ -254,7 +255,8 @@ describe("acp translator stable lifecycle handlers", () => { }); const unfiltered = await agent.listSessions(createListSessionsRequest({ limit: 1 })); - expect(unfiltered.nextCursor).toEqual(expect.any(String)); + expect(unfiltered.nextCursor).toBeTypeOf("string"); + expect(unfiltered.nextCursor).not.toBe(""); await expect( agent.listSessions( createListSessionsRequest({ cwd: "/work/a", cursor: unfiltered.nextCursor }), @@ -264,7 +266,8 @@ describe("acp translator stable lifecycle handlers", () => { const filtered = await agent.listSessions( createListSessionsRequest({ cwd: "/work/a", limit: 1 }), ); - expect(filtered.nextCursor).toEqual(expect.any(String)); + expect(filtered.nextCursor).toBeTypeOf("string"); + expect(filtered.nextCursor).not.toBe(""); await expect( agent.listSessions(createListSessionsRequest({ cursor: filtered.nextCursor })), ).rejects.toThrow(/cursor does not match the cwd filter/i); @@ -311,7 +314,14 @@ describe("acp translator stable lifecycle handlers", () => { const result = await agent.resumeSession(createResumeSessionRequest("agent:main:work")); expect(result.modes?.currentModeId).toBe("adaptive"); - expect(result.configOptions).toEqual(expect.any(Array)); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "adaptive", + }), + ]), + ); expect(sessionStore.getSession("agent:main:work")?.sessionKey).toBe("agent:main:work"); expect(request).not.toHaveBeenCalledWith("sessions.get", expect.anything()); expect(sessionUpdate).toHaveBeenCalledWith({ diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts index f3e81689d72..92573da7fb5 100644 --- a/src/acp/translator.stop-reason.test.ts +++ b/src/acp/translator.stop-reason.test.ts @@ -203,7 +203,8 @@ describe("acp translator stop reason mapping", () => { const promptPromise = promptAgent(agent, sessionId); await vi.waitFor(() => { - expect(runId).toEqual(expect.any(String)); + expect(runId).toBeTypeOf("string"); + expect(runId).not.toBe(""); }); const capturedRunId = requireValue(runId, "chat.send run id"); From 1b9986952cf41593aa500eb6727e253bb8bc958b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:47:55 +0100 Subject: [PATCH 245/806] test: tighten auth profile assertions --- src/agents/auth-profiles.store.save.test.ts | 6 +++++- src/agents/auth-profiles/usage.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 085d061cea7..346448d3386 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -170,7 +170,11 @@ describe("saveAuthProfileStore", () => { lastGood?: unknown; usageStats?: unknown; }; - expect(authProfiles.profiles["anthropic:default"]).toEqual(expect.any(Object)); + expect(authProfiles.profiles["anthropic:default"]).toEqual({ + type: "api_key", + provider: "anthropic", + key: "sk-anthropic-plain", + }); expect(authProfiles.order).toBeUndefined(); expect(authProfiles.lastGood).toBeUndefined(); expect(authProfiles.usageStats).toBeUndefined(); diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index f5649eadedb..dd4a0905349 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -364,12 +364,13 @@ describe("clearExpiredCooldowns", () => { }); it("clears expired cooldownUntil and resets errorCount", () => { + const lastFailureAt = Date.now() - 120_000; const store = makeStore({ "anthropic:default": { cooldownUntil: Date.now() - 1_000, errorCount: 4, failureCounts: { rate_limit: 3, timeout: 1 }, - lastFailureAt: Date.now() - 120_000, + lastFailureAt, }, }); @@ -380,7 +381,7 @@ describe("clearExpiredCooldowns", () => { expect(stats?.errorCount).toBe(0); expect(stats?.failureCounts).toBeUndefined(); // lastFailureAt preserved for failureWindowMs decay - expect(stats?.lastFailureAt).toEqual(expect.any(Number)); + expect(stats?.lastFailureAt).toBe(lastFailureAt); }); it("clears expired disabledUntil and disabledReason", () => { @@ -610,6 +611,7 @@ describe("markAuthProfileUsed", () => { storeMocks.updateAuthProfileStoreWithLock.mockResolvedValue(null); + const beforeUsed = Date.now(); await markAuthProfileUsed({ store, profileId: "anthropic:default", @@ -622,7 +624,7 @@ describe("markAuthProfileUsed", () => { ); expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(0); expect(store.usageStats?.["anthropic:default"]?.cooldownUntil).toBeUndefined(); - expect(store.usageStats?.["anthropic:default"]?.lastUsed).toEqual(expect.any(Number)); + expect(store.usageStats?.["anthropic:default"]?.lastUsed).toBeGreaterThanOrEqual(beforeUsed); }); it("adopts locked store usage stats without saving locally when lock update succeeds", async () => { From b32312efa57d189a0b31ba4ef59649d0514e225b Mon Sep 17 00:00:00 2001 From: Jeremy Knows Date: Fri, 8 May 2026 06:49:47 -0700 Subject: [PATCH 246/806] fix(failover): defer profile cooldown marking to unblock rate-limit rotation (#57283) Merged via squash. Prepared head SHA: 498c31d6dce8e2abbbeb070ee94826b0821aa1d0 Co-authored-by: jeremyknows <237305675+jeremyknows@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...pi-agent.auth-profile-rotation.e2e.test.ts | 50 +++++++++++++++- src/agents/pi-embedded-runner/run.ts | 23 +++++-- .../run/assistant-failover.test.ts | 60 +++++++++++++++++++ .../run/assistant-failover.ts | 38 +++++++----- 5 files changed, 149 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5a3e4ba58..c6b10de4941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -652,6 +652,7 @@ Docs: https://docs.openclaw.ai - Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. - Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. - Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. +- Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows. ## 2026.5.3-1 diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 1884f9e7d94..8bde202c594 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -83,6 +83,7 @@ const installRunEmbeddedMocks = () => { }; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let authProfileUsageTesting: typeof import("./auth-profiles/usage.js").__testing; let createDiagnosticLogRecordCaptureFn: typeof import("../logging/test-helpers/diagnostic-log-capture.js").createDiagnosticLogRecordCapture; let cleanupLogCapture: (() => void) | undefined; let resetLoggerFn: typeof import("../logging/logger.js").resetLogger; @@ -93,6 +94,7 @@ beforeAll(async () => { vi.resetModules(); installRunEmbeddedMocks(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + ({ __testing: authProfileUsageTesting } = await import("./auth-profiles/usage.js")); ({ createDiagnosticLogRecordCapture: createDiagnosticLogRecordCaptureFn } = await import("../logging/test-helpers/diagnostic-log-capture.js")); ({ resetLogger: resetLoggerFn, setLoggerOverride: setLoggerOverrideFn } = @@ -128,6 +130,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; + authProfileUsageTesting.setDepsForTest(null); cleanupLogCapture?.(); cleanupLogCapture = undefined; setLoggerOverrideFn(null); @@ -905,6 +908,46 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(sleepWithAbortMock).not.toHaveBeenCalled(); }); + it("starts the retry attempt before prompt failure cooldown marking finishes", async () => { + let releaseMark: (() => void) | undefined; + const markCanFinish = new Promise((resolve) => { + releaseMark = resolve; + }); + let markStarted = false; + authProfileUsageTesting.setDepsForTest({ + updateAuthProfileStoreWithLock: async () => { + markStarted = true; + await markCanFinish; + return null; + }, + }); + + try { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPromptErrorThenSuccessfulAttempt("rate limit exceeded"); + + const runPromise = runAutoPinnedOpenAiTurn({ + agentDir, + workspaceDir, + sessionKey: "agent:test:prompt-deferred-mark", + runId: "run:prompt-deferred-mark", + }); + + await vi.waitFor(() => expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2)); + expect(markStarted).toBe(true); + releaseMark?.(); + releaseMark = undefined; + await runPromise; + + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); + }); + } finally { + releaseMark?.(); + } + }); + it("uses configured overload backoff before rotating profiles", async () => { const { usageStats } = await runAutoPinnedRotationCase({ errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', @@ -1507,8 +1550,9 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); it("skips profiles in cooldown when rotating after failure", async () => { - await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { const authPath = path.join(agentDir, "auth-profiles.json"); + const p2CooldownUntil = Date.now() + 60 * 60 * 1000; const payload = { version: 1, profiles: { @@ -1518,7 +1562,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }, usageStats: { "openai:p1": { lastUsed: 1 }, - "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown + "openai:p2": { cooldownUntil: p2CooldownUntil }, // p2 in cooldown "openai:p3": { lastUsed: 3 }, }, }; @@ -1536,7 +1580,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { const usageStats = await readUsageStats(agentDir); expect(typeof usageStats["openai:p1"]?.lastUsed).toBe("number"); expect(typeof usageStats["openai:p3"]?.lastUsed).toBe("number"); - expect(usageStats["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + expect(usageStats["openai:p2"]?.cooldownUntil).toBe(p2CooldownUntil); }); }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d7a825abc36..05e591eca64 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -2003,11 +2003,6 @@ export async function runEmbeddedPiAgent( promptErrorDetails.reason ?? classifyFailoverReason(errorText, { provider }); const promptProfileFailureReason = resolveRunAuthProfileFailureReason(promptFailoverReason); - await maybeMarkAuthProfileFailure({ - profileId: lastProfileId, - reason: promptProfileFailureReason, - modelId, - }); const promptFailoverFailure = promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider }); // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. @@ -2046,6 +2041,15 @@ export async function runEmbeddedPiAgent( promptFailoverDecision.action === "rotate_profile" && (await advanceAuthProfile()) ) { + if (failedPromptProfileId && promptProfileFailureReason) { + maybeMarkAuthProfileFailure({ + profileId: failedPromptProfileId, + reason: promptProfileFailureReason, + modelId, + }).catch((err) => + log.warn(`deferred prompt profile failure mark failed: ${String(err)}`), + ); + } traceAttempts.push({ provider, model: modelId, @@ -2072,6 +2076,15 @@ export async function runEmbeddedPiAgent( profileRotated: true, }); } + if (failedPromptProfileId && promptProfileFailureReason) { + maybeMarkAuthProfileFailure({ + profileId: failedPromptProfileId, + reason: promptProfileFailureReason, + modelId, + }).catch((err) => + log.warn(`deferred prompt profile failure mark failed: ${String(err)}`), + ); + } const fallbackThinking = pickFallbackThinkingLevel({ message: errorText, attempted: attemptedThinking, diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts index 551640c5863..6e529c8328a 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts @@ -57,6 +57,66 @@ function expectThrownFailoverError(outcome: Outcome): FailoverError { } describe("handleAssistantFailover", () => { + describe("rotate_profile branch", () => { + it("rotates before waiting on auth profile failure marking", async () => { + const events: string[] = []; + let releaseMark!: () => void; + const markFinished = new Promise((resolve) => { + releaseMark = resolve; + }); + const markSettled = new Promise((resolve) => { + void markFinished.then(() => resolve()); + }); + const maybeMarkAuthProfileFailure = vi.fn(async () => { + events.push("mark-start"); + await markFinished; + events.push("mark-finish"); + }); + + const outcome = await handleAssistantFailover( + makeParams({ + initialDecision: { action: "rotate_profile", reason: "rate_limit" }, + failoverReason: "rate_limit", + assistantProfileFailureReason: "rate_limit", + lastProfileId: "openai:p1", + billingFailure: false, + rateLimitFailure: true, + maybeMarkAuthProfileFailure, + advanceAuthProfile: vi.fn(async () => { + events.push("advance"); + return true; + }), + }), + ); + + expect(outcome.action).toBe("retry"); + expect(events).toEqual(["advance", "mark-start"]); + releaseMark(); + await markSettled; + await vi.waitFor(() => expect(events).toEqual(["advance", "mark-start", "mark-finish"])); + expect(events).toEqual(["advance", "mark-start", "mark-finish"]); + }); + + it("does not log profile-specific warnings without a failed profile id", async () => { + const warn = vi.fn(); + const outcome = await handleAssistantFailover( + makeParams({ + initialDecision: { action: "rotate_profile", reason: "timeout" }, + failoverReason: "timeout", + timedOut: true, + cloudCodeAssistFormatError: true, + lastProfileId: undefined, + billingFailure: false, + advanceAuthProfile: vi.fn(async () => true), + warn, + }), + ); + + expect(outcome.action).toBe("retry"); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("Profile undefined")); + }); + }); + describe("surface_error branch (openclaw#70124)", () => { it("throws a billing FailoverError so the webchat can render the provider failure", async () => { const logDecision = vi.fn(); diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.ts b/src/agents/pi-embedded-runner/run/assistant-failover.ts index a200a86e434..752e24ec1c3 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.ts @@ -97,22 +97,20 @@ export async function handleAssistantFailover(params: { }; if (decision.action === "rotate_profile") { - if (params.lastProfileId) { - const reason = params.timedOut ? "timeout" : params.assistantProfileFailureReason; - await params.maybeMarkAuthProfileFailure({ - profileId: params.lastProfileId, - reason, - modelId: params.modelId, - }); - if (params.timedOut && !params.isProbeSession) { - params.warn(`Profile ${params.lastProfileId} timed out. Trying next account...`); + const failedProfileId = params.lastProfileId; + const failureReason = params.timedOut ? "timeout" : params.assistantProfileFailureReason; + const markFailedProfile = () => { + if (!failedProfileId || !failureReason || failureReason === "timeout") { + return; } - if (params.cloudCodeAssistFormatError) { - params.warn( - `Profile ${params.lastProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`, - ); - } - } + params + .maybeMarkAuthProfileFailure({ + profileId: failedProfileId, + reason: failureReason, + modelId: params.modelId, + }) + .catch((err) => params.warn(`deferred profile failure mark failed: ${String(err)}`)); + }; if (params.failoverReason === "overloaded") { overloadProfileRotations += 1; @@ -124,6 +122,7 @@ export async function handleAssistantFailover(params: { params.warn( `overload profile rotation cap reached for ${sanitizeForLog(params.provider)}/${sanitizeForLog(params.modelId)} after ${overloadProfileRotations} rotations; escalating to model fallback`, ); + markFailedProfile(); params.logAssistantFailoverDecision("fallback_model", { status }); return { action: "throw", @@ -152,6 +151,15 @@ export async function handleAssistantFailover(params: { } const rotated = await params.advanceAuthProfile(); + markFailedProfile(); + if (params.timedOut && !params.isProbeSession && failedProfileId) { + params.warn(`Profile ${failedProfileId} timed out. Trying next account...`); + } + if (params.cloudCodeAssistFormatError && failedProfileId) { + params.warn( + `Profile ${failedProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`, + ); + } if (rotated) { params.logAssistantFailoverDecision("rotate_profile"); await params.maybeBackoffBeforeOverloadFailover(params.failoverReason); From 0fe6a3c938303d98b87938c2584573c234b58877 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:49:45 +0100 Subject: [PATCH 247/806] test: tighten subagent registry timestamps --- src/agents/subagent-registry.announce-loop-guard.test.ts | 3 ++- src/agents/subagent-registry.persistence.test.ts | 6 ++++-- src/agents/subagent-registry.steer-restart.test.ts | 7 ++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 5ea9ddac6dd..eba02ce175c 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -188,12 +188,13 @@ describe("announce loop guard (#18264)", () => { mocks.loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]])); // Initialization attempts resume once, then gives up for exhausted entries. + const beforeInit = Date.now(); registry.initSubagentRegistry(); await Promise.resolve(); await Promise.resolve(); expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(entry.cleanupCompletedAt).toEqual(expect.any(Number)); + expect(entry.cleanupCompletedAt).toBeGreaterThanOrEqual(beforeInit); }); test("expired completion-message entries are still resumed for announce", async () => { diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 1d9beec6fc2..d013acdb581 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -507,6 +507,7 @@ describe("subagent registry persistence", () => { expect(afterFirst?.cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); + const beforeRetry = Date.now(); restartRegistry(); await waitForRegistryWork(async () => { const afterSecond = await readPersistedRun<{ @@ -519,7 +520,7 @@ describe("subagent registry persistence", () => { const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { runs: Record; }; - expect(afterSecond.runs["run-3"].cleanupCompletedAt).toEqual(expect.any(Number)); + expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("retries cleanup announce after announce flow rejects", async () => { @@ -553,6 +554,7 @@ describe("subagent registry persistence", () => { expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); + const beforeRetry = Date.now(); restartRegistry(); await waitForRegistryWork(async () => { const afterSecond = await readPersistedRun<{ @@ -565,7 +567,7 @@ describe("subagent registry persistence", () => { const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { runs: Record; }; - expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toEqual(expect.any(Number)); + expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("keeps delete-mode runs retryable when announce is deferred", async () => { diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index fa9c3bd7503..0e43283b139 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -566,9 +566,10 @@ describe("subagent registry steer restarts", () => { const run = listMainRuns()[0]; expect(run?.outcome).toMatchObject({ status: "error", error: "manual kill" }); - expect(run?.outcome?.startedAt).toEqual(expect.any(Number)); - expect(run?.outcome?.endedAt).toEqual(expect.any(Number)); - expect(run?.outcome?.elapsedMs).toEqual(expect.any(Number)); + expect(run?.outcome?.startedAt).toBeTypeOf("number"); + expect(run?.outcome?.endedAt).toBeTypeOf("number"); + expect(run?.outcome?.elapsedMs).toBeTypeOf("number"); + expect(run?.outcome?.elapsedMs).toBeGreaterThanOrEqual(0); expect(run?.outcome?.endedAt).toBeGreaterThanOrEqual(run?.outcome?.startedAt ?? 0); expect(run?.cleanupHandled).toBe(true); expect(typeof run?.cleanupCompletedAt).toBe("number"); From 0248305ab25a972fca4af53404d2b433e194ac03 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:51:08 +0100 Subject: [PATCH 248/806] test: tighten gateway compaction ids --- src/gateway/session-compaction-checkpoints.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index ff418a1dd03..729062f5545 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -196,7 +196,8 @@ describe("session-compaction-checkpoints", () => { expect(forkSpy).not.toHaveBeenCalled(); expect(forked).not.toBeNull(); expect(forked?.sessionFile).not.toBe(sessionFile); - expect(forked?.sessionId).toEqual(expect.any(String)); + expect(forked?.sessionId).toBeTypeOf("string"); + expect(forked?.sessionId).not.toBe(""); } finally { openSpy.mockRestore(); forkSpy.mockRestore(); @@ -289,13 +290,15 @@ describe("session-compaction-checkpoints", () => { parentId: null, message: expect.objectContaining({ content: "legacy first" }), }); - expect(forkedEntries[1]?.id).toEqual(expect.any(String)); + expect(forkedEntries[1]?.id).toBeTypeOf("string"); + expect(forkedEntries[1]?.id).not.toBe(""); expect(forkedEntries[2]).toMatchObject({ type: "message", parentId: forkedEntries[1]?.id, message: expect.objectContaining({ content: "legacy second" }), }); - expect(forkedEntries[2]?.id).toEqual(expect.any(String)); + expect(forkedEntries[2]?.id).toBeTypeOf("string"); + expect(forkedEntries[2]?.id).not.toBe(""); const messages = SessionManager.open(forked!.sessionFile, dir).buildSessionContext().messages; expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ From a16f0dd73cba359944af47e1a3ce7e8646f02f0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:52:15 +0100 Subject: [PATCH 249/806] test: tighten session status ids --- src/agents/openclaw-tools.session-status.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index a3c4ac5dbde..734a6dfb5fd 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -900,7 +900,7 @@ describe("session_status tool", () => { liveModelSwitchPending: true, }), ); - expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId).toBeTypeOf("string"); expect(saved.sessionId.trim().length).toBeGreaterThan(0); }); @@ -928,7 +928,7 @@ describe("session_status tool", () => { liveModelSwitchPending: true, }), ); - expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId).toBeTypeOf("string"); expect(saved.sessionId.trim().length).toBeGreaterThan(0); }); From f9a29a06efdc4e6f571d7e53f1e309a638444aea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:53:25 +0100 Subject: [PATCH 250/806] test: tighten agent string assertions --- src/agents/pi-embedded-error-observation.test.ts | 4 ++-- src/agents/skills.bundled-frontmatter.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index c89e587a263..bdfdbf8cf8b 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -109,8 +109,8 @@ describe("buildApiErrorObservationFields", () => { `{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`, ); - expect(observed.rawErrorPreview).toEqual(expect.any(String)); - expect(observed.providerErrorMessagePreview).toEqual(expect.any(String)); + expect(observed.rawErrorPreview).toBeTypeOf("string"); + expect(observed.providerErrorMessagePreview).toBeTypeOf("string"); expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401); expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201); expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true); diff --git a/src/agents/skills.bundled-frontmatter.test.ts b/src/agents/skills.bundled-frontmatter.test.ts index 1fbbf4e713c..4d2ad708f31 100644 --- a/src/agents/skills.bundled-frontmatter.test.ts +++ b/src/agents/skills.bundled-frontmatter.test.ts @@ -17,9 +17,9 @@ describe("bundled taskflow skill frontmatter", () => { const raw = await fs.readFile(path.join(repoRoot, relativePath), "utf8"); const frontmatter = parseFrontmatter(raw); - expect(frontmatter.name, relativePath).toEqual(expect.any(String)); + expect(frontmatter.name, relativePath).toBeTypeOf("string"); expect(frontmatter.name.length, relativePath).toBeGreaterThan(0); - expect(frontmatter.description, relativePath).toEqual(expect.any(String)); + expect(frontmatter.description, relativePath).toBeTypeOf("string"); expect(frontmatter.description.length, relativePath).toBeGreaterThan(0); } }); From 2008873be6b6365bbde84baa33b91ba3f8c99fce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:54:31 +0100 Subject: [PATCH 251/806] test: tighten agent timestamp assertions --- src/agents/main-session-restart-recovery.test.ts | 5 +++-- src/agents/tools/video-generate-background.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 951d40dfc61..f99c3daef87 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -305,6 +305,7 @@ describe("main-session-restart-recovery", () => { const callParams = vi.mocked(callGateway).mock.calls[0]?.[0].params as { message?: string }; expect(callParams.message).toContain(pendingPayload); + const beforeStoreRead = Date.now(); const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); const entry = store["agent:main:main"]; expect(entry).toMatchObject({ @@ -314,8 +315,8 @@ describe("main-session-restart-recovery", () => { pendingFinalDeliveryAttemptCount: 1, pendingFinalDeliveryLastError: null, }); - expect(entry?.pendingFinalDeliveryCreatedAt).toEqual(expect.any(Number)); - expect(entry?.pendingFinalDeliveryLastAttemptAt).toEqual(expect.any(Number)); + expect(entry?.pendingFinalDeliveryCreatedAt).toBeLessThanOrEqual(beforeStoreRead); + expect(entry?.pendingFinalDeliveryLastAttemptAt).toBeLessThanOrEqual(beforeStoreRead); expect(entry?.pendingFinalDeliveryLastAttemptAt ?? 0).toBeGreaterThanOrEqual( entry?.pendingFinalDeliveryCreatedAt ?? Number.POSITIVE_INFINITY, ); diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index 9c260284e4f..89533e29af3 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -104,12 +104,13 @@ describe("video generate background helpers", () => { sessionKey: "agent:main:discord:channel:123", }); + const beforeProgress = Date.now(); recordVideoGenerationTaskProgress({ handle, progressSummary: "Generating video", }); - expect(getAgentRunContext(handle.runId)?.lastActiveAt).toEqual(expect.any(Number)); + expect(getAgentRunContext(handle.runId)?.lastActiveAt).toBeGreaterThanOrEqual(beforeProgress); failVideoGenerationTaskRun({ handle, From 048a50cfe15627d997590f1d484a5849255655b6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:56:01 +0100 Subject: [PATCH 252/806] test: tighten auto reply timestamps --- src/auto-reply/reply/commands-tts.test.ts | 3 ++- src/auto-reply/reply/inbound-meta.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index 7458d92110e..b6d6b6946ac 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -315,6 +315,7 @@ describe("handleTtsCommands status fallback reporting", () => { const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1, sessionFile }; const sessionStore = { "session-key": sessionEntry }; + const beforeTtsRead = Date.now(); const result = await handleTtsCommands( buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }), true, @@ -330,7 +331,7 @@ describe("handleTtsCommands status fallback reporting", () => { expect.objectContaining({ text: "latest visible reply" }), ); expect(sessionEntry.lastTtsReadLatestHash).toMatch(/^[a-f0-9]{64}$/); - expect(sessionEntry.lastTtsReadLatestAt).toEqual(expect.any(Number)); + expect(sessionEntry.lastTtsReadLatestAt).toBeGreaterThanOrEqual(beforeTtsRead); }); it("does not resend /tts latest for the same assistant reply", async () => { diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 22e43fad321..66d49fb5d5c 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -389,7 +389,7 @@ describe("buildInboundUserContextPrefix", () => { } as TemplateContext); const conversationInfo = parseConversationInfoPayload(text); - expect(conversationInfo["timestamp"]).toEqual(expect.any(String)); + expect(conversationInfo["timestamp"]).toBe("Sun 2026-02-15 13:35 GMT"); }); it("honors envelope user timezone for conversation timestamps", () => { From cd89496d08093a9a149d440390c7ef624aab962e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 14:57:41 +0100 Subject: [PATCH 253/806] test: tighten timeout signature assertions --- src/agents/bash-tools.exec-foreground-failures.test.ts | 3 ++- src/agents/bootstrap-budget.test.ts | 3 ++- src/infra/push-apns.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/agents/bash-tools.exec-foreground-failures.test.ts b/src/agents/bash-tools.exec-foreground-failures.test.ts index 4ca357b67de..34e6d91197e 100644 --- a/src/agents/bash-tools.exec-foreground-failures.test.ts +++ b/src/agents/bash-tools.exec-foreground-failures.test.ts @@ -48,7 +48,8 @@ describe("exec foreground failures", () => { exitCode: null, aggregated: "", }); - expect((result.details as { durationMs?: number }).durationMs).toEqual(expect.any(Number)); + expect((result.details as { durationMs?: number }).durationMs).toBeTypeOf("number"); + expect((result.details as { durationMs?: number }).durationMs).toBeGreaterThanOrEqual(0); }); it("rejects invalid host values before launching a command", async () => { diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index 2b6d246fe14..fc832dfc42a 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -211,7 +211,8 @@ describe("bootstrap prompt warnings", () => { mode: "once", }); expect(first.warningShown).toBe(true); - expect(first.signature).toEqual(expect.any(String)); + expect(first.signature).toBeTypeOf("string"); + expect(first.signature).not.toBe(""); expect(JSON.parse(first.signature ?? "{}")).toMatchObject({ bootstrapMaxChars: 120, bootstrapTotalMaxChars: 200, diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 12e90ff4bbe..95a3d02acb3 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -589,7 +589,8 @@ describe("push APNs send semantics", () => { }, }, }); - expect(sent?.signature).toEqual(expect.any(String)); + expect(sent?.signature).toBeTypeOf("string"); + expect(sent?.signature).not.toBe(""); expect(result).toMatchObject({ ok: true, status: 202, From dd1b276a9ca05e1b004120a4183a7ff32fcfa972 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:00:10 +0100 Subject: [PATCH 254/806] test: tighten provider stream assertions --- src/agents/openai-transport-stream.test.ts | 3 ++- src/plugin-sdk/provider-stream.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 522363b0353..a5eb59e49a7 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -998,7 +998,8 @@ describe("openai transport stream", () => { }; expect(params.instructions).toBe("Stable prefix\nDynamic suffix"); - expect(params.input).toEqual(expect.any(Array)); + expect(Array.isArray(params.input)).toBe(true); + expect(params.input?.map((item) => item.role)).toEqual(["user"]); expect( params.input?.filter((item) => item.role === "system" || item.role === "developer"), ).toEqual([]); diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 962565049a1..05c32338af4 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -239,7 +239,8 @@ describe("buildProviderStreamFamilyHooks", () => { config: { thinkingConfig: { thinkingBudget: -1 } }, service_tier: "flex", }); - expect(capturedHeaders).toEqual(expect.any(Object)); + expect(capturedHeaders).toBeTypeOf("object"); + expect(capturedHeaders).not.toBeNull(); const openRouterHooks = OPENROUTER_THINKING_STREAM_HOOKS; void requireStreamFn( From 5c39e2da3ac80c64f7202f1474e79f926ae6a794 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:01:34 +0100 Subject: [PATCH 255/806] test: accept utc timestamp label --- src/auto-reply/reply/inbound-meta.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 66d49fb5d5c..1de1fdd4f3a 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -389,7 +389,7 @@ describe("buildInboundUserContextPrefix", () => { } as TemplateContext); const conversationInfo = parseConversationInfoPayload(text); - expect(conversationInfo["timestamp"]).toBe("Sun 2026-02-15 13:35 GMT"); + expect(conversationInfo["timestamp"]).toMatch(/^Sun 2026-02-15 13:35 (?:GMT|UTC)$/); }); it("honors envelope user timezone for conversation timestamps", () => { From 7d20be5fb5e8e12354cc76fed74dd44a6dc1dae1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:02:20 +0100 Subject: [PATCH 256/806] test: tighten gateway health auth assertions --- src/gateway/server.auth.compat-baseline.test.ts | 2 +- src/gateway/server.health.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 699f567eead..2155545a668 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -217,7 +217,7 @@ describe("gateway auth compatibility baseline", () => { }); expect(rotated.ok).toBe(true); const rotatedToken = rotated.ok ? rotated.entry.token : ""; - expect(rotatedToken).toEqual(expect.any(String)); + expect(rotatedToken).toBeTypeOf("string"); expect(rotatedToken.length).toBeGreaterThan(0); const ws = await openWs(port); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 6b6611db0c6..da0e2fd2353 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -167,7 +167,7 @@ describe("gateway server health/presence", () => { await localHarness.close(); const evt = await shutdownP; const evtPayload = evt.payload as { reason?: unknown } | undefined; - expect(evtPayload?.reason).toEqual(expect.any(String)); + expect(evtPayload?.reason).toBe("gateway stopping"); }); test( From 94911768116ec19ddae9cfe74019e8197af91225 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:03:49 +0100 Subject: [PATCH 257/806] test: tighten provider rewrite assertions --- ...config.uses-first-github-copilot-profile-env-tokens.test.ts | 3 ++- .../pi-embedded-runner/context-engine-maintenance.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index bbedd794d31..609d720794e 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -271,6 +271,7 @@ function expectCopilotProviderFromPlan( plan.action === "write" ? (JSON.parse(plan.contents) as { providers?: Record }) : {}; - expect(parsed.providers?.["github-copilot"]).toEqual(expect.any(Object)); + expect(parsed.providers?.["github-copilot"]).toBeDefined(); + expect(parsed.providers?.["github-copilot"]).not.toBeNull(); return expect(parsed.providers?.["github-copilot"]); } diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index 079c387e562..62b04f772c7 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -201,7 +201,8 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, ], }); - expect(rewritePromise).toEqual(expect.any(Promise)); + expect(rewritePromise).toBeDefined(); + expect(rewritePromise?.then).toBeTypeOf("function"); await flushAsyncWork(); expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled(); From eecef7e10cd94ef3b3ed7d4d60a9cd50f62ddd2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:05:29 +0100 Subject: [PATCH 258/806] test: tighten storage doctor assertions --- ...r.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts | 3 ++- ui/src/ui/storage.node.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index 71369a59672..50d8b4331a6 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -174,7 +174,8 @@ describe("doctor command", () => { throw new Error("Expected doctor to write migrated auth profiles"); } const profiles = (written.auth as { profiles: Record }).profiles; - expect(profiles["anthropic:me@example.com"]).toEqual(expect.any(Object)); + expect(profiles).toHaveProperty("anthropic:me@example.com"); + expect(profiles["anthropic:me@example.com"]).not.toBeNull(); expect(profiles["anthropic:default"]).toBeUndefined(); }, 30_000); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index e8f39474a9d..3cc9979433e 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -571,7 +571,8 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); - expect(persisted.sessionsByGateway).toEqual(expect.any(Object)); + expect(persisted.sessionsByGateway).toBeTypeOf("object"); + expect(persisted.sessionsByGateway).not.toBeNull(); const scopes = Object.keys(persisted.sessionsByGateway); expect(scopes).toHaveLength(10); // oldest stale entries should be evicted From ff860dcf6eb2c4ee4ebe6d1db77be29003a89baa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:06:59 +0100 Subject: [PATCH 259/806] test: tighten slack slash session key --- extensions/slack/src/monitor/slash.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index e4e02bf4d10..b2dbc7d2f6d 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1226,7 +1226,8 @@ describe("slack slash command session metadata", () => { }; expect(call.ctx?.OriginatingChannel).toBe("slack"); expect(call.ctx?.GroupSpace).toBe("T1"); - expect(call.sessionKey).toEqual(expect.any(String)); + expect(call.sessionKey).toBeTypeOf("string"); + expect(call.sessionKey).not.toBe(""); }); it("awaits session metadata persistence before dispatch", async () => { From dce9261415f79e9e4bff2b3d83ee438d002ad5ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:08:47 +0100 Subject: [PATCH 260/806] test: tighten e2e helper assertions --- src/gateway/android-node.capabilities.live.test.ts | 3 ++- src/gateway/gateway-cli-backend.live-helpers.test.ts | 3 ++- test/cli-json-stdout.e2e.test.ts | 4 +++- test/scripts/parallels-smoke-model.test.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index fdfe045538c..977f8660396 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -47,7 +47,8 @@ function asRecord(value: unknown): Record { } function expectRecord(value: unknown, label: string): Record { - expect(value, label).toEqual(expect.any(Object)); + expect(value, label).toBeTypeOf("object"); + expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); return value as Record; } diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 7faf0e5a85b..0a37a19a00e 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,7 +88,8 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(client).toEqual(expect.any(Object)); + expect(client).toBeTypeOf("object"); + expect(client).not.toBeNull(); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index d12f239feac..6280f755cf8 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -34,7 +34,9 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(parsed).toEqual(expect.any(Object)); + expect(parsed).toBeTypeOf("object"); + expect(parsed).not.toBeNull(); + expect(Array.isArray(parsed)).toBe(false); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index c2cea49a4dc..eb4c8bc821e 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -481,7 +481,7 @@ console.log(JSON.stringify(result)); ) as { status: number; stdout: string }; expect(result.status).toBe(124); - expect(result.stdout).toEqual(expect.any(String)); + expect(result.stdout).toBeTypeOf("string"); }); it("runs the Windows agent turn through the detached done-file runner", () => { From 9da2f7cf812cf8ad232fbaedf2edd96994dfa953 Mon Sep 17 00:00:00 2001 From: Statxc Date: Fri, 8 May 2026 09:11:17 -0500 Subject: [PATCH 261/806] fix(gateway): reset webchat /new in place when dmScope is main (#77434) (#71170) Merged via squash. Prepared head SHA: 96a9a83eaccc615466c92039d2b43e15057f309a Co-authored-by: statxc <181730535+statxc@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 2 +- docs/web/control-ui.md | 2 +- src/gateway/server-methods/sessions.ts | 41 ++++++++++++ .../server.sessions.reset-hooks.test.ts | 64 ++++++++++++++++++- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b10de4941..b4c94d7c930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -653,6 +653,7 @@ Docs: https://docs.openclaw.ai - Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. - Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. - Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows. +- Gateway/sessions: when `session.dmScope: "main"` is configured, route a bare webchat `/new` against the agent's main session (`sessions.create` with `emitCommandHooks=true`) to an in-place reset instead of creating a parallel `dashboard:` child, matching `/new` behavior on Telegram/Discord. Fixes #77434. (#71170) Thanks @statxc. ## 2026.5.3-1 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 07735a3a15b..f0a32073f1e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -125,7 +125,7 @@ Current source-of-truth: - `/new [model]` starts a new session; `/reset` is the reset alias. - - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session; typed `/reset` still runs the Gateway's in-place reset. + - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). - `/stop` aborts the current run. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8a9edd50643..ce014cb893b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -165,7 +165,7 @@ Imported themes are stored only in the current browser profile. They are not wri - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model. - - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. + - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports include current context tokens, the chat composer area shows a compact context usage indicator. It switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index dd3de12dff0..0b29745097c 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -26,6 +26,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, @@ -1023,6 +1024,46 @@ export const sessionsHandlers: GatewayRequestHandlers = { } canonicalParentSessionKey = parent.canonicalKey; } + if ( + canonicalParentSessionKey && + p.emitCommandHooks === true && + !requestedKey && + !resolveOptionalInitialSessionMessage(p) && + cfg.session?.dmScope === "main" + ) { + const parentAgentId = normalizeAgentId( + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + ); + const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }); + if (canonicalParentSessionKey === parentMainKey) { + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); + const resetResult = await performGatewaySessionReset({ + key: canonicalParentSessionKey, + reason: "new", + commandSource: "webchat", + }); + if (!resetResult.ok) { + respond(false, undefined, resetResult.error); + return; + } + respond( + true, + { + ok: true, + key: resetResult.key, + sessionId: resetResult.entry.sessionId, + entry: resetResult.entry, + runStarted: false, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: resetResult.key, + reason: "new", + }); + return; + } + } if (canonicalParentSessionKey && p.emitCommandHooks === true) { const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); const parentAgentId = normalizeAgentId( diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index f0efa97cda8..1557718667e 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { embeddedRunMock, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, bootstrapCacheMocks, @@ -410,6 +410,68 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga ); }); +test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { + const { dir } = await createSessionStoreDir(); + const transcriptPath = path.join(dir, "sess-parent-dms.jsonl"); + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + message: { role: "user", content: "hello before /new" }, + })}\n`, + "utf-8", + ); + + testState.sessionConfig = { dmScope: "main" }; + try { + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent-dms", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + const result = await directSessionReq<{ + ok: boolean; + key: string; + sessionId: string; + runStarted: boolean; + }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + // Reset-in-place: response key matches the parent main key, NOT a dashboard child. + expect(result.payload?.key).toBe("agent:main:main"); + expect(result.payload?.runStarted).toBe(false); + expect(result.payload?.sessionId).not.toBe("sess-parent-dms"); + + expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); + expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1); + const [endEvent] = ( + sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + const [startEvent] = ( + sessionLifecycleHookMocks.runSessionStart.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + expect(endEvent).toMatchObject({ + sessionId: "sess-parent-dms", + sessionKey: "agent:main:main", + reason: "new", + }); + expect(startEvent).toMatchObject({ + sessionKey: "agent:main:main", + resumedFrom: "sess-parent-dms", + }); + } finally { + testState.sessionConfig = undefined; + } +}); + test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2"); From f6476140d25ffcbd7512dbed688fa0a7166fe4e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:11:17 +0100 Subject: [PATCH 262/806] test: tighten live provider assertions --- .../browser/src/browser/pw-session.browserless.live.test.ts | 3 ++- src/agents/pi-embedded-runner-extraparams.live.test.ts | 4 ++-- src/agents/xai.live.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.browserless.live.test.ts b/extensions/browser/src/browser/pw-session.browserless.live.test.ts index abd2d71bf73..19030387c1f 100644 --- a/extensions/browser/src/browser/pw-session.browserless.live.test.ts +++ b/extensions/browser/src/browser/pw-session.browserless.live.test.ts @@ -18,7 +18,8 @@ describeLive("browser (live): remote CDP tab persistence", () => { await pw.closePlaywrightBrowserConnection().catch(() => {}); const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" }); - expect(created.targetId).toEqual(expect.any(String)); + expect(created.targetId).toBeTypeOf("string"); + expect(created.targetId).not.toBe(""); try { await waitFor( async () => { diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 6cd1362d42f..18971aa50d5 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -59,8 +59,8 @@ describeLive("pi embedded extra params (live)", () => { } } - expect(stopReason).toEqual(expect.any(String)); - expect(outputTokens).toEqual(expect.any(Number)); + expect(stopReason).toBeTypeOf("string"); + expect(outputTokens).toBeTypeOf("number"); // Should respect maxTokens from config (16) — allow a small buffer for provider rounding. expect(outputTokens ?? 0).toBeLessThanOrEqual(20); }, 30_000); diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index 2201ccbdeee..0eb973b196f 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -119,7 +119,7 @@ describeLive("xai live", () => { const doneMessage = await collectDoneMessage( stream as AsyncIterable<{ type: string; message?: AssistantLikeMessage }>, ); - expect(doneMessage.content).toEqual(expect.any(Array)); + expect(Array.isArray(doneMessage.content)).toBe(true); const payload = requireLiveValue(capturedPayload, "captured xAI payload"); if ("tool_stream" in payload) { expect(payload.tool_stream).toBe(true); From 60068c52b04eced4ffae601460607f405526b310 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:17:48 +0100 Subject: [PATCH 263/806] test: run json stdout e2e from source --- test/cli-json-stdout.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 6280f755cf8..61d5905028a 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -23,10 +23,10 @@ describe("cli json stdout contract", () => { delete env.OPENCLAW_CONFIG_PATH; delete env.VITEST; - const entry = path.resolve(process.cwd(), "openclaw.mjs"); + const entry = path.resolve(process.cwd(), "src/entry.ts"); const result = spawnSync( process.execPath, - [entry, "update", "status", "--json", "--timeout", "1"], + ["--import", "tsx", entry, "update", "status", "--json", "--timeout", "1"], { cwd: process.cwd(), env, encoding: "utf8" }, ); From d0ea4056622f0d830c6699ba61ebd1e7dc061abd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:17:51 +0100 Subject: [PATCH 264/806] test: tighten object shape assertions --- src/agents/runtime-plan/build.test.ts | 2 +- src/commands/tasks.test.ts | 15 +++++++++++---- src/cron/service.read-ops-nonblocking.test.ts | 10 ++++++---- src/plugin-sdk/provider-stream.test.ts | 8 ++++++-- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 628e8b860ae..0a4b8eee15b 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -156,7 +156,7 @@ describe("AgentRuntimePlan", () => { expect(normalized).toHaveLength(1); expect(normalized[0]?.name).toBe("ping"); - expect(normalized[0]?.parameters).toBeTypeOf("object"); + expect(normalized[0]?.parameters).toStrictEqual({}); }); it("does not forward OpenAI API-key profiles into the Codex harness auth slot", () => { diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 4b99ed596ce..5e7039d3c1a 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -20,6 +20,15 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +const zeroTaskAuditCounts = { + delivery_failed: 0, + inconsistent_timestamps: 0, + lost: 0, + missing_cleanup: 0, + stale_queued: 0, + stale_running: 0, +}; + async function withTaskCommandStateDir(run: () => Promise): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, @@ -150,11 +159,9 @@ describe("tasks commands", () => { expect(payload.mode).toBe("preview"); expect(payload.maintenance.taskFlows.pruned).toBe(1); - expect(payload.auditBefore.byCode).toBeTypeOf("object"); - expect(Array.isArray(payload.auditBefore.byCode)).toBe(false); + expect(payload.auditBefore.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditBefore.taskFlows.byCode.stale_running).toBe(0); - expect(payload.auditAfter.byCode).toBeTypeOf("object"); - expect(Array.isArray(payload.auditAfter.byCode)).toBe(false); + expect(payload.auditAfter.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditAfter.taskFlows.byCode.stale_running).toBe(0); }); }); diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 311dffb995c..72d5c74337a 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -128,8 +128,10 @@ describe("CronService read ops while job is running", () => { await isolatedRun.runStarted; expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1); - await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); - await expect(cron.status()).resolves.toBeTypeOf("object"); + await expect(cron.list({ includeDisabled: true })).resolves.toHaveLength(1); + await expect(cron.status()).resolves.toEqual( + expect.objectContaining({ enabled: true, storePath: store.storePath }), + ); const running = await cron.list({ includeDisabled: true }); expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); @@ -197,7 +199,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during cron.run"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during cron.run")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); @@ -258,7 +260,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during startup"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during startup")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 05c32338af4..34c2e1a0ec2 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -1,5 +1,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; +import { VERSION } from "../version.js"; import { composeProviderStreamWrappers as composeProviderStreamWrappersShared, createMoonshotThinkingWrapper as createMoonshotThinkingWrapperShared, @@ -239,8 +240,11 @@ describe("buildProviderStreamFamilyHooks", () => { config: { thinkingConfig: { thinkingBudget: -1 } }, service_tier: "flex", }); - expect(capturedHeaders).toBeTypeOf("object"); - expect(capturedHeaders).not.toBeNull(); + expect(capturedHeaders).toEqual({ + "User-Agent": `openclaw/${VERSION}`, + originator: "openclaw", + version: VERSION, + }); const openRouterHooks = OPENROUTER_THINKING_STREAM_HOOKS; void requireStreamFn( From b7033369a66b975f1bccd34eca5f6e7ca8a99fe7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:21:00 +0100 Subject: [PATCH 265/806] test: tighten non-live object guards --- extensions/memory-wiki/src/tool.test.ts | 3 ++- src/gateway/server.talk-config.test.ts | 7 +++++-- src/infra/net/fetch-guard.ssrf.test.ts | 3 +-- test/cli-json-stdout.e2e.test.ts | 7 ++++++- ui/src/ui/storage.node.test.ts | 11 +++++++---- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index e6469b964d9..9fd0acf780e 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,8 +3,9 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { - expect(value).toBeTypeOf("object"); + expect(typeof value).toBe("object"); expect(value).not.toBeNull(); + expect(Array.isArray(value)).toBe(false); return value as Record; } diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index f85342f74c7..dcae34484ec 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -383,8 +383,11 @@ describe("gateway talk.config", () => { // the UI keeps the SecretRef context, but every field becomes the // sentinel so no credential material leaks to read-scope callers. const redactedApiKey = talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.apiKey; - expect(redactedApiKey).toBeTypeOf("object"); - expect((redactedApiKey as SecretRef).id).toBe("__OPENCLAW_REDACTED__"); + expect(redactedApiKey).toEqual({ + id: "__OPENCLAW_REDACTED__", + provider: "__OPENCLAW_REDACTED__", + source: "__OPENCLAW_REDACTED__", + }); expect(talk?.resolved?.config?.apiKey).toEqual(redactedApiKey); }); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 1246142abf2..684d0ab43fe 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -74,8 +74,7 @@ function getDispatcherClassName(value: unknown): string | null { } function expectDispatcherAttached(value: unknown): void { - expect(value).toBeTypeOf("object"); - expect(value).not.toBeNull(); + expect(getDispatcherClassName(value)).toMatch(/^(Agent|Mock)$/u); } function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 61d5905028a..f47d3678d16 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -34,9 +34,14 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(parsed).toBeTypeOf("object"); + expect(typeof parsed).toBe("object"); expect(parsed).not.toBeNull(); expect(Array.isArray(parsed)).toBe(false); + expect(Object.keys(parsed as Record).sort()).toEqual([ + "availability", + "channel", + "update", + ]); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 3cc9979433e..31c8a4a48b3 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -571,9 +571,12 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); - expect(persisted.sessionsByGateway).toBeTypeOf("object"); - expect(persisted.sessionsByGateway).not.toBeNull(); - const scopes = Object.keys(persisted.sessionsByGateway); + const sessionsByGateway = persisted.sessionsByGateway as unknown; + expect(typeof sessionsByGateway).toBe("object"); + expect(sessionsByGateway).not.toBeNull(); + expect(Array.isArray(sessionsByGateway)).toBe(false); + const scopedSessions = sessionsByGateway as Record; + const scopes = Object.keys(scopedSessions); expect(scopes).toHaveLength(10); // oldest stale entries should be evicted expect(scopes).not.toContain("wss://stale-0.example:8443"); @@ -581,7 +584,7 @@ describe("loadSettings default gateway URL derivation", () => { // newest stale entries and the current gateway should be retained expect(scopes).toContain("wss://stale-10.example:8443"); expect(scopes).toContain("wss://gateway.example:8443"); - expect(persisted.sessionsByGateway["wss://gateway.example:8443"]).toEqual({ + expect(scopedSessions["wss://gateway.example:8443"]).toEqual({ sessionKey: "agent:current:main", lastActiveSessionKey: "agent:current:main", }); From 7c31a9aafc1cd07dc83463aea45db0d36fbd6098 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:23:09 +0100 Subject: [PATCH 266/806] test: clear object shape matcher scan --- src/cli/config-cli.test.ts | 6 ++++-- src/docs/clawhub-plugin-docs.test.ts | 3 ++- src/gateway/android-node.capabilities.live.test.ts | 2 +- src/gateway/gateway-cli-backend.live-helpers.test.ts | 4 +++- src/plugins/bundled-plugin-metadata.test.ts | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 3500d7cfb11..6b9034ef28e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -673,8 +673,10 @@ describe("config cli", () => { properties?: Record; }; expect(payload.properties?.$schema).toEqual({ type: "string" }); - expect(payload.properties?.channels).toBeTypeOf("object"); - expect(payload.properties?.channels).not.toBeNull(); + expect(payload.properties?.channels).toMatchObject({ + type: "object", + properties: { telegram: { type: "object" } }, + }); expect(payload.properties?.plugins).toBeUndefined(); expect(mockError).not.toHaveBeenCalled(); }); diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 968b6beca17..3fd19fa4640 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -42,8 +42,9 @@ describe("ClawHub plugin docs", () => { expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]); expect(typeof pluginManifest.id).toBe("string"); - expect(pluginManifest.configSchema).toBeTypeOf("object"); + expect(typeof pluginManifest.configSchema).toBe("object"); expect(pluginManifest.configSchema).not.toBeNull(); + expect(Array.isArray(pluginManifest.configSchema)).toBe(false); }); it("does not tell plugin authors to use bare clawhub publish", async () => { diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index 977f8660396..bd295aa97d8 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -47,7 +47,7 @@ function asRecord(value: unknown): Record { } function expectRecord(value: unknown, label: string): Record { - expect(value, label).toBeTypeOf("object"); + expect(typeof value, label).toBe("object"); expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); return value as Record; diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 0a37a19a00e..5a0eb4e88bb 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,8 +88,10 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(client).toBeTypeOf("object"); + expect(typeof client).toBe("object"); expect(client).not.toBeNull(); + expect(typeof (client as { start?: unknown }).start).toBe("function"); + expect(typeof (client as { stopAndWait?: unknown }).stopAndWait).toBe("function"); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 0d1dcf7c07a..8860299ad11 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -454,8 +454,9 @@ describe("bundled plugin metadata", () => { it("keeps config schemas on all bundled plugin manifests", () => { for (const entry of listRepoBundledPluginMetadata()) { - expect(entry.manifest.configSchema).toBeTypeOf("object"); + expect(typeof entry.manifest.configSchema).toBe("object"); expect(entry.manifest.configSchema).not.toBeNull(); + expect(Array.isArray(entry.manifest.configSchema)).toBe(false); } }); From f309a4020dbda90460dece847ad652fbe051411c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:25:24 +0100 Subject: [PATCH 267/806] test: clear defined matcher scan --- extensions/discord/src/voice/manager.e2e.test.ts | 7 ++++--- ....uses-first-github-copilot-profile-env-tokens.test.ts | 7 ++++--- .../context-engine-maintenance.test.ts | 1 - src/gateway/talk-realtime-relay.test.ts | 9 ++++++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 5859198d9cb..359aa2cad21 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -1182,10 +1182,11 @@ describe("DiscordVoiceManager", () => { player: { state: { status: string } }; } | undefined; - expect(entry).toBeDefined(); - if (entry) { - entry.player.state.status = "playing"; + if (!entry) { + throw new Error("expected voice session for guild g1"); } + expect(entry.player.state.status).toBe("idle"); + entry.player.state.status = "playing"; await ( manager as unknown as { diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index 609d720794e..aeb53c12e70 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -271,7 +271,8 @@ function expectCopilotProviderFromPlan( plan.action === "write" ? (JSON.parse(plan.contents) as { providers?: Record }) : {}; - expect(parsed.providers?.["github-copilot"]).toBeDefined(); - expect(parsed.providers?.["github-copilot"]).not.toBeNull(); - return expect(parsed.providers?.["github-copilot"]); + const provider = parsed.providers?.["github-copilot"]; + expect(typeof provider).toBe("object"); + expect(provider).not.toBeNull(); + return expect(provider); } diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index 62b04f772c7..c39e09ee497 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -201,7 +201,6 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, ], }); - expect(rewritePromise).toBeDefined(); expect(rewritePromise?.then).toBeTypeOf("function"); await flushAsyncWork(); diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index 2ebc7bd14ef..458d8ce8451 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -552,6 +552,13 @@ describe("talk realtime gateway relay", () => { expect(() => createSession("conn-1")).toThrow( "Too many active realtime relay sessions for this connection", ); - expect(createSession("conn-2")).toBeDefined(); + expect(createSession("conn-2")).toMatchObject({ + provider: "relay-test", + transport: "gateway-relay", + audio: { + inputEncoding: "pcm16", + outputEncoding: "pcm16", + }, + }); }); }); From 7ff5e09289814822fab03ad8ddf79ab877817d66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:27:28 +0100 Subject: [PATCH 268/806] test: tighten nullable status assertions --- src/agents/anthropic-payload-log.test.ts | 8 ++++++-- src/agents/cache-trace.test.ts | 2 +- src/agents/tools/music-generate-tool.status.test.ts | 2 +- src/agents/tools/video-generate-tool.status.test.ts | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index a96a20bb06a..0b26f633cd2 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -14,7 +14,7 @@ describe("createAnthropicPayloadLogger", () => { flush: async () => undefined, }, }); - expect(logger).not.toBeNull(); + expect(typeof logger?.wrapStreamFn).toBe("function"); const payload = { messages: [ @@ -41,7 +41,11 @@ describe("createAnthropicPayloadLogger", () => { }) as StreamFn; const wrapped = logger?.wrapStreamFn(streamFn); - await wrapped?.({ api: "anthropic-messages" } as never, { messages: [] } as never, {}); + expect(typeof wrapped).toBe("function"); + if (!wrapped) { + throw new Error("expected payload logger to wrap stream function"); + } + await wrapped({ api: "anthropic-messages" } as never, { messages: [] } as never, {}); const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; const sanitizedPayload = (event.payload ?? {}) as Record; diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index f0d751072fb..9b13f03e088 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -53,7 +53,7 @@ describe("createCacheTrace", () => { }, }); - expect(trace).not.toBeNull(); + expect(typeof trace?.recordStage).toBe("function"); expect(trace?.filePath).toBe(resolveUserPath("~/.openclaw/logs/cache-trace.jsonl")); trace?.recordStage("session:loaded", { diff --git a/src/agents/tools/music-generate-tool.status.test.ts b/src/agents/tools/music-generate-tool.status.test.ts index 106bcf5933f..1256b3b5322 100644 --- a/src/agents/tools/music-generate-tool.status.test.ts +++ b/src/agents/tools/music-generate-tool.status.test.ts @@ -49,7 +49,7 @@ describe("createMusicGenerateTool status actions", () => { const result = createMusicGenerateDuplicateGuardResult("agent:main:discord:direct:123"); const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? ""; - expect(result).not.toBeNull(); + expect(result?.content).toHaveLength(1); expect(text).toContain("Music generation task task-active is already running with google."); expect(text).toContain("Do not call music_generate again for this request."); expect(result?.details).toMatchObject({ diff --git a/src/agents/tools/video-generate-tool.status.test.ts b/src/agents/tools/video-generate-tool.status.test.ts index e9d60a736e9..42f0210eb08 100644 --- a/src/agents/tools/video-generate-tool.status.test.ts +++ b/src/agents/tools/video-generate-tool.status.test.ts @@ -49,7 +49,7 @@ describe("createVideoGenerateTool status actions", () => { const result = createVideoGenerateDuplicateGuardResult("agent:main:discord:direct:123"); const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? ""; - expect(result).not.toBeNull(); + expect(result?.content).toHaveLength(1); expect(text).toContain("Video generation task task-active is already running with openai."); expect(text).toContain("Do not call video_generate again for this request."); expect(result?.details).toMatchObject({ From b4a717829d1a37a57d69606b1a1e78f4f07ff41b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:29:10 +0100 Subject: [PATCH 269/806] test: tighten gateway nullable assertions --- src/gateway/probe.auth.integration.test.ts | 6 +++--- src/gateway/probe.test.ts | 4 ++-- src/gateway/server.device-pair-approve-authz.test.ts | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts index ba9c4237717..0981a35ed4f 100644 --- a/src/gateway/probe.auth.integration.test.ts +++ b/src/gateway/probe.auth.integration.test.ts @@ -118,9 +118,9 @@ describe("probeGateway auth integration", () => { expect(result.ok).toBe(true); expect(result.error).toBeNull(); - expect(result.health).not.toBeNull(); - expect(result.status).not.toBeNull(); - expect(result.configSnapshot).not.toBeNull(); + expectRecord(result.health, "probe health"); + expectRecord(result.status, "probe status"); + expectRecord(result.configSnapshot, "probe config snapshot"); }); }); }); diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index a451358562e..2eb1ddd74c7 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -212,7 +212,7 @@ describe("probeGateway", () => { expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(1_000); - expect(gatewayClientState.options).not.toBeNull(); + expect(gatewayClientState.options?.url).toBe("ws://127.0.0.1:18789"); expect(gatewayClientState.startCalls).toBe(1); }); @@ -243,7 +243,7 @@ describe("probeGateway", () => { }); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(250); - expect(gatewayClientState.options).not.toBeNull(); + expect(gatewayClientState.options?.url).toBe("ws://127.0.0.1:18789"); expect(gatewayClientState.startCalls).toBe(0); }); diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index a4560310917..48ecc2bd5f8 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -79,7 +79,6 @@ describe("gateway device.pair.approve caller scope guard", () => { expect(approve.error?.message).toBe("missing scope: operator.admin"); const paired = await getPairedDevice(approverIdentity.identity.deviceId); - expect(paired).not.toBeNull(); expect(paired?.approvedScopes).toEqual(["operator.admin"]); } finally { pairingWs?.close(); @@ -138,7 +137,7 @@ describe("gateway device.pair.approve caller scope guard", () => { expect(reject.error?.message).toBe("device pairing rejection denied"); const stillPending = await getPendingDevicePairing(request.request.requestId); - expect(stillPending).not.toBeNull(); + expect(stillPending?.requestId).toBe(request.request.requestId); } finally { pairingWs?.close(); started.ws.close(); From e402efe818bc759709cabfbeae5615e3aca90c81 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:31:27 +0100 Subject: [PATCH 270/806] test: tighten media tool factory assertions --- src/agents/tools/image-generate-tool.test.ts | 2 +- src/agents/tools/music-generate-tool.test.ts | 4 ++-- src/agents/tools/pdf-tool.test.ts | 2 +- src/agents/tools/video-generate-tool.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 4b235f086fd..f028a06c23c 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -113,7 +113,7 @@ function stubImageGenerationProviders() { } function requireImageGenerateTool(tool: ReturnType) { - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected image_generate tool"); } diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index 569601cdb89..80a839e079e 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -207,7 +207,7 @@ describe("createMusicGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected music_generate tool"); } @@ -277,7 +277,7 @@ describe("createMusicGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected music_generate tool"); } diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index ae4306a616d..f73c2446116 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -49,7 +49,7 @@ function requirePdfTool( ? R : never, ) { - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected pdf tool"); } diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index f4894e3a091..9f0f1059f7f 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -309,7 +309,7 @@ describe("createVideoGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected video_generate tool"); } @@ -589,7 +589,7 @@ describe("createVideoGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected video_generate tool"); } From 23a9bf8333fc937a3bfac5afa8f654e52090c311 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:33:36 +0100 Subject: [PATCH 271/806] test: tighten image tool factory assertions --- src/agents/tools/image-tool.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 93f5db0a22a..1453351fb87 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -533,7 +533,7 @@ async function expectImageToolExecOk( } function requireImageTool(tool: T | null | undefined): T { - expect(tool).not.toBeNull(); + expect(typeof (tool as { execute?: unknown } | null | undefined)?.execute).toBe("function"); if (!tool) { throw new Error("expected image tool"); } @@ -654,7 +654,7 @@ describe("image tool implicit imageModel config", () => { deferAutoModelResolution: true, }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); expect(resolveDefaultMediaModelSpy).not.toHaveBeenCalled(); expect(resolveAutoMediaKeyProvidersSpy).not.toHaveBeenCalled(); }); @@ -673,7 +673,7 @@ describe("image tool implicit imageModel config", () => { ...createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), fallbacks: ["openai/gpt-5.4-mini", "anthropic/claude-opus-4-6"], }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -700,7 +700,7 @@ describe("image tool implicit imageModel config", () => { ...createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), fallbacks: ["openai/gpt-5.4-mini", "anthropic/claude-opus-4-6"], }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -801,7 +801,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax-portal/MiniMax-VL-01"), ); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -814,7 +814,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "opencode/gpt-5-nano", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -827,7 +827,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "opencode-go/kimi-k2.6", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -842,7 +842,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("zai/glm-4.6v"), ); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -871,7 +871,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "acme/vision-1", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -933,7 +933,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "amazon-bedrock/vision-1", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -1098,7 +1098,7 @@ describe("image tool implicit imageModel config", () => { primary: "openai/gpt-5.4-mini", }); const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); expect(tool?.description).toContain( "Only use this tool when images were NOT already provided", ); From d7d83eb8671d69a6fe611ef37d3e32eaa1a38f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:36:43 +0100 Subject: [PATCH 272/806] test: dedupe cli backend resolution assertions --- src/agents/cli-backends.test.ts | 75 +++++++++++++++------------------ 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 537e635c49b..75f74541e03 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -93,6 +93,14 @@ function createRuntimeBackendEntry(params: Parameters } satisfies RuntimeBackendEntry; } +function requireCliBackendConfig(...args: Parameters) { + const resolved = resolveCliBackendConfig(...args); + if (!resolved) { + throw new Error(`expected CLI backend config for ${args[0]}`); + } + return resolved; +} + function createClaudeCliOverrideConfig(config: CliBackendConfig): OpenClawConfig { return { agents: { @@ -377,10 +385,9 @@ beforeEach(() => { describe("resolveCliBackendConfig reliability merge", () => { it("defaults codex-cli fresh sandboxing and config-pinned resume sandboxing", () => { - const resolved = resolveCliBackendConfig("codex-cli"); + const resolved = requireCliBackendConfig("codex-cli"); - expect(resolved).not.toBeNull(); - expect(resolved?.config.args).toEqual([ + expect(resolved.config.args).toEqual([ "exec", "--json", "--color", @@ -391,7 +398,7 @@ describe("resolveCliBackendConfig reliability merge", () => { 'service_tier="priority"', "--skip-git-repo-check", ]); - expect(resolved?.config.resumeArgs).toEqual([ + expect(resolved.config.resumeArgs).toEqual([ "exec", "resume", "{sessionId}", @@ -423,15 +430,14 @@ describe("resolveCliBackendConfig reliability merge", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("codex-cli", cfg); + const resolved = requireCliBackendConfig("codex-cli", cfg); - expect(resolved).not.toBeNull(); - expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000); + expect(resolved.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000); // Ensure defaults are retained when only one field is overridden. - expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3); - expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000); - expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); - expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); + expect(resolved.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3); + expect(resolved.config.reliability?.watchdog?.resume?.minMs).toBe(60_000); + expect(resolved.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); + expect(resolved.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); }); it("deep-merges reliability output-limit overrides", () => { @@ -467,9 +473,8 @@ describe("resolveCliBackendConfig reliability merge", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("test-cli", cfg); + const resolved = requireCliBackendConfig("test-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.reliability?.outputLimits).toEqual({ maxTurnRawChars: 16_384, maxTurnLines: 20_000, @@ -511,9 +516,8 @@ describe("resolveCliBackendLiveTest", () => { describe("resolveCliBackendConfig claude-cli defaults", () => { it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => { - const resolved = resolveCliBackendConfig("claude-cli"); + const resolved = requireCliBackendConfig("claude-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.output).toBe("jsonl"); @@ -543,11 +547,10 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }); it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => { - const resolved = resolveCliBackendConfig("claude-cli", { + const resolved = requireCliBackendConfig("claude-cli", { tools: { exec: { security: "allowlist", ask: "on-miss" } }, }); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--permission-mode"); expect(resolved?.config.args).not.toContain("bypassPermissions"); expect(resolved?.config.resumeArgs).not.toContain("--permission-mode"); @@ -631,9 +634,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.command).toBe("/usr/local/bin/claude"); expect(resolved?.config.args).toContain("--setting-sources"); expect(resolved?.config.args).toContain("user"); @@ -679,9 +681,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); expect(resolved?.config.args).not.toContain("--permission-mode"); expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); @@ -709,9 +710,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); expect(resolved?.config.args).toEqual([ "-p", @@ -754,9 +754,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual([ "-p", "--setting-sources", @@ -783,9 +782,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); }); @@ -800,9 +798,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); }); @@ -830,9 +827,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toContain("--setting-sources"); expect(resolved?.config.args).toContain("user"); expect(resolved?.config.args).not.toContain("--permission-mode"); @@ -859,9 +855,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.env).toEqual({ SAFE_CUSTOM: "ok", ANTHROPIC_BASE_URL: "https://evil.example.com/v1", @@ -894,9 +889,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.args).toEqual([ @@ -930,9 +924,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { describe("resolveCliBackendConfig google-gemini-cli defaults", () => { it("uses Gemini CLI json args and existing-session resume mode", () => { - const resolved = resolveCliBackendConfig("google-gemini-cli"); + const resolved = requireCliBackendConfig("google-gemini-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("gemini-system-settings"); expect(resolved?.config.args).toEqual([ @@ -958,9 +951,8 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { }); it("uses Codex CLI bundle MCP config overrides", () => { - const resolved = resolveCliBackendConfig("codex-cli"); + const resolved = requireCliBackendConfig("codex-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("codex-config-overrides"); expect(resolved?.defaultAuthProfileId).toBeUndefined(); @@ -990,7 +982,7 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { }), ]; - const resolved = resolveCliBackendConfig("claude-cli"); + const resolved = requireCliBackendConfig("claude-cli"); expect(resolved?.resolveExecutionArgs).toBe(resolveExecutionArgs); }); @@ -1026,9 +1018,8 @@ describe("resolveCliBackendConfig alias precedence", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("kimi", cfg); + const resolved = requireCliBackendConfig("kimi", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.command).toBe("kimi-canonical"); expect(resolved?.config.args).toEqual(["--canonical"]); }); From bf0cbfead7871dddb9d4def12e4fcf3739341f46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:38:31 +0100 Subject: [PATCH 273/806] test: dedupe gateway hooks assertions --- src/gateway/hooks.test.ts | 44 +++++++-------------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index eea103203b0..3031b068ed8 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -41,10 +41,10 @@ const createIMessageAliasPlugin = () => ({ describe("gateway hooks helpers", () => { const resolveHooksConfigOrThrow = (cfg: OpenClawConfig) => { const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); if (!resolved) { throw new Error("hooks config missing"); } + expect(resolved.token).toBe(cfg.hooks?.token); return resolved; }; @@ -188,11 +188,7 @@ describe("gateway hooks helpers", () => { list: [{ id: "main", default: true }, { id: "hooks" }], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); expect(resolveHookTargetAgentId(resolved, "hooks")).toBe("hooks"); expect(resolveHookTargetAgentId(resolved, "missing-agent")).toBe("main"); expect(resolveHookTargetAgentId(resolved, undefined)).toBeUndefined(); @@ -223,11 +219,7 @@ describe("gateway hooks helpers", () => { const cfg = { hooks: { enabled: true, token: "secret" }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const denied = resolveHookSessionKey({ hooksConfig: resolved, source: "request", @@ -240,11 +232,7 @@ describe("gateway hooks helpers", () => { const cfg = { hooks: { enabled: true, token: "secret", allowRequestSessionKey: true }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const allowed = resolveHookSessionKey({ hooksConfig: resolved, source: "request", @@ -262,11 +250,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const blocked = resolveHookSessionKey({ hooksConfig: resolved, @@ -291,11 +275,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const denied = resolveHookSessionKey({ hooksConfig: resolved, @@ -313,11 +293,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const allowed = resolveHookSessionKey({ hooksConfig: resolved, @@ -335,11 +311,7 @@ describe("gateway hooks helpers", () => { defaultSessionKey: "hook:ingress", }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const resolvedKey = resolveHookSessionKey({ hooksConfig: resolved, From 8a0a56556dd687ca1edc417961c5dae17f883867 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:39:57 +0100 Subject: [PATCH 274/806] test: tighten memory session file assertions --- .../src/host/session-files.test.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index dc768a6d0f4..702e9fe8fdf 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -111,20 +111,18 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(filePath, jsonlLines.join("\n")); const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - // The content should have 3 lines (3 message records) - const contentLines = entry!.content.split("\n"); + const contentLines = entry?.content.split("\n"); expect(contentLines).toHaveLength(3); - expect(contentLines[0]).toContain("User: Hello world"); - expect(contentLines[1]).toContain("Assistant: Hi there"); - expect(contentLines[2]).toContain("User: Tell me a joke"); + expect(contentLines?.[0]).toContain("User: Hello world"); + expect(contentLines?.[1]).toContain("Assistant: Hi there"); + expect(contentLines?.[2]).toContain("User: Tell me a joke"); // lineMap should map each content line to its original JSONL line (1-indexed) // Content line 0 → JSONL line 4 (the first user message) // Content line 1 → JSONL line 6 (the assistant message) // Content line 2 → JSONL line 7 (the second user message) - expect(entry!.lineMap).toEqual([4, 6, 7]); + expect(entry?.lineMap).toEqual([4, 6, 7]); }); it("returns empty lineMap when no messages are found", async () => { @@ -136,9 +134,8 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(filePath, jsonlLines.join("\n")); const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe(""); - expect(entry!.lineMap).toEqual([]); + expect(entry?.content).toBe(""); + expect(entry?.lineMap).toEqual([]); }); it("indexes usage-counted reset/deleted archives but still skips bak and checkpoint artifacts", async () => { @@ -172,10 +169,8 @@ describe("buildSessionEntry", () => { // .bak and compaction checkpoints remain opaque pre-archive / snapshot // artifacts and stay empty so they do not get double-indexed. - expect(bakEntry).not.toBeNull(); expect(bakEntry?.content).toBe(""); expect(bakEntry?.lineMap).toEqual([]); - expect(checkpointEntry).not.toBeNull(); expect(checkpointEntry?.content).toBe(""); expect(checkpointEntry?.lineMap).toEqual([]); }); @@ -199,7 +194,6 @@ describe("buildSessionEntry", () => { const entry = await buildSessionEntry(archivePath); - expect(entry).not.toBeNull(); expect(entry?.content).toBe(""); expect(entry?.lineMap).toEqual([]); expect(entry?.generatedByCronRun).toBe(true); @@ -221,7 +215,6 @@ describe("buildSessionEntry", () => { const entry = await buildSessionEntry(archivePath); - expect(entry).not.toBeNull(); expect(entry?.content).toBe(""); expect(entry?.lineMap).toEqual([]); expect(entry?.generatedByCronRun).toBe(true); @@ -239,8 +232,7 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(filePath, jsonlLines.join("\n")); const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.lineMap).toEqual([3, 5]); + expect(entry?.lineMap).toEqual([3, 5]); }); it("strips inbound metadata when a user envelope is split across text blocks", async () => { @@ -269,8 +261,7 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(filePath, jsonlLines.join("\n")); const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe("User: Actual user text"); + expect(entry?.content).toBe("User: Actual user text"); }); it("skips inter-session user messages", async () => { @@ -296,8 +287,7 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(filePath, jsonlLines.join("\n")); const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); - expect(entry!.lineMap).toEqual([2, 3]); + expect(entry?.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); + expect(entry?.lineMap).toEqual([2, 3]); }); }); From 3e7f2da32d6fe1b40643c52317752eb26dc64732 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:40:51 +0100 Subject: [PATCH 275/806] test: tighten gateway lifecycle assertions --- .../server-methods/server-methods.test.ts | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 1991c99bfca..c49dede5266 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -74,10 +74,11 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await snapshotPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(100); - expect(snapshot?.endedAt).toBe(200); + expect(snapshot).toMatchObject({ + status: "timeout", + startedAt: 100, + endedAt: 200, + }); } finally { vi.useRealTimers(); } @@ -89,10 +90,11 @@ describe("waitForAgentJob", () => { startedAt: 300, endedAt: 400, }); - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("ok"); - expect(snapshot?.startedAt).toBe(300); - expect(snapshot?.endedAt).toBe(400); + expect(snapshot).toMatchObject({ + status: "ok", + startedAt: 300, + endedAt: 400, + }); }); it("ignores transient aborted end events when the same run later succeeds", async () => { @@ -119,10 +121,11 @@ describe("waitForAgentJob", () => { }); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("ok"); - expect(snapshot?.startedAt).toBe(500); - expect(snapshot?.endedAt).toBe(700); + expect(snapshot).toMatchObject({ + status: "ok", + startedAt: 500, + endedAt: 700, + }); }); it("lets a later aborted timeout replace a pending lifecycle error", async () => { @@ -149,10 +152,11 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(800); - expect(snapshot?.endedAt).toBe(1_000); + expect(snapshot).toMatchObject({ + status: "timeout", + startedAt: 800, + endedAt: 1_000, + }); expect(snapshot?.error).toBeUndefined(); } finally { vi.useRealTimers(); @@ -183,11 +187,12 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("error"); - expect(snapshot?.startedAt).toBe(1_100); - expect(snapshot?.endedAt).toBe(1_300); - expect(snapshot?.error).toBe("final error"); + expect(snapshot).toMatchObject({ + status: "error", + startedAt: 1_100, + endedAt: 1_300, + error: "final error", + }); } finally { vi.useRealTimers(); } From df913465f81fdee34019b6556c123f53bd0aca81 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:41:36 +0100 Subject: [PATCH 276/806] test: tighten provider env assertions --- extensions/deepinfra/onboard.test.ts | 7 ++++--- extensions/kilocode/onboard.test.ts | 7 ++++--- extensions/qianfan/index.test.ts | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/extensions/deepinfra/onboard.test.ts b/extensions/deepinfra/onboard.test.ts index 8eea909031d..46ebe2a586c 100644 --- a/extensions/deepinfra/onboard.test.ts +++ b/extensions/deepinfra/onboard.test.ts @@ -115,9 +115,10 @@ describe("DeepInfra provider config", () => { try { const result = resolveEnvApiKey("deepinfra"); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("test-deepinfra-key"); - expect(result?.source).toContain("DEEPINFRA_API_KEY"); + expect(result).toMatchObject({ + apiKey: "test-deepinfra-key", + source: expect.stringContaining("DEEPINFRA_API_KEY"), + }); } finally { envSnapshot.restore(); } diff --git a/extensions/kilocode/onboard.test.ts b/extensions/kilocode/onboard.test.ts index 311af08862c..cd24bb1126f 100644 --- a/extensions/kilocode/onboard.test.ts +++ b/extensions/kilocode/onboard.test.ts @@ -150,9 +150,10 @@ describe("Kilo Gateway provider config", () => { try { const result = resolveEnvApiKey("kilocode"); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("test-kilo-key"); - expect(result?.source).toContain("KILOCODE_API_KEY"); + expect(result).toMatchObject({ + apiKey: "test-kilo-key", + source: expect.stringContaining("KILOCODE_API_KEY"), + }); } finally { vi.unstubAllEnvs(); } diff --git a/extensions/qianfan/index.test.ts b/extensions/qianfan/index.test.ts index a25d323efe6..abe70506997 100644 --- a/extensions/qianfan/index.test.ts +++ b/extensions/qianfan/index.test.ts @@ -25,9 +25,10 @@ describe("qianfan provider plugin", () => { expect(provider.docsPath).toBe("/providers/qianfan"); expect(provider.envVars).toEqual(["QIANFAN_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved).not.toBeNull(); - expect(resolved?.provider.id).toBe("qianfan"); - expect(resolved?.method.id).toBe("api-key"); + expect(resolved).toMatchObject({ + provider: { id: "qianfan" }, + method: { id: "api-key" }, + }); }); it("builds the static Qianfan model catalog", async () => { From bb9beba7cf3f1048f3a72744bde071ac92ad5ba7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:42:18 +0100 Subject: [PATCH 277/806] test: tighten storage session map assertions --- ui/src/ui/storage.node.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 31c8a4a48b3..f4d8c970318 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -572,9 +572,14 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); const sessionsByGateway = persisted.sessionsByGateway as unknown; - expect(typeof sessionsByGateway).toBe("object"); - expect(sessionsByGateway).not.toBeNull(); - expect(Array.isArray(sessionsByGateway)).toBe(false); + expect(sessionsByGateway).toEqual( + expect.objectContaining({ + "wss://gateway.example:8443": { + sessionKey: "agent:current:main", + lastActiveSessionKey: "agent:current:main", + }, + }), + ); const scopedSessions = sessionsByGateway as Record; const scopes = Object.keys(scopedSessions); expect(scopes).toHaveLength(10); @@ -584,10 +589,6 @@ describe("loadSettings default gateway URL derivation", () => { // newest stale entries and the current gateway should be retained expect(scopes).toContain("wss://stale-10.example:8443"); expect(scopes).toContain("wss://gateway.example:8443"); - expect(scopedSessions["wss://gateway.example:8443"]).toEqual({ - sessionKey: "agent:current:main", - lastActiveSessionKey: "agent:current:main", - }); }); it("persists local user identity separately from gateway settings", () => { From 0cf28560fa4505c1178dfc865f0e72725d21e481 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:42:45 +0100 Subject: [PATCH 278/806] test: tighten compaction checkpoint assertions --- .../session-compaction-checkpoints.test.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 729062f5545..f865aa711c7 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -62,21 +62,23 @@ describe("session-compaction-checkpoints", () => { expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(sessionManagerOpenSpy).not.toHaveBeenCalled(); - expect(snapshot).not.toBeNull(); - expect(snapshot?.leafId).toBe(leafId); - expect(snapshot?.sessionFile).not.toBe(sessionFile); - expect(snapshot?.sessionFile).toContain(".checkpoint."); - expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(true); - expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + expect(snapshot).toMatchObject({ leafId }); + if (!snapshot) { + throw new Error("expected checkpoint snapshot"); + } + expect(snapshot.sessionFile).not.toBe(sessionFile); + expect(snapshot.sessionFile).toContain(".checkpoint."); + expect(fsSync.existsSync(snapshot.sessionFile)).toBe(true); + expect(await fs.readFile(snapshot.sessionFile, "utf-8")).toBe(originalBefore); session.appendCompaction("checkpoint summary", leafId, 123, { ok: true }); - expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + expect(await fs.readFile(snapshot.sessionFile, "utf-8")).toBe(originalBefore); expect(await fs.readFile(sessionFile, "utf-8")).not.toBe(originalBefore); await cleanupCompactionCheckpointSnapshot(snapshot); - expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false); + expect(fsSync.existsSync(snapshot.sessionFile)).toBe(false); expect(fsSync.existsSync(sessionFile)).toBe(true); } finally { copyFileSyncSpy.mockRestore(); @@ -119,11 +121,12 @@ describe("session-compaction-checkpoints", () => { expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(sessionManagerOpenSpy).not.toHaveBeenCalled(); - expect(snapshot).not.toBeNull(); - expect(snapshot?.sessionId).toBe(sessionId); - expect(snapshot?.leafId).toBe(leafId); - expect(snapshot?.sessionFile).not.toBe(sessionFile); - expect(snapshot?.sessionFile).toContain(".checkpoint."); + expect(snapshot).toMatchObject({ sessionId, leafId }); + if (!snapshot) { + throw new Error("expected checkpoint snapshot"); + } + expect(snapshot.sessionFile).not.toBe(sessionFile); + expect(snapshot.sessionFile).toContain(".checkpoint."); } finally { await cleanupCompactionCheckpointSnapshot(snapshot); copyFileSyncSpy.mockRestore(); @@ -194,16 +197,19 @@ describe("session-compaction-checkpoints", () => { expect(openSpy).not.toHaveBeenCalled(); expect(forkSpy).not.toHaveBeenCalled(); - expect(forked).not.toBeNull(); - expect(forked?.sessionFile).not.toBe(sessionFile); - expect(forked?.sessionId).toBeTypeOf("string"); - expect(forked?.sessionId).not.toBe(""); + expect(forked).toMatchObject({ sessionFile: expect.any(String) }); + if (!forked) { + throw new Error("expected forked checkpoint transcript"); + } + expect(forked.sessionFile).not.toBe(sessionFile); + expect(forked.sessionId).toBeTypeOf("string"); + expect(forked.sessionId).not.toBe(""); } finally { openSpy.mockRestore(); forkSpy.mockRestore(); } - const forkedLines = (await fs.readFile(forked!.sessionFile, "utf-8")).trim().split(/\r?\n/); + const forkedLines = (await fs.readFile(forked.sessionFile, "utf-8")).trim().split(/\r?\n/); const forkedEntries = forkedLines.map((line) => JSON.parse(line) as Record); const sourceEntries = (await fs.readFile(sessionFile, "utf-8")) .trim() @@ -218,7 +224,7 @@ describe("session-compaction-checkpoints", () => { expect(forkedEntries[0]).toMatchObject({ type: "session", - id: forked!.sessionId, + id: forked.sessionId, cwd: dir, parentSession: sessionFile, }); @@ -274,15 +280,18 @@ describe("session-compaction-checkpoints", () => { sessionDir: dir, }); - expect(forked).not.toBeNull(); - const forkedEntries = (await fs.readFile(forked!.sessionFile, "utf-8")) + expect(forked).toMatchObject({ sessionFile: expect.any(String) }); + if (!forked) { + throw new Error("expected forked checkpoint transcript"); + } + const forkedEntries = (await fs.readFile(forked.sessionFile, "utf-8")) .trim() .split(/\r?\n/) .map((line) => JSON.parse(line) as Record); expect(forkedEntries[0]).toMatchObject({ type: "session", version: CURRENT_SESSION_VERSION, - id: forked!.sessionId, + id: forked.sessionId, parentSession: legacySessionFile, }); expect(forkedEntries[1]).toMatchObject({ @@ -300,7 +309,7 @@ describe("session-compaction-checkpoints", () => { expect(forkedEntries[2]?.id).toBeTypeOf("string"); expect(forkedEntries[2]?.id).not.toBe(""); - const messages = SessionManager.open(forked!.sessionFile, dir).buildSessionContext().messages; + const messages = SessionManager.open(forked.sessionFile, dir).buildSessionContext().messages; expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ "legacy first", "legacy second", @@ -371,7 +380,11 @@ describe("session-compaction-checkpoints", () => { createdAt: now + 100, }); - expect(stored).not.toBeNull(); + expect(stored?.preCompaction).toMatchObject({ + sessionId, + sessionFile: currentSnapshotFile, + leafId: "current-leaf", + }); expect(fsSync.existsSync(existingCheckpoints[0].preCompaction.sessionFile)).toBe(false); expect(fsSync.existsSync(existingCheckpoints[1].preCompaction.sessionFile)).toBe(false); expect(fsSync.existsSync(existingCheckpoints[2].preCompaction.sessionFile)).toBe(true); From 29e27d2d9cb3d059a6b24cf66e6896851bf66a4b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:42:59 +0100 Subject: [PATCH 279/806] test: tighten ui element assertions --- ui/src/ui/chat/chat-avatar.test.ts | 1 - ui/src/ui/views/login-gate.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts index 5efa18d7ebf..541f88500fb 100644 --- a/ui/src/ui/chat/chat-avatar.test.ts +++ b/ui/src/ui/chat/chat-avatar.test.ts @@ -41,7 +41,6 @@ function renderAvatar(params: Parameters) { describe("renderChatAvatar", () => { it("renders assistant fallback, blob image, and text avatars", () => { const defaultAvatar = renderAvatar(["assistant"]); - expect(defaultAvatar).not.toBeNull(); expect(defaultAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); const remoteAvatar = renderAvatar([ diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 14a472fbdfb..219f2dcf5ee 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -192,7 +192,6 @@ describe("renderLoginGate", () => { await Promise.resolve(); const alert = container.querySelector('[role="alert"]'); - expect(alert).not.toBeNull(); expect(alert?.dataset.kind).toBe("protocol-mismatch"); expect(alert?.textContent).toContain("Protocol mismatch"); expect(alert?.textContent).toContain("openclaw dashboard"); From 72209f7758454b61fe164f6e038a273a3686fbc3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:44:08 +0100 Subject: [PATCH 280/806] test: tighten component fixture assertions --- ui/src/ui/components/modal-dialog.test.ts | 15 +++++++++------ ui/src/ui/components/resizable-divider.test.ts | 13 ++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/ui/components/modal-dialog.test.ts index db88558ac86..1e3f52e93db 100644 --- a/ui/src/ui/components/modal-dialog.test.ts +++ b/ui/src/ui/components/modal-dialog.test.ts @@ -112,21 +112,24 @@ describe("openclaw-modal-dialog", () => { const { dialog } = await renderModal(); const first = container.querySelector("#first-action"); const last = container.querySelector("#last-action"); - expect(first).not.toBeNull(); - expect(last).not.toBeNull(); + expect(first?.id).toBe("first-action"); + expect(last?.id).toBe("last-action"); + if (!first || !last) { + throw new Error("expected modal focus trap actions"); + } - last!.focus(); + last.focus(); const tab = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, composed: true, }); - last!.dispatchEvent(tab); + last.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(first); - first!.focus(); + first.focus(); const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, @@ -134,7 +137,7 @@ describe("openclaw-modal-dialog", () => { cancelable: true, composed: true, }); - first!.dispatchEvent(shiftTab); + first.dispatchEvent(shiftTab); expect(shiftTab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(last); expect(dialog.open).toBe(true); diff --git a/ui/src/ui/components/resizable-divider.test.ts b/ui/src/ui/components/resizable-divider.test.ts index 6de8f7ddc23..71e2237398a 100644 --- a/ui/src/ui/components/resizable-divider.test.ts +++ b/ui/src/ui/components/resizable-divider.test.ts @@ -44,10 +44,13 @@ async function renderDivider() { const root = container.querySelector("#split-root"); const divider = container.querySelector("resizable-divider"); - expect(root).not.toBeNull(); - expect(divider).not.toBeNull(); + expect(root?.id).toBe("split-root"); + expect(divider?.tagName.toLowerCase()).toBe("resizable-divider"); + if (!root || !divider) { + throw new Error("expected resizable divider fixture"); + } - root!.getBoundingClientRect = vi.fn(() => ({ + root.getBoundingClientRect = vi.fn(() => ({ bottom: 0, height: 0, left: 0, @@ -59,9 +62,9 @@ async function renderDivider() { toJSON: () => ({}), })); - await divider!.updateComplete; + await divider.updateComplete; await nextFrame(); - return divider!; + return divider; } function dispatchPointer(target: EventTarget, type: string, clientX: number) { From 4763c07be690bfc5a1489f82466a8a53adaa3bb2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:45:10 +0100 Subject: [PATCH 281/806] test: tighten run controls assertions --- ui/src/ui/chat/run-controls.test.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 539b900c457..9357bf73578 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -59,10 +59,9 @@ describe("chat run controls", () => { const queueButton = container.querySelector('button[title="Queue"]'); const stopButton = container.querySelector('button[title="Stop"]'); - expect(queueButton).not.toBeNull(); expect(queueButton?.disabled).toBe(true); - expect(stopButton).not.toBeNull(); - stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stopButton?.title).toBe("Stop"); + stopButton?.click(); expect(onAbort).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("New session"); @@ -85,13 +84,13 @@ describe("chat run controls", () => { const newSessionButton = container.querySelector( 'button[title="New session"]', ); - expect(newSessionButton).not.toBeNull(); - newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(newSessionButton?.title).toBe("New session"); + newSessionButton?.click(); expect(onNewSession).toHaveBeenCalledTimes(1); const sendButton = container.querySelector('button[title="Send"]'); - expect(sendButton).not.toBeNull(); - sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(sendButton?.title).toBe("Send"); + sendButton?.click(); expect(onStoreDraft).toHaveBeenCalledWith(" run this "); expect(onSend).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("Stop"); @@ -114,9 +113,8 @@ describe("chat run controls", () => { ); const queueButton = container.querySelector('button[title="Queue"]'); - expect(queueButton).not.toBeNull(); expect(queueButton?.disabled).toBe(false); - queueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + queueButton?.click(); expect(onStoreDraft).toHaveBeenCalledWith(" follow up "); expect(onSend).toHaveBeenCalledTimes(1); }); @@ -176,10 +174,8 @@ describe("chat status indicators", () => { ); let indicator = container.querySelector(".compaction-indicator--active"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Compacting context..."); indicator = container.querySelector(".compaction-indicator--fallback"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5"); renderIndicators( @@ -199,10 +195,8 @@ describe("chat status indicators", () => { }, ); indicator = container.querySelector(".compaction-indicator--complete"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Context compacted"); indicator = container.querySelector(".compaction-indicator--fallback-cleared"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5"); nowSpy.mockReturnValue(20_000); @@ -260,8 +254,8 @@ describe("context notice", () => { render(renderContextNotice(lowUsageSession, 200_000), container); expect(container.textContent).toContain("23% context used"); expect(container.textContent).toContain("46k / 200k"); - expect(container.querySelector(".context-notice--usage")).not.toBeNull(); - expect(container.querySelector(".context-notice__meter")).not.toBeNull(); + expect(container.querySelectorAll(".context-notice--usage")).toHaveLength(1); + expect(container.querySelectorAll(".context-notice__meter")).toHaveLength(1); expect(container.querySelector(".context-notice__icon")).toBeNull(); expect(container.textContent).not.toContain("757.3k / 200k"); @@ -280,7 +274,6 @@ describe("context notice", () => { expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true); expect(container.textContent).not.toContain("757.3k / 200k"); const notice = container.querySelector(".context-notice"); - expect(notice).not.toBeNull(); expect(notice?.classList.contains("context-notice--warning")).toBe(true); expect(notice?.getAttribute("title")).toBe("Session context usage: 190k / 200k (95%)"); expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); @@ -289,7 +282,6 @@ describe("context notice", () => { expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); const icon = container.querySelector(".context-notice__icon"); - expect(icon).not.toBeNull(); expect(icon?.tagName.toLowerCase()).toBe("svg"); expect(icon?.classList.contains("context-notice__icon")).toBe(true); expect(icon?.getAttribute("width")).toBe("16"); @@ -351,7 +343,6 @@ describe("side result render", () => { container, ); - expect(container.querySelector(".chat-side-result")).not.toBeNull(); expect(container.textContent).toContain("BTW"); expect(container.textContent).toContain("what changed?"); expect(container.textContent).toContain("Not saved to chat history"); From 10d445c911a13c586006c6f44891a7feb4283166 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:46:22 +0100 Subject: [PATCH 282/806] test: tighten cron view assertions --- ui/src/ui/views/cron.test.ts | 43 ++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 2056c5aa727..6cbb3add085 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -218,7 +218,6 @@ describe("cron view", () => { expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); const link = container.querySelector("a.session-link"); - expect(link).not.toBeNull(); expect(link?.getAttribute("href")).toContain( "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", ); @@ -290,7 +289,6 @@ describe("cron view", () => { render(renderCron(expandedProps), container); const collapseButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); - expect(collapseButton).not.toBeNull(); expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenCalledWith(true); @@ -306,8 +304,8 @@ describe("cron view", () => { render(renderCron(collapsedProps), container); const collapsedButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); - expect(container.querySelector(".cron-workspace--form-collapsed")).not.toBeNull(); - expect(container.querySelector(".cron-workspace-form--collapsed")).not.toBeNull(); + expect(container.querySelectorAll(".cron-workspace--form-collapsed")).toHaveLength(1); + expect(container.querySelectorAll(".cron-workspace-form--collapsed")).toHaveLength(1); expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".cron-form")?.hasAttribute("hidden")).toBe(true); expect(container.querySelector(".cron-form-actions")?.hasAttribute("hidden")).toBe(true); @@ -515,7 +513,6 @@ describe("cron view", () => { expect(container.textContent).toContain("Best effort delivery"); const staggerGroup = container.querySelector(".cron-stagger-group"); - expect(staggerGroup).not.toBeNull(); expect(staggerGroup?.textContent).toContain("Stagger window"); expect(staggerGroup?.textContent).toContain("Stagger unit"); expect(container.textContent).toContain( @@ -549,7 +546,6 @@ describe("cron view", () => { ); const agentInput = container.querySelector('input[placeholder="main or ops"]'); - expect(agentInput).not.toBeNull(); expect(agentInput instanceof HTMLInputElement).toBe(true); expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true); @@ -715,19 +711,28 @@ describe("cron view", () => { container, ); - expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-delivery-account-suggestions")).not.toBeNull(); - expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull(); + expect(Array.from(container.querySelectorAll("datalist")).map((node) => node.id)).toEqual([ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]); expect( - container.querySelector('input[list="cron-delivery-account-suggestions"]'), - ).not.toBeNull(); + Array.from(container.querySelectorAll("input[list]")).map((node) => + node.getAttribute("list"), + ), + ).toEqual( + expect.arrayContaining([ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]), + ); + expect(container.querySelectorAll("input[list]")).toHaveLength(6); }); }); From 8161dafacff582dac04010ec9e27c0f86728364d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:47:27 +0100 Subject: [PATCH 283/806] test: tighten command palette assertions --- ui/src/ui/views/command-palette.test.ts | 43 +++++++++++-------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts index b010ac5f54e..984bf02b404 100644 --- a/ui/src/ui/views/command-palette.test.ts +++ b/ui/src/ui/views/command-palette.test.ts @@ -141,31 +141,27 @@ describe("command palette", () => { await renderPalette({ query: "overview", activeIndex: 0 }); const dialog = container.querySelector("dialog.cmd-palette-overlay"); - expect(dialog).not.toBeNull(); - expect(dialog!.open).toBe(true); - expect(dialog!.hasAttribute("role")).toBe(false); - expect(dialog!.hasAttribute("aria-modal")).toBe(false); - expect(dialog!.getAttribute("aria-labelledby")).toBe("cmd-palette-label"); + expect(dialog?.open).toBe(true); + expect(dialog?.hasAttribute("role")).toBe(false); + expect(dialog?.hasAttribute("aria-modal")).toBe(false); + expect(dialog?.getAttribute("aria-labelledby")).toBe("cmd-palette-label"); const label = container.querySelector("#cmd-palette-label"); const input = container.querySelector("#cmd-palette-input"); const listbox = container.querySelector("#cmd-palette-listbox"); expect(label?.textContent).toBe("Type a command…"); expect(label?.getAttribute("for")).toBe("cmd-palette-input"); - expect(input).not.toBeNull(); - expect(input!.getAttribute("role")).toBe("combobox"); - expect(input!.getAttribute("aria-autocomplete")).toBe("list"); - expect(input!.getAttribute("aria-expanded")).toBe("true"); - expect(input!.getAttribute("aria-controls")).toBe("cmd-palette-listbox"); - expect(input!.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview"); + expect(input?.getAttribute("role")).toBe("combobox"); + expect(input?.getAttribute("aria-autocomplete")).toBe("list"); + expect(input?.getAttribute("aria-expanded")).toBe("true"); + expect(input?.getAttribute("aria-controls")).toBe("cmd-palette-listbox"); + expect(input?.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview"); expect(document.activeElement).toBe(input); - expect(listbox).not.toBeNull(); - expect(listbox!.getAttribute("role")).toBe("listbox"); - const option = listbox!.querySelector("#cmd-palette-option-nav-overview"); - expect(option).not.toBeNull(); - expect(option!.getAttribute("role")).toBe("option"); - expect(option!.getAttribute("aria-selected")).toBe("true"); + expect(listbox?.getAttribute("role")).toBe("listbox"); + const option = listbox?.querySelector("#cmd-palette-option-nav-overview"); + expect(option?.getAttribute("role")).toBe("option"); + expect(option?.getAttribute("aria-selected")).toBe("true"); }); it("traps Tab on the combobox and restores focus on Escape", async () => { @@ -177,7 +173,6 @@ describe("command palette", () => { await renderPalette({ onToggle }); const input = container.querySelector("#cmd-palette-input"); - expect(input).not.toBeNull(); expect(document.activeElement).toBe(input); const tab = new KeyboardEvent("keydown", { @@ -185,7 +180,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input!.dispatchEvent(tab); + input?.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(input); @@ -194,7 +189,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input!.dispatchEvent(escape); + input?.dispatchEvent(escape); expect(escape.defaultPrevented).toBe(true); expect(onToggle).toHaveBeenCalledTimes(1); @@ -208,17 +203,17 @@ describe("command palette", () => { await renderPalette({ onToggle }); const dialog = container.querySelector("dialog.cmd-palette-overlay"); const input = container.querySelector("#cmd-palette-input"); - expect(dialog).not.toBeNull(); - expect(input).not.toBeNull(); + expect(dialog?.open).toBe(true); + expect(input?.id).toBe("cmd-palette-input"); - input!.dispatchEvent( + input?.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); - dialog!.dispatchEvent(new Event("cancel", { cancelable: true })); + dialog?.dispatchEvent(new Event("cancel", { cancelable: true })); expect(onToggle).toHaveBeenCalledTimes(1); }); From a0459cde8aadbc2358a35f058d534bae4c88237b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:47:31 +0100 Subject: [PATCH 284/806] test: use gemini 3.1 in live switch --- src/agents/google-gemini-switch.live.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/google-gemini-switch.live.test.ts b/src/agents/google-gemini-switch.live.test.ts index 95379aba60a..efc275c4d16 100644 --- a/src/agents/google-gemini-switch.live.test.ts +++ b/src/agents/google-gemini-switch.live.test.ts @@ -10,7 +10,7 @@ const LIVE = isLiveTestEnabled(["GEMINI_LIVE_TEST"]); const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip; describeLive("gemini live switch", () => { - const googleModels = ["gemini-3-pro-preview", "gemini-2.5-pro"] as const; + const googleModels = ["gemini-3.1-pro-preview", "gemini-2.5-pro"] as const; for (const modelId of googleModels) { it(`handles unsigned tool calls from Antigravity when switching to ${modelId}`, async () => { From 58713503569be93001420e05d1e8bbeb74473dea Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:48:27 +0100 Subject: [PATCH 285/806] test: tighten provider choice assertions --- extensions/arcee/index.test.ts | 14 ++++++++------ extensions/deepseek/index.test.ts | 7 ++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/extensions/arcee/index.test.ts b/extensions/arcee/index.test.ts index 234f3aabfc1..c29d6937873 100644 --- a/extensions/arcee/index.test.ts +++ b/extensions/arcee/index.test.ts @@ -20,17 +20,19 @@ describe("arcee provider plugin", () => { providers: [provider], choice: "arceeai-api-key", }); - expect(directChoice).not.toBeNull(); - expect(directChoice?.provider.id).toBe("arcee"); - expect(directChoice?.method.id).toBe("arcee-platform"); + expect(directChoice).toMatchObject({ + provider: { id: "arcee" }, + method: { id: "arcee-platform" }, + }); const orChoice = resolveProviderPluginChoice({ providers: [provider], choice: "arceeai-openrouter", }); - expect(orChoice).not.toBeNull(); - expect(orChoice?.provider.id).toBe("arcee"); - expect(orChoice?.method.id).toBe("openrouter"); + expect(orChoice).toMatchObject({ + provider: { id: "arcee" }, + method: { id: "openrouter" }, + }); }); it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => { diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 85b21538bb5..75f0afa18f1 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -141,9 +141,10 @@ describe("deepseek provider plugin", () => { expect(provider.label).toBe("DeepSeek"); expect(provider.envVars).toEqual(["DEEPSEEK_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved).not.toBeNull(); - expect(resolved?.provider.id).toBe("deepseek"); - expect(resolved?.method.id).toBe("api-key"); + expect(resolved).toMatchObject({ + provider: { id: "deepseek" }, + method: { id: "api-key" }, + }); }); it("builds the static DeepSeek model catalog", async () => { From 2cbc67dbc6d43e41db2bdcaf79ed2ef86c087b49 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:48:28 +0100 Subject: [PATCH 286/806] test: tighten run controls stop assertion --- ui/src/ui/chat/run-controls.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 9357bf73578..9e1c8df57e5 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -134,9 +134,8 @@ describe("chat run controls", () => { ); const stopButton = container.querySelector('button[title="Stop"]'); - expect(stopButton).not.toBeNull(); expect(stopButton?.disabled).toBe(false); - stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + stopButton?.click(); expect(onAbort).toHaveBeenCalledTimes(1); }); }); From b98d860d4d5a4aa924bcc5b181dc004a804c8794 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:49:22 +0100 Subject: [PATCH 287/806] test: tighten oc path assertions --- src/oc-path/tests/find.test.ts | 671 ++++++++++--------- src/oc-path/tests/scenarios/pitfalls.test.ts | 1 - 2 files changed, 353 insertions(+), 319 deletions(-) diff --git a/src/oc-path/tests/find.test.ts b/src/oc-path/tests/find.test.ts index 34ba3345ca8..68bdd879132 100644 --- a/src/oc-path/tests/find.test.ts +++ b/src/oc-path/tests/find.test.ts @@ -7,197 +7,196 @@ * (a `*` in the `item` slot produces concrete paths whose `item` field * carries the matched value). */ -import { describe, expect, it } from 'vitest'; -import { findOcPaths } from '../find.js'; -import { parseJsonc } from '../jsonc/parse.js'; -import { parseJsonl } from '../jsonl/parse.js'; -import { parseMd } from '../parse.js'; -import { parseYaml } from '../yaml/parse.js'; -import { - formatOcPath, - hasWildcard, - OcPathError, - parseOcPath, -} from '../oc-path.js'; -import { - resolveOcPath, - setOcPath, -} from '../universal.js'; +import { describe, expect, it } from "vitest"; +import { findOcPaths } from "../find.js"; +import { parseJsonc } from "../jsonc/parse.js"; +import { parseJsonl } from "../jsonl/parse.js"; +import { formatOcPath, hasWildcard, OcPathError, parseOcPath } from "../oc-path.js"; +import { parseMd } from "../parse.js"; +import { resolveOcPath, setOcPath } from "../universal.js"; +import { parseYaml } from "../yaml/parse.js"; // ---------- hasWildcard ---------------------------------------------------- -describe('hasWildcard', () => { - it('detects single-segment * in any slot', () => { - expect(hasWildcard(parseOcPath('oc://X/*/y'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/*'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/b/*'))).toBe(true); +describe("hasWildcard", () => { + it("detects single-segment * in any slot", () => { + expect(hasWildcard(parseOcPath("oc://X/*/y"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/*"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/b/*"))).toBe(true); }); - it('detects ** in any slot', () => { - expect(hasWildcard(parseOcPath('oc://X/**'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/**/c'))).toBe(true); + it("detects ** in any slot", () => { + expect(hasWildcard(parseOcPath("oc://X/**"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/**/c"))).toBe(true); }); - it('detects wildcards inside dotted sub-segments', () => { - expect(hasWildcard(parseOcPath('oc://X/a.*.c'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a.**.c'))).toBe(true); + it("detects wildcards inside dotted sub-segments", () => { + expect(hasWildcard(parseOcPath("oc://X/a.*.c"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a.**.c"))).toBe(true); }); - it('returns false for plain paths', () => { - expect(hasWildcard(parseOcPath('oc://X/a/b/c'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/a.b.c'))).toBe(false); + it("returns false for plain paths", () => { + expect(hasWildcard(parseOcPath("oc://X/a/b/c"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/a.b.c"))).toBe(false); }); - it('treats `*` inside an identifier as literal', () => { - expect(hasWildcard(parseOcPath('oc://X/foo*bar'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/a*'))).toBe(false); + it("treats `*` inside an identifier as literal", () => { + expect(hasWildcard(parseOcPath("oc://X/foo*bar"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/a*"))).toBe(false); }); }); // ---------- Wildcard guard on resolveOcPath / setOcPath ------------------- -describe('wildcard guard', () => { - const yaml = parseYaml('steps:\n - id: a\n command: foo\n').ast; +describe("wildcard guard", () => { + const yaml = parseYaml("steps:\n - id: a\n command: foo\n").ast; - it('resolveOcPath throws OcPathError for wildcard pattern (F16)', () => { + it("resolveOcPath throws OcPathError for wildcard pattern (F16)", () => { // Previously returned `null` — indistinguishable from "path doesn't // resolve". Now throws with `OC_PATH_WILDCARD_IN_RESOLVE` so the // CLI / consumers can surface "use findOcPaths" rather than "not // found". setOcPath uses a discriminated `wildcard-not-allowed` // reason; this is the resolve-side analogue. - expect(() => - resolveOcPath(yaml, parseOcPath('oc://wf/steps/*/command')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(yaml, parseOcPath("oc://wf/steps/*/command"))).toThrow( + /findOcPaths/, + ); try { - resolveOcPath(yaml, parseOcPath('oc://wf/**')); - expect.fail('should have thrown'); + resolveOcPath(yaml, parseOcPath("oc://wf/**")); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcPathError); - expect((err as OcPathError).code).toBe('OC_PATH_WILDCARD_IN_RESOLVE'); + expect((err as OcPathError).code).toBe("OC_PATH_WILDCARD_IN_RESOLVE"); } }); - it('setOcPath returns wildcard-not-allowed for wildcard pattern', () => { - const r = setOcPath(yaml, parseOcPath('oc://wf/steps/*/command'), 'bar'); + it("setOcPath returns wildcard-not-allowed for wildcard pattern", () => { + const r = setOcPath(yaml, parseOcPath("oc://wf/steps/*/command"), "bar"); expect(r.ok).toBe(false); - if (!r.ok) {expect(r.reason).toBe('wildcard-not-allowed');} + if (!r.ok) { + expect(r.reason).toBe("wildcard-not-allowed"); + } }); - it('setOcPath wildcard guard reason carries actionable detail', () => { - const r = setOcPath(yaml, parseOcPath('oc://wf/**'), 'bar'); + it("setOcPath wildcard guard reason carries actionable detail", () => { + const r = setOcPath(yaml, parseOcPath("oc://wf/**"), "bar"); expect(r.ok).toBe(false); - if (!r.ok) {expect(r.detail).toContain('findOcPaths');} + if (!r.ok) { + expect(r.detail).toContain("findOcPaths"); + } }); }); // ---------- findOcPaths — fast-path (no wildcards) ------------------------- -describe('findOcPaths — non-wildcard fast-path', () => { - it('wraps resolveOcPath result for plain path', () => { - const ast = parseYaml('name: x\n').ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/name')); +describe("findOcPaths — non-wildcard fast-path", () => { + it("wraps resolveOcPath result for plain path", () => { + const ast = parseYaml("name: x\n").ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/name")); expect(out).toHaveLength(1); - expect(out[0].match.kind).toBe('leaf'); - expect(formatOcPath(out[0].path)).toBe('oc://wf/name'); + expect(out[0].match.kind).toBe("leaf"); + expect(formatOcPath(out[0].path)).toBe("oc://wf/name"); }); - it('returns empty for unresolved plain path', () => { - const ast = parseYaml('name: x\n').ast; - expect(findOcPaths(ast, parseOcPath('oc://wf/missing'))).toHaveLength(0); + it("returns empty for unresolved plain path", () => { + const ast = parseYaml("name: x\n").ast; + expect(findOcPaths(ast, parseOcPath("oc://wf/missing"))).toHaveLength(0); }); }); // ---------- findOcPaths — YAML -------------------------------------------- -describe('findOcPaths — YAML kind', () => { +describe("findOcPaths — YAML kind", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: build\n' + - ' command: npm run build\n' + - ' - id: test\n' + - ' command: npm test\n' + - ' - id: lint\n' + - ' command: npm run lint\n' + "steps:\n" + + " - id: build\n" + + " command: npm run build\n" + + " - id: test\n" + + " command: npm test\n" + + " - id: lint\n" + + " command: npm run lint\n", ).ast; - it('* in item slot enumerates each step', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf.lobster/steps/*/command')); + it("* in item slot enumerates each step", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf.lobster/steps/*/command")); expect(out).toHaveLength(3); const paths = out.map((m) => formatOcPath(m.path)); expect(paths).toEqual([ - 'oc://wf.lobster/steps/0/command', - 'oc://wf.lobster/steps/1/command', - 'oc://wf.lobster/steps/2/command', + "oc://wf.lobster/steps/0/command", + "oc://wf.lobster/steps/1/command", + "oc://wf.lobster/steps/2/command", ]); }); - it('preserves slot shape — concrete path has matched value in item slot', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); + it("preserves slot shape — concrete path has matched value in item slot", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/id")); expect(out).toHaveLength(3); for (const m of out) { - expect(m.path.section).toBe('steps'); - expect(m.path.field).toBe('id'); + expect(m.path.section).toBe("steps"); + expect(m.path.field).toBe("id"); expect(m.path.item).toMatch(/^[0-2]$/); } }); - it('returns leaf valueText for each match', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); - const leaves = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : null); - expect(leaves).toEqual(['build', 'test', 'lint']); + it("returns leaf valueText for each match", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/id")); + const leaves = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : null)); + expect(leaves).toEqual(["build", "test", "lint"]); }); - it('** descends recursively', () => { - const yaml2 = parseYaml( - 'a:\n b:\n c: deep\n d: shallow\n' - ).ast; - const out = findOcPaths(yaml2, parseOcPath('oc://wf/**')); + it("** descends recursively", () => { + const yaml2 = parseYaml("a:\n b:\n c: deep\n d: shallow\n").ast; + const out = findOcPaths(yaml2, parseOcPath("oc://wf/**")); // ** matches root + a + a.b + a.b.c + a.d - const leaves = out.filter((m) => m.match.kind === 'leaf').map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(leaves.toSorted()).toEqual(['deep', 'shallow']); + const leaves = out + .filter((m) => m.match.kind === "leaf") + .map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(leaves.toSorted()).toEqual(["deep", "shallow"]); }); - it('returns empty for path that does not match', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/missing/*/x')); + it("returns empty for path that does not match", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/missing/*/x")); expect(out).toHaveLength(0); }); - it('every returned path is consumable by resolveOcPath', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/command')); + it("every returned path is consumable by resolveOcPath", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/command")); for (const m of out) { const r = resolveOcPath(yaml, m.path); - expect(r).not.toBeNull(); - expect(r?.kind).toBe('leaf'); + expect(r?.kind).toBe("leaf"); } }); }); // ---------- findOcPaths — JSONC -------------------------------------------- -describe('findOcPaths — JSONC kind', () => { +describe("findOcPaths — JSONC kind", () => { const jsonc = parseJsonc( - '{\n' + - ' "plugins": {\n' + - ' "github": {"enabled": true},\n' + - ' "gitlab": {"enabled": false},\n' + - ' "slack": {"enabled": true}\n' + - ' }\n' + - '}\n' + "{\n" + + ' "plugins": {\n' + + ' "github": {"enabled": true},\n' + + ' "gitlab": {"enabled": false},\n' + + ' "slack": {"enabled": true}\n' + + " }\n" + + "}\n", ).ast; - it('* in item slot enumerates each plugin', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + it("* in item slot enumerates each plugin", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled")); expect(out).toHaveLength(3); const keys = out.map((m) => m.path.item); - expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['github', 'gitlab', 'slack']); + expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual([ + "github", + "gitlab", + "slack", + ]); }); - it('returns boolean leaves with leafType', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + it("returns boolean leaves with leafType", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled")); for (const m of out) { - expect(m.match.kind).toBe('leaf'); - if (m.match.kind === 'leaf') { - expect(m.match.leafType).toBe('boolean'); + expect(m.match.kind).toBe("leaf"); + if (m.match.kind === "leaf") { + expect(m.match.leafType).toBe("boolean"); } } }); @@ -205,22 +204,22 @@ describe('findOcPaths — JSONC kind', () => { // ---------- findOcPaths — JSONL -------------------------------------------- -describe('findOcPaths — JSONL kind', () => { +describe("findOcPaths — JSONL kind", () => { const jsonl = parseJsonl( '{"event":"start","userId":"u1"}\n' + - '{"event":"action","userId":"u1"}\n' + - '{"event":"end","userId":"u1"}\n' + '{"event":"action","userId":"u1"}\n' + + '{"event":"end","userId":"u1"}\n', ).ast; - it('* in section slot enumerates each value line', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + it("* in section slot enumerates each value line", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event")); expect(out).toHaveLength(3); - const events = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(events).toEqual(['start', 'action', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "action", "end"]); }); - it('preserves Lnnn line addresses in concrete paths', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + it("preserves Lnnn line addresses in concrete paths", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event")); for (const m of out) { expect(m.path.section).toMatch(/^L\d+$/); } @@ -229,206 +228,224 @@ describe('findOcPaths — JSONL kind', () => { // F8 — line-slot union and predicate. Without these, yaml/jsonc // walkers handled them but JSONL fell through to `pickLine(addr)` // which returns null for union/predicate shapes → silent zero matches. - it('union {L1,L2} at line slot enumerates each alternative', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,L3}/event')); + it("union {L1,L2} at line slot enumerates each alternative", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,L3}/event")); expect(out).toHaveLength(2); - const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); - expect(events).toEqual(['start', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "end"]); }); - it('union of positional + literal line addresses works', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,$last}/event')); + it("union of positional + literal line addresses works", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,$last}/event")); expect(out).toHaveLength(2); - const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); - expect(events).toEqual(['start', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "end"]); }); - it('predicate [event=action] at line slot filters by top-level field', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=action]/userId')); + it("predicate [event=action] at line slot filters by top-level field", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=action]/userId")); expect(out).toHaveLength(1); - if (out[0]?.match.kind === 'leaf') {expect(out[0].match.valueText).toBe('u1');} + if (out[0]?.match.kind === "leaf") { + expect(out[0].match.valueText).toBe("u1"); + } }); - it('predicate [event=missing] at line slot matches zero lines (silent zero is correct)', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=missing]/userId')); + it("predicate [event=missing] at line slot matches zero lines (silent zero is correct)", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=missing]/userId")); expect(out).toHaveLength(0); }); }); // ---------- Positional primitives ($first / $last / -N) ------------------- -describe('positional primitives — yaml', () => { - const yaml = parseYaml( - 'steps:\n - id: a\n - id: b\n - id: c\n' - ).ast; +describe("positional primitives — yaml", () => { + const yaml = parseYaml("steps:\n - id: a\n - id: b\n - id: c\n").ast; - it('resolveOcPath accepts $first', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$first/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + it("resolveOcPath accepts $first", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/$first/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("a"); + } }); - it('resolveOcPath accepts $last', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$last/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('c');} + it("resolveOcPath accepts $last", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/$last/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("c"); + } }); - it('resolveOcPath accepts negative index', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/-2/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + it("resolveOcPath accepts negative index", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/-2/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("b"); + } }); - it('out-of-range positional returns null', () => { - expect(resolveOcPath(yaml, parseOcPath('oc://wf/steps/-99/id'))).toBeNull(); + it("out-of-range positional returns null", () => { + expect(resolveOcPath(yaml, parseOcPath("oc://wf/steps/-99/id"))).toBeNull(); }); - it('positional on empty container returns null', () => { - const empty = parseYaml('steps: []\n').ast; - expect(resolveOcPath(empty, parseOcPath('oc://wf/steps/$first/id'))).toBeNull(); + it("positional on empty container returns null", () => { + const empty = parseYaml("steps: []\n").ast; + expect(resolveOcPath(empty, parseOcPath("oc://wf/steps/$first/id"))).toBeNull(); }); - it('findOcPaths emits concrete index for positional', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/$last/id')); + it("findOcPaths emits concrete index for positional", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/$last/id")); expect(out).toHaveLength(1); - expect(out[0].path.item).toBe('2'); + expect(out[0].path.item).toBe("2"); }); - it('hasWildcard returns false for positional patterns', () => { + it("hasWildcard returns false for positional patterns", () => { // Positional ≠ wildcard — they resolve deterministically. - expect(hasWildcard(parseOcPath('oc://X/$last/id'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/-1/id'))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/$last/id"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/-1/id"))).toBe(false); }); }); -describe('positional primitives — jsonc', () => { +describe("positional primitives — jsonc", () => { const jsonc = parseJsonc('{"items":[10,20,30]}').ast; - it('$first picks first array element', () => { - const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$first')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('10');} + it("$first picks first array element", () => { + const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$first")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("10"); + } }); - it('$last picks last array element', () => { - const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$last')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + it("$last picks last array element", () => { + const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$last")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("30"); + } }); - it('$first on object picks first-declared key', () => { + it("$first on object picks first-declared key", () => { const obj = parseJsonc('{"a":1,"b":2,"c":3}').ast; - const m = resolveOcPath(obj, parseOcPath('oc://config/$first')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('1');} + const m = resolveOcPath(obj, parseOcPath("oc://config/$first")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("1"); + } }); }); -describe('positional primitives — jsonl', () => { - const jsonl = parseJsonl( - '{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n' - ).ast; +describe("positional primitives — jsonl", () => { + const jsonl = parseJsonl('{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n').ast; - it('$first picks first value line', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/$first/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('start');} + it("$first picks first value line", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/$first/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("start"); + } }); - it('$last picks last value line (existing behavior)', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/$last/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + it("$last picks last value line (existing behavior)", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/$last/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("end"); + } }); - it('-1 is alias for $last', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/-1/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + it("-1 is alias for $last", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/-1/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("end"); + } }); }); // ---------- Segment unions {a,b,c} ----------------------------------------- -describe('union segments — yaml', () => { +describe("union segments — yaml", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: a\n command: x\n' + - ' - id: b\n run: y\n' + - ' - id: c\n pipeline: z\n' + "steps:\n" + + " - id: a\n command: x\n" + + " - id: b\n run: y\n" + + " - id: c\n pipeline: z\n", ).ast; - it('{command,run} matches each step that has either field', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,run}')); + it("{command,run} matches each step that has either field", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/{command,run}")); expect(out).toHaveLength(2); const fields = out.map((m) => m.path.field); - expect(fields.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['command', 'run']); + expect(fields.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["command", "run"]); }); - it('preserves the chosen alternative in concrete paths', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,pipeline}')); + it("preserves the chosen alternative in concrete paths", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/{command,pipeline}")); expect(out).toHaveLength(2); for (const m of out) { - expect(['command', 'pipeline']).toContain(m.path.field); + expect(["command", "pipeline"]).toContain(m.path.field); } }); - it('unions on top-level keys', () => { - const yaml2 = parseYaml('a: 1\nb: 2\nc: 3\n').ast; - const out = findOcPaths(yaml2, parseOcPath('oc://X/{a,c}')); + it("unions on top-level keys", () => { + const yaml2 = parseYaml("a: 1\nb: 2\nc: 3\n").ast; + const out = findOcPaths(yaml2, parseOcPath("oc://X/{a,c}")); expect(out).toHaveLength(2); - const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(values.toSorted()).toEqual(['1', '3']); + const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(values.toSorted()).toEqual(["1", "3"]); }); - it('hasWildcard detects unions (single-match guard rejects them)', () => { - expect(hasWildcard(parseOcPath('oc://X/{a,b}'))).toBe(true); + it("hasWildcard detects unions (single-match guard rejects them)", () => { + expect(hasWildcard(parseOcPath("oc://X/{a,b}"))).toBe(true); // F16 — wildcard guard now throws OC_PATH_WILDCARD_IN_RESOLVE // instead of returning silent null. - expect(() => - resolveOcPath(parseYaml('a: 1\nb: 2\n').ast, parseOcPath('oc://X/{a,b}')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(parseYaml("a: 1\nb: 2\n").ast, parseOcPath("oc://X/{a,b}"))).toThrow( + /findOcPaths/, + ); }); }); // ---------- Value predicates [key=value] ---------------------------------- -describe('value predicates — yaml', () => { +describe("value predicates — yaml", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: build\n command: npm run build\n' + - ' - id: test\n command: npm test\n' + - ' - id: lint\n command: npm run lint\n' + "steps:\n" + + " - id: build\n command: npm run build\n" + + " - id: test\n command: npm test\n" + + " - id: lint\n command: npm run lint\n", ).ast; - it('[id=test] selects the matching step', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=test]/command')); + it("[id=test] selects the matching step", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=test]/command")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') { - expect(out[0].match.valueText).toBe('npm test'); + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("npm test"); } - expect(out[0].path.item).toBe('1'); // concrete index of the matched step + expect(out[0].path.item).toBe("1"); // concrete index of the matched step }); - it('predicate yields no matches when key/value missing', () => { - expect(findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=nonexistent]/command'))).toHaveLength(0); + it("predicate yields no matches when key/value missing", () => { + expect(findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=nonexistent]/command"))).toHaveLength( + 0, + ); }); - it('predicate concretizes the index — path round-trips through resolveOcPath', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=build]/command')); + it("predicate concretizes the index — path round-trips through resolveOcPath", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=build]/command")); expect(out).toHaveLength(1); const resolved = resolveOcPath(yaml, out[0].path); - expect(resolved?.kind).toBe('leaf'); + expect(resolved?.kind).toBe("leaf"); }); - it('predicate rejects single-match verbs (treated as wildcard)', () => { + it("predicate rejects single-match verbs (treated as wildcard)", () => { // F16 — wildcard guard throws on predicate too (predicate is a // multi-match shape; resolveOcPath is single-match only). - expect(() => - resolveOcPath(yaml, parseOcPath('oc://wf/steps/[id=build]')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(yaml, parseOcPath("oc://wf/steps/[id=build]"))).toThrow( + /findOcPaths/, + ); }); }); -describe('quoted segments (v1.0)', () => { +describe("quoted segments (v1.0)", () => { // Evidence: openclaw#69004 — model alias `anthropic/claude-opus-4-7`. // Slash inside the key has no other syntax that doesn't conflict with // path-level slash split. @@ -437,70 +454,80 @@ describe('quoted segments (v1.0)', () => { '"anthropic/claude-opus-4-7":{"alias":"opus47","contextWindow":1000000},' + '"github-copilot/claude-opus-4.7-1m-internal":{"alias":"copilot-opus-1m","contextWindow":1000000},' + '"plain":{"alias":"p","contextWindow":200000}' + - '}}}}' + "}}}}", ).ast; - it('resolveOcPath — quoted segment with literal slash', () => { + it("resolveOcPath — quoted segment with literal slash", () => { const m = resolveOcPath( jsonc, parseOcPath('oc://config/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'), ); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('opus47');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("opus47"); + } }); - it('resolveOcPath — quoted segment with literal slash AND dot', () => { + it("resolveOcPath — quoted segment with literal slash AND dot", () => { const m = resolveOcPath( jsonc, - parseOcPath('oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias'), + parseOcPath( + 'oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias', + ), ); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('copilot-opus-1m');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("copilot-opus-1m"); + } }); - it('quoted segment with whitespace', () => { + it("quoted segment with whitespace", () => { const ast = parseJsonc('{"prompts":{"hello world":"value"}}').ast; const m = resolveOcPath(ast, parseOcPath('oc://X/prompts/"hello world"')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('value');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("value"); + } }); - it('quoted segment with embedded escape sequences', () => { + it("quoted segment with embedded escape sequences", () => { // Key literally contains a backslash and a quote. const ast = parseJsonc('{"keys":{"a\\\\b":"v1","c\\"d":"v2"}}').ast; const m1 = resolveOcPath(ast, parseOcPath('oc://X/keys/"a\\\\b"')); - expect(m1?.kind).toBe('leaf'); - if (m1?.kind === 'leaf') {expect(m1.valueText).toBe('v1');} + expect(m1?.kind).toBe("leaf"); + if (m1?.kind === "leaf") { + expect(m1.valueText).toBe("v1"); + } }); - it('findOcPaths — wildcard returns paths with quoted keys when needed', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + it("findOcPaths — wildcard returns paths with quoted keys when needed", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias")); expect(out).toHaveLength(3); // The two slash-bearing keys round-trip via quotes; `plain` stays bare. const items = out.map((m) => m.path.item); - expect(items.some((s) => s === 'plain')).toBe(true); + expect(items.some((s) => s === "plain")).toBe(true); expect(items.some((s) => s === '"anthropic/claude-opus-4-7"')).toBe(true); expect(items.some((s) => s === '"github-copilot/claude-opus-4.7-1m-internal"')).toBe(true); }); - it('findOcPaths — emitted paths round-trip through resolveOcPath', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + it("findOcPaths — emitted paths round-trip through resolveOcPath", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias")); for (const m of out) { const r = resolveOcPath(jsonc, m.path); - expect(r?.kind).toBe('leaf'); + expect(r?.kind).toBe("leaf"); } }); - it('rejects unbalanced quotes at parse time', () => { + it("rejects unbalanced quotes at parse time", () => { expect(() => parseOcPath('oc://X/"unterminated')).toThrow(/Unbalanced/); }); - it('control characters still rejected inside quotes', () => { + it("control characters still rejected inside quotes", () => { expect(() => parseOcPath('oc://X/"\x00"')).toThrow(/Control character/); }); }); -describe('value predicates — numeric operators (v1.1)', () => { +describe("value predicates — numeric operators (v1.1)", () => { // Evidence: openclaw#54383 — compaction fails when maxTokens > model output cap. // Doctor lint rule: flag any model with maxTokens > 128000 (Anthropic per-request output cap). const jsonc = parseJsonc( @@ -508,142 +535,148 @@ describe('value predicates — numeric operators (v1.1)', () => { '{"id":"claude-sonnet-4-6","contextWindow":1000000,"maxTokens":128000},' + '{"id":"claude-opus-4-7","contextWindow":1000000,"maxTokens":240000},' + '{"id":"claude-sonnet-4-7","contextWindow":200000,"maxTokens":64000}' + - ']}}}}' + "]}}}}", ).ast; // Slot layout: section=`models.providers.anthropic.models`, item=predicate, field=`id`. - const PREFIX = 'oc://config/models.providers.anthropic.models'; + const PREFIX = "oc://config/models.providers.anthropic.models"; - it('> finds models exceeding the per-request output cap', () => { + it("> finds models exceeding the per-request output cap", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>128000]/id`)); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-opus-4-7');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("claude-opus-4-7"); + } }); - it('>= matches the boundary', () => { + it(">= matches the boundary", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>=128000]/id`)); - const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(ids.toSorted()).toEqual(['claude-opus-4-7', 'claude-sonnet-4-6']); + const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(ids.toSorted()).toEqual(["claude-opus-4-7", "claude-sonnet-4-6"]); }); - it('< filters small context windows', () => { + it("< filters small context windows", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<500000]/id`)); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-sonnet-4-7');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("claude-sonnet-4-7"); + } }); - it('<= matches the boundary', () => { + it("<= matches the boundary", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<=200000]/id`)); - const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(ids).toEqual(['claude-sonnet-4-7']); + const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(ids).toEqual(["claude-sonnet-4-7"]); }); - it('numeric operator rejects non-numeric leaves silently', () => { + it("numeric operator rejects non-numeric leaves silently", () => { // String leaf, numeric op — predicate doesn't match (no false positive). const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[id>5]/id`)); expect(out).toHaveLength(0); }); - it('rejects numeric predicate value that is not a number', () => { + it("rejects numeric predicate value that is not a number", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>foo]/id`)); expect(out).toHaveLength(0); }); }); -describe('value predicates — jsonc', () => { +describe("value predicates — jsonc", () => { const jsonc = parseJsonc( - '{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}' + '{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}', ).ast; - it('[enabled=true] filters by sibling boolean', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/[enabled=true]/role')); + it("[enabled=true] filters by sibling boolean", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/[enabled=true]/role")); expect(out).toHaveLength(2); - const roles = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(roles.toSorted()).toEqual(['tracker', 'vcs']); + const roles = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(roles.toSorted()).toEqual(["tracker", "vcs"]); }); }); // ---------- Ordinal addressing (#N) for distinct duplicate slugs ---------- -describe('ordinal addressing — md', () => { +describe("ordinal addressing — md", () => { // Two items with the same slug after slugify (`foo: a` and `foo: b`). - const md = parseMd( - '## Tools\n\n- foo: a\n- foo: b\n- bar: c\n' - ).ast; + const md = parseMd("## Tools\n\n- foo: a\n- foo: b\n- bar: c\n").ast; - it('#0 picks the first item by document order', () => { - const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#0/foo')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + it("#0 picks the first item by document order", () => { + const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#0/foo")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("a"); + } }); - it('#1 picks the second item — distinct from #0 even though slug collides', () => { - const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#1/foo')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + it("#1 picks the second item — distinct from #0 even though slug collides", () => { + const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#1/foo")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("b"); + } }); - it('out-of-range #N returns null', () => { - expect(resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#99/foo'))).toBeNull(); + it("out-of-range #N returns null", () => { + expect(resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#99/foo"))).toBeNull(); }); - it('findOcPaths disambiguates duplicate-slug items via #N', () => { - const out = findOcPaths(md, parseOcPath('oc://AGENTS.md/tools/*/foo')); + it("findOcPaths disambiguates duplicate-slug items via #N", () => { + const out = findOcPaths(md, parseOcPath("oc://AGENTS.md/tools/*/foo")); // 2 items have key `foo` (and matching slug); 1 has `bar` (no match). expect(out).toHaveLength(2); const items = out.map((m) => m.path.item); - expect(items).toEqual(['#0', '#1']); - const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(values.toSorted()).toEqual(['a', 'b']); + expect(items).toEqual(["#0", "#1"]); + const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(values.toSorted()).toEqual(["a", "b"]); }); - it('non-duplicate slug keeps slug form (back-compat)', () => { - const md2 = parseMd('## Tools\n\n- foo: a\n- bar: b\n').ast; - const out = findOcPaths(md2, parseOcPath('oc://AGENTS.md/tools/*')); + it("non-duplicate slug keeps slug form (back-compat)", () => { + const md2 = parseMd("## Tools\n\n- foo: a\n- bar: b\n").ast; + const out = findOcPaths(md2, parseOcPath("oc://AGENTS.md/tools/*")); const items = out.map((m) => m.path.item); // Both unique → both stay as slugs. - expect(items.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['bar', 'foo']); + expect(items.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["bar", "foo"]); }); }); // ---------- findOcPaths — Markdown ----------------------------------------- -describe('findOcPaths — Markdown kind', () => { +describe("findOcPaths — Markdown kind", () => { const md = parseMd( - '---\nname: drafter\nrole: writer\n---\n\n' + - '## Tools\n\n' + - '- send_email: enabled\n' + - '- search: enabled\n' + - '- read_email: disabled\n' + "---\nname: drafter\nrole: writer\n---\n\n" + + "## Tools\n\n" + + "- send_email: enabled\n" + + "- search: enabled\n" + + "- read_email: disabled\n", ).ast; - it('* in field slot enumerates frontmatter keys', () => { - const out = findOcPaths(md, parseOcPath('oc://SOUL.md/[frontmatter]/*')); + it("* in field slot enumerates frontmatter keys", () => { + const out = findOcPaths(md, parseOcPath("oc://SOUL.md/[frontmatter]/*")); expect(out).toHaveLength(2); const keys = out.map((m) => m.path.item ?? m.path.field); - expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['name', 'role']); + expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["name", "role"]); }); - it('* in field slot enumerates each item kv key', () => { + it("* in field slot enumerates each item kv key", () => { // Item slug is the kv-key slug ('send_email' → 'send-email'). - const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/send-email/*')); + const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/send-email/*")); expect(out).toHaveLength(1); - expect(out[0].match.kind).toBe('leaf'); - if (out[0].match.kind === 'leaf') { - expect(out[0].match.valueText).toBe('enabled'); + expect(out[0].match.kind).toBe("leaf"); + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("enabled"); } }); - it('* in item slot + matching field returns each item whose kv key matches', () => { + it("* in item slot + matching field returns each item whose kv key matches", () => { // The kv key on `- send_email: enabled` is `send_email`. Pattern // field='send_email' matches that one item; the other two items // (search, read_email) have different kv keys. - const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/*/send_email')); + const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/*/send_email")); expect(out).toHaveLength(1); - expect(out[0].path.item).toBe('send-email'); + expect(out[0].path.item).toBe("send-email"); }); - it('** at section slot matches items at every depth (F14 — cross-kind symmetry)', () => { + it("** at section slot matches items at every depth (F14 — cross-kind symmetry)", () => { // Without the retain-i branch on `**`, walkMd only descended one // level (i + 1, consumed `**`) — yaml/jsonc walkers also retain // `**` to keep matching deeper. Lint rules expecting universal @@ -656,23 +689,23 @@ describe('findOcPaths — Markdown kind', () => { // section layer and then can't satisfy the item slot since the // walker is now inside the wrong block looking for an item slug. const multiBlock = parseMd( - '## Boundaries\n\n' + - '- never: rm -rf\n\n' + - '## Tools\n\n' + - '- send_email: enabled\n' + - '- search: enabled\n', + "## Boundaries\n\n" + + "- never: rm -rf\n\n" + + "## Tools\n\n" + + "- send_email: enabled\n" + + "- search: enabled\n", ).ast; - const out = findOcPaths(multiBlock, parseOcPath('oc://SOUL.md/**/send-email')); + const out = findOcPaths(multiBlock, parseOcPath("oc://SOUL.md/**/send-email")); // The `send-email` item is under the `tools` block. Pin that we // get at least one match (the substrate's md `**` should reach it). expect(out.length).toBeGreaterThanOrEqual(1); const items = out.map((m) => m.path.item).filter((v): v is string => v !== undefined); - expect(items).toContain('send-email'); + expect(items).toContain("send-email"); }); }); -describe('findOcPaths — quoted segments survive expansion (regression: resolve↔find symmetry)', () => { - it('finds keys with slashes when the path quotes them and a sibling wildcards', () => { +describe("findOcPaths — quoted segments survive expansion (regression: resolve↔find symmetry)", () => { + it("finds keys with slashes when the path quotes them and a sibling wildcards", () => { // Closes ClawSweeper P2 on PR #78678: when a pattern needs // expansion (e.g. trailing union or wildcard), the JSONC walker // bypassed `resolveJsoncOcPath` and compared object keys to the @@ -701,7 +734,9 @@ describe('findOcPaths — quoted segments survive expansion (regression: resolve ); // Both alternatives in the union should match. expect(out.length).toBe(2); - const fields = out.map((m) => m.path.field).toSorted((a, b) => (a ?? '').localeCompare(b ?? '')); - expect(fields).toEqual(['alias', 'contextWindow']); + const fields = out + .map((m) => m.path.field) + .toSorted((a, b) => (a ?? "").localeCompare(b ?? "")); + expect(fields).toEqual(["alias", "contextWindow"]); }); }); diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts index dc6276bbba4..bb6b54c6bd5 100644 --- a/src/oc-path/tests/scenarios/pitfalls.test.ts +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -161,7 +161,6 @@ describe("wave-23 pitfalls — sentinels & collisions", () => { ast, parseOcPath("oc://config/channels.telegram.groups.-5028303500.requireMention"), ); - expect(m).not.toBeNull(); expect(m?.kind).toBe("leaf"); if (m?.kind === "leaf") { expect(m.valueText).toBe("false"); From 28fad6a6c3718794f06a89860cab76e8a2144be9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:49:34 +0100 Subject: [PATCH 288/806] test: dedupe image generation tool assertions --- src/agents/tools/image-generate-tool.test.ts | 150 +++++++++---------- 1 file changed, 67 insertions(+), 83 deletions(-) diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index f028a06c23c..dd61db58eb3 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -286,7 +286,7 @@ describe("createImageGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + requireImageGenerateTool( createImageGenerateTool({ config: { agents: { @@ -298,7 +298,7 @@ describe("createImageGenerateTool", () => { }, }, }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); @@ -325,7 +325,7 @@ describe("createImageGenerateTool", () => { }, ]); - expect( + requireImageGenerateTool( createImageGenerateTool({ config: { agents: { @@ -337,7 +337,7 @@ describe("createImageGenerateTool", () => { }, }, }), - ).not.toBeNull(); + ); }); it("infers an OpenAI image-generation model from env-backed auth", () => { @@ -347,7 +347,7 @@ describe("createImageGenerateTool", () => { expect(resolveImageGenerationModelConfigForTool({ cfg: {} })).toEqual({ primary: "openai/gpt-image-1", }); - expect(createImageGenerateTool({ config: {} })).not.toBeNull(); + requireImageGenerateTool(createImageGenerateTool({ config: {} })); }); it("does not load runtime providers while resolving an explicitly configured model", () => { @@ -410,7 +410,7 @@ describe("createImageGenerateTool", () => { ).toEqual({ primary: "openai/gpt-image-2", }); - expect(createImageGenerateTool({ config: {}, agentDir: "/tmp/agent" })).not.toBeNull(); + requireImageGenerateTool(createImageGenerateTool({ config: {}, agentDir: "/tmp/agent" })); expect(isConfigured).toHaveBeenCalledWith({ cfg: {}, agentDir: "/tmp/agent", @@ -548,24 +548,21 @@ describe("createImageGenerateTool", () => { contentType: "image/png", }); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - mediaMaxMb: 8, - imageGenerationModel: { - primary: "openai/gpt-image-1", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + mediaMaxMb: 8, + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, }, }, }, - }, - agentDir: "/tmp/agent", - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + agentDir: "/tmp/agent", + }), + ); const result = await tool.execute("call-1", { prompt: "A cat wearing sunglasses", @@ -856,19 +853,17 @@ describe("createImageGenerateTool", () => { contentType: "image/jpeg", }); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { primary: "google/gemini-3.1-flash-image-preview" }, + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { primary: "google/gemini-3.1-flash-image-preview" }, + }, }, }, - }, - }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-regression", { prompt: "kodo sawaki zazen" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; @@ -913,21 +908,19 @@ describe("createImageGenerateTool", () => { }), }, ]); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3.1-flash-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, }, }, }, - }, - }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); await expect(tool.execute("call-2", { prompt: "too many cats", count: 5 })).rejects.toThrow( "count must be between 1 and 4", @@ -1363,22 +1356,19 @@ describe("createImageGenerateTool", () => { it("rejects unsupported aspect ratios", async () => { stubImageGenerationProviders(); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3-pro-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); await expect( tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }), @@ -1390,22 +1380,19 @@ describe("createImageGenerateTool", () => { it("lists registered provider and model options", async () => { stubImageGenerationProviders(); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3.1-flash-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-list", { action: "list" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; @@ -1467,22 +1454,19 @@ describe("createImageGenerateTool", () => { }, ]); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "__proto__/proto-v1", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "__proto__/proto-v1", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-list-proto", { action: "list" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; From a6313f64f90cbeb670f88c36b4b75538cedcc355 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:51:12 +0100 Subject: [PATCH 289/806] test: tighten nullable agent helper assertions --- .../pi-embedded-helpers.buildbootstrapcontextfiles.test.ts | 3 ++- src/agents/sandbox.resolveSandboxContext.test.ts | 5 ++++- src/agents/subagent-spawn.attachments.test.ts | 2 -- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index 8a52376a1a5..bc3a65aa842 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -71,7 +71,8 @@ describe("buildBootstrapContextFiles", () => { warn: (message) => warnings.push(message), }); const kept = result?.content.match(/kept (\d+)\+(\d+) chars/); - expect(kept).not.toBeNull(); + expect(kept?.[1]).toEqual(expect.any(String)); + expect(kept?.[2]).toEqual(expect.any(String)); if (!kept) { throw new Error("missing truncation kept-count marker"); } diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index c75b8ac4c19..b55ed3c0fde 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -322,7 +322,10 @@ describe("resolveSandboxContext", () => { workspaceDir, }); - expect(result).not.toBeNull(); + expect(result).toMatchObject({ workspaceDir: expect.any(String) }); + if (!result) { + throw new Error("expected sandbox workspace resolution"); + } expect(syncSkillsToWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ sourceWorkspaceDir: workspaceDir, diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 36767de7d54..231b7d1ad6f 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -36,7 +36,6 @@ describe("decodeStrictBase64", () => { const input = "hello world"; const encoded = Buffer.from(input).toString("base64"); const result = decodeStrictBase64(encoded, maxBytes); - expect(result).not.toBeNull(); expect(result?.toString("utf8")).toBe(input); }); @@ -79,7 +78,6 @@ describe("decodeStrictBase64", () => { const exactBuf = Buffer.alloc(1024, 0x41); const encoded = exactBuf.toString("base64"); const result = decodeStrictBase64(encoded, maxBytes); - expect(result).not.toBeNull(); expect(result?.byteLength).toBe(1024); }); }); From 0aa2bcd8d36dc94093151eb6fa1f195e07c0987b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:52:26 +0100 Subject: [PATCH 290/806] test: tighten quick settings assertions --- ui/src/ui/views/config-quick.test.ts | 31 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 17cf2f8cf0c..624653570d5 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -67,14 +67,23 @@ describe("renderQuickSettings", () => { render(renderQuickSettings(createProps()), container); - expect(container.querySelector(".qs-card--model")).not.toBeNull(); - expect(container.querySelector(".qs-card--channels")).not.toBeNull(); - expect(container.querySelector(".qs-card--security")).not.toBeNull(); - expect(container.querySelector(".qs-card--appearance")).not.toBeNull(); - expect(container.querySelector(".qs-card--automations")).not.toBeNull(); - expect(container.querySelector(".qs-side-stack .qs-card--appearance")).not.toBeNull(); - expect(container.querySelector(".qs-side-stack .qs-card--automations")).not.toBeNull(); - expect(container.querySelector(".qs-card--personal")).not.toBeNull(); + expect( + Array.from(container.querySelectorAll(".qs-card")) + .map((card) => + Array.from(card.classList).find( + (className) => className.startsWith("qs-card--") && className !== "qs-card--span-all", + ), + ) + .filter(Boolean), + ).toEqual([ + "qs-card--model", + "qs-card--channels", + "qs-card--security", + "qs-card--personal", + "qs-card--appearance", + "qs-card--automations", + ]); + expect(container.querySelectorAll(".qs-side-stack .qs-card")).toHaveLength(2); expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1); }); @@ -203,9 +212,9 @@ describe("renderQuickSettings", () => { const input = inputs.find((node) => node.closest(".qs-identity-card--assistant"), ) as HTMLInputElement | null; - expect(input).not.toBeNull(); + expect(input?.type).toBe("file"); if (!input) { - return; + throw new Error("expected assistant avatar file input"); } Object.defineProperty(input, "files", { @@ -247,7 +256,7 @@ describe("renderQuickSettings", () => { const clear = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Clear override", ); - expect(clear).not.toBeUndefined(); + expect(clear?.textContent?.trim()).toBe("Clear override"); clear?.dispatchEvent(new Event("click")); expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); From 8940d346828ebd79890038dff0769b167ca15a0d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:53:52 +0100 Subject: [PATCH 291/806] test: tighten settings poller assertions --- ui/src/ui/app-settings.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 4dc2a67b408..acb9d91d012 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -256,8 +256,8 @@ describe("setTabFromRoute", () => { const host = createHost("chat"); setTabFromRoute(host, "logs"); - expect(host.logsPollInterval).not.toBeNull(); expect(host.debugPollInterval).toBeNull(); + expect(host.logsPollInterval).not.toBe(host.debugPollInterval); setTabFromRoute(host, "chat"); expect(host.logsPollInterval).toBeNull(); @@ -267,8 +267,8 @@ describe("setTabFromRoute", () => { const host = createHost("chat"); setTabFromRoute(host, "debug"); - expect(host.debugPollInterval).not.toBeNull(); expect(host.logsPollInterval).toBeNull(); + expect(host.debugPollInterval).not.toBe(host.logsPollInterval); setTabFromRoute(host, "chat"); expect(host.debugPollInterval).toBeNull(); From 7c401f24e412f8b71ea7362f6afe2ba870400ea7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:53:51 +0100 Subject: [PATCH 292/806] test: dedupe history image prune assertions --- .../run/history-image-prune.test.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts index 820e0c482c9..e9442e786da 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts +++ b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts @@ -23,15 +23,23 @@ function expectPrunedImageMessage( messages: AgentMessage[], errorMessage: string, ): Array<{ type: string; text?: string; data?: string }> { - const pruned = pruneProcessedHistoryImages(messages); - expect(pruned).not.toBeNull(); - expect(pruned).not.toBe(messages); - const content = expectArrayMessageContent(pruned?.[0], errorMessage); + const pruned = expectPrunedMessages(messages); + const content = expectArrayMessageContent(pruned[0], errorMessage); expect(content).toHaveLength(2); expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); return content; } +function expectPrunedMessages(messages: AgentMessage[]): AgentMessage[] { + const pruned = pruneProcessedHistoryImages(messages); + expect(pruned).toEqual(expect.any(Array)); + if (!pruned) { + throw new Error("expected pruned history messages"); + } + expect(pruned).not.toBe(messages); + return pruned; +} + function expectImageMessagePreserved(messages: AgentMessage[], errorMessage: string) { const pruned = pruneProcessedHistoryImages(messages); @@ -98,10 +106,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const content = expectArrayMessageContent(pruned?.[0], "expected user array content"); + const content = expectArrayMessageContent(pruned[0], "expected user array content"); expect(content[0]?.text).toBe( [ "old image", @@ -128,10 +135,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const firstUser = pruned?.[0] as Extract | undefined; + const firstUser = pruned[0] as Extract | undefined; expect(firstUser?.content).toBe(`please remember ${PRUNED_HISTORY_MEDIA_REFERENCE_MARKER}`); const originalUser = messages[0] as Extract | undefined; expect(originalUser?.content).toBe( @@ -149,10 +155,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const toolResult = pruned?.[0] as Extract | undefined; + const toolResult = pruned[0] as Extract | undefined; expect(toolResult?.content).toBe(`previous ${PRUNED_HISTORY_MEDIA_REFERENCE_MARKER} result`); const originalToolResult = messages[0] as | Extract @@ -284,13 +289,12 @@ describe("pruneProcessedHistoryImages", () => { assistantTurn(), ]; - const pruned = pruneProcessedHistoryImages(messages); - expect(pruned).not.toBeNull(); + const pruned = expectPrunedMessages(messages); - const oldContent = expectArrayMessageContent(pruned?.[0], "expected old user content"); + const oldContent = expectArrayMessageContent(pruned[0], "expected old user content"); expect(oldContent[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); - const recentContent = expectArrayMessageContent(pruned?.[6], "expected recent user content"); + const recentContent = expectArrayMessageContent(pruned[6], "expected recent user content"); expect(recentContent[1]).toMatchObject({ type: "image", data: "abc" }); const originalOldContent = expectArrayMessageContent( From 58e9468f4f0532db92740e4f948e606b46c60c9b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:54:41 +0100 Subject: [PATCH 293/806] test: tighten exec approval assertions --- ui/src/ui/controllers/exec-approval.test.ts | 51 ++++++++++++--------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/ui/src/ui/controllers/exec-approval.test.ts b/ui/src/ui/controllers/exec-approval.test.ts index 4230c3e703a..ba5402e007b 100644 --- a/ui/src/ui/controllers/exec-approval.test.ts +++ b/ui/src/ui/controllers/exec-approval.test.ts @@ -9,9 +9,10 @@ describe("parseExecApprovalRequested", () => { createdAtMs: 1000, expiresAtMs: 2000, }); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("exec"); - expect(result!.request.command).toBe("rm -rf /"); + expect(result).toMatchObject({ + kind: "exec", + request: { command: "rm -rf /" }, + }); }); }); @@ -34,17 +35,20 @@ describe("parsePluginApprovalRequested", () => { it("parses a valid payload", () => { const result = parsePluginApprovalRequested(validPayload); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("plugin"); - expect(result!.pluginTitle).toBe("Dangerous command detected"); - expect(result!.pluginDescription).toBe("chmod 777 script.sh modifies file permissions"); - expect(result!.pluginSeverity).toBe("high"); - expect(result!.pluginId).toBe("sage"); - expect(result!.request.command).toBe("Dangerous command detected"); - expect(result!.request.agentId).toBe("agent-1"); - expect(result!.request.sessionKey).toBe("sess-1"); - expect(result!.createdAtMs).toBe(1000); - expect(result!.expiresAtMs).toBe(120_000); + expect(result).toMatchObject({ + kind: "plugin", + pluginTitle: "Dangerous command detected", + pluginDescription: "chmod 777 script.sh modifies file permissions", + pluginSeverity: "high", + pluginId: "sage", + request: { + command: "Dangerous command detected", + agentId: "agent-1", + sessionKey: "sess-1", + }, + createdAtMs: 1000, + expiresAtMs: 120_000, + }); }); it("returns null when title is missing from request", () => { @@ -86,14 +90,17 @@ describe("parsePluginApprovalRequested", () => { request: { title: "Alert" }, }; const result = parsePluginApprovalRequested(minimal); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("plugin"); - expect(result!.pluginTitle).toBe("Alert"); - expect(result!.pluginDescription).toBeNull(); - expect(result!.pluginSeverity).toBeNull(); - expect(result!.pluginId).toBeNull(); - expect(result!.request.agentId).toBeNull(); - expect(result!.request.sessionKey).toBeNull(); + expect(result).toMatchObject({ + kind: "plugin", + pluginTitle: "Alert", + pluginDescription: null, + pluginSeverity: null, + pluginId: null, + request: { + agentId: null, + sessionKey: null, + }, + }); }); }); From fc327378a0787762bb2f92febb62cdf1c44f9499 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:55:39 +0100 Subject: [PATCH 294/806] test: tighten root output assertions --- test/appcast.test.ts | 7 ++++--- test/cli-json-stdout.e2e.test.ts | 12 ++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/test/appcast.test.ts b/test/appcast.test.ts index 7abad4740ce..cce46caf054 100644 --- a/test/appcast.test.ts +++ b/test/appcast.test.ts @@ -32,9 +32,10 @@ describe("appcast.xml", () => { expect(items.length).toBeGreaterThan(0); for (const item of items) { - expect(item.shortVersion, item.raw).not.toBeNull(); - expect(item.sparkleVersion, item.raw).not.toBeNull(); - expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion!)); + if (item.shortVersion === null || item.sparkleVersion === null) { + throw new Error(`Appcast entry missing version fields: ${item.raw}`); + } + expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion)); } }); diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index f47d3678d16..912113d289c 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -34,14 +34,10 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(typeof parsed).toBe("object"); - expect(parsed).not.toBeNull(); - expect(Array.isArray(parsed)).toBe(false); - expect(Object.keys(parsed as Record).sort()).toEqual([ - "availability", - "channel", - "update", - ]); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected JSON object stdout, got: ${stdout}`); + } + expect(Object.keys(parsed).sort()).toEqual(["availability", "channel", "update"]); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); From e5dd03fb3d291202607dfa1b5e521b9f39ea0739 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:55:59 +0100 Subject: [PATCH 295/806] test: tighten runner image helper assertions --- .../pi-embedded-helpers.isbillingerrormessage.test.ts | 9 +++++---- .../pi-embedded-runner/run.incomplete-turn.test.ts | 3 +-- src/agents/pi-embedded-runner/run/images.test.ts | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 229413a9250..ab8b7473fe9 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -963,10 +963,11 @@ describe("image dimension errors", () => { const raw = '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; const parsed = parseImageDimensionError(raw); - expect(parsed).not.toBeNull(); - expect(parsed?.maxDimensionPx).toBe(2000); - expect(parsed?.messageIndex).toBe(84); - expect(parsed?.contentIndex).toBe(1); + expect(parsed).toMatchObject({ + maxDimensionPx: 2000, + messageIndex: 84, + contentIndex: 1, + }); expect(isImageDimensionErrorMessage(raw)).toBe(true); }); }); diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 4c600a29fcd..dba5a2122f8 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -1072,8 +1072,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), }); - expect(incompleteTurnText).not.toBeNull(); - expect(incompleteTurnText).toContain("couldn't generate a response"); + expect(incompleteTurnText).toEqual(expect.stringContaining("couldn't generate a response")); }); it("surfaces tool-use terminal with pre-tool text and side effects as replay-unsafe (#76477)", () => { diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index fc7545fda3c..caefbb883f0 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -267,8 +267,7 @@ describe("loadImageFromRef", () => { }, ); - expect(image).not.toBeNull(); - expect(image?.type).toBe("image"); + expect(image).toMatchObject({ type: "image" }); expect(image?.data.length).toBeGreaterThan(0); } finally { await fs.rm(sandboxParent, { recursive: true, force: true }); From d9175464d732ab78ba6daae171d518863b90e57a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:56:28 +0100 Subject: [PATCH 296/806] test: tighten chat mobile helper assertions --- ui/src/ui/app-render.helpers.browser.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index fb0c1b981ba..f267df681e8 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -182,10 +182,14 @@ describe("chat header controls (browser)", () => { const sessionRows = container.querySelectorAll(".chat-controls__session-row"); expect(sessionRows).toHaveLength(1); - expect(container.querySelector('select[data-chat-agent-filter="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-session-select="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-model-select="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-thinking-select="true"]')).not.toBeNull(); + expect( + Array.from(container.querySelectorAll("select")).map((select) => select.dataset), + ).toEqual([ + expect.objectContaining({ chatAgentFilter: "true" }), + expect.objectContaining({ chatSessionSelect: "true" }), + expect.objectContaining({ chatModelSelect: "true" }), + expect.objectContaining({ chatThinkingSelect: "true" }), + ]); }); it("renders the mobile dropdown from state instead of mutating DOM classes", async () => { @@ -200,8 +204,6 @@ describe("chat header controls (browser)", () => { const toggle = container.querySelector(".chat-controls-mobile-toggle"); const dropdown = container.querySelector(".chat-controls-dropdown"); - expect(toggle).not.toBeNull(); - expect(dropdown).not.toBeNull(); expect(toggle?.getAttribute("aria-expanded")).toBe("false"); expect(toggle?.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); expect(dropdown?.id).toBe("chat-mobile-controls-dropdown"); From b46c26b4b023b333ae04b0d5c62016ed844a80b5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:57:58 +0100 Subject: [PATCH 297/806] test: tighten tool card button assertions --- ui/src/ui/chat/tool-cards.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index 1c58fa6b66d..5e77a0b0d9a 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -83,7 +83,6 @@ describe("tool-cards", () => { expect(container.textContent).toContain("Tool call"); expect(container.textContent).not.toContain("Tool input"); const summaryButton = container.querySelector("button.chat-tool-msg-summary"); - expect(summaryButton).not.toBeNull(); expect(summaryButton?.getAttribute("aria-expanded")).toBe("false"); }); @@ -174,9 +173,9 @@ describe("tool-cards", () => { ); const sidebarButton = container.querySelector(".chat-tool-card__action-btn"); - sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(sidebarButton?.classList.contains("chat-tool-card__action-btn")).toBe(true); + sidebarButton?.click(); - expect(sidebarButton).not.toBeNull(); expect(onOpenSidebar).toHaveBeenCalledWith( expect.objectContaining({ kind: "canvas", From a571fcf041cc2b3930ddf48b8c2904eccfa002dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:57:58 +0100 Subject: [PATCH 298/806] test: tighten auth profile assertions --- src/agents/auth-profiles/oauth.concurrent-agents.test.ts | 7 ++++--- .../auth-profiles/oauth.fallback-to-main-agent.test.ts | 7 ++++--- ....uses-first-github-copilot-profile-env-tokens.test.ts | 9 ++++----- src/agents/pi-hooks/compaction-safeguard.test.ts | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index c9e38907fb3..4460118b322 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -117,9 +117,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () expect(callCount).toBe(1); expect(results).toHaveLength(agentCount); for (const result of results) { - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("cross-agent-refreshed-access"); - expect(result?.provider).toBe(provider); + expect(result).toMatchObject({ + apiKey: "cross-agent-refreshed-access", + provider, + }); } }, 10_000); }); diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index 673f4a0cf3a..12800417176 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -186,9 +186,10 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { // fresh main credentials are used read-through without copying the refresh token. const result = await resolveFromSecondaryAgent(profileId); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("fresh-access-token"); - expect(result?.provider).toBe("anthropic"); + expect(result).toMatchObject({ + apiKey: "fresh-access-token", + provider: "anthropic", + }); // The secondary store keeps its local credential; inherited OAuth is read-through. const secondaryStore = JSON.parse( diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index aeb53c12e70..17ce8e2063e 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -224,7 +224,7 @@ describe("models-config", () => { provider: { baseUrl: "https://api.copilot.example", models: [] }, }); - expectCopilotProviderFromPlan(plan).toEqual({ + expect(expectCopilotProviderFromPlan(plan)).toEqual({ baseUrl: "https://api.copilot.example", models: [], }); @@ -235,7 +235,7 @@ describe("models-config", () => { provider: { baseUrl: "https://api.individual.githubcopilot.com", models: [] }, }); - expectCopilotProviderFromPlan(plan)?.toEqual({ + expect(expectCopilotProviderFromPlan(plan)).toEqual({ baseUrl: "https://api.individual.githubcopilot.com", models: [], }); @@ -272,7 +272,6 @@ function expectCopilotProviderFromPlan( ? (JSON.parse(plan.contents) as { providers?: Record }) : {}; const provider = parsed.providers?.["github-copilot"]; - expect(typeof provider).toBe("object"); - expect(provider).not.toBeNull(); - return expect(provider); + expect(provider).toEqual(expect.any(Object)); + return provider; } diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index d47669a02f1..0354707ec11 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -511,7 +511,7 @@ describe("compaction-safeguard runtime registry", () => { it("clears entry when value is null", () => { const sm = {}; setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 }); - expect(getCompactionSafeguardRuntime(sm)).not.toBeNull(); + expect(getCompactionSafeguardRuntime(sm)).toEqual({ maxHistoryShare: 0.7 }); setCompactionSafeguardRuntime(sm, null); expect(getCompactionSafeguardRuntime(sm)).toBeNull(); }); From b856b3f51c7d1659a44240fd1b46b16fa7bc3c1f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:59:00 +0100 Subject: [PATCH 299/806] test: tighten dreaming empty state assertion --- ui/src/ui/views/dreaming.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 07c5c446ba3..5c1bb53fcf3 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -497,7 +497,7 @@ describe("dreaming view", () => { setDreamSubTab("diary"); setDreamDiarySubTab("dreams"); const emptyContainer = renderInto(buildProps({ dreamDiaryContent: null })); - expect(emptyContainer.querySelector(".dreams-diary__empty")).not.toBeNull(); + expect(emptyContainer.querySelectorAll(".dreams-diary__empty")).toHaveLength(1); expect(emptyContainer.querySelector(".dreams-diary__empty-text")?.textContent).toContain( "No dreams yet", ); From 954d20ece2de0fba3688f7800613183fbeb9685c Mon Sep 17 00:00:00 2001 From: the sun gif man Date: Fri, 8 May 2026 16:59:53 +0200 Subject: [PATCH 300/806] fix: allow Nix store plugin hardlinks (#79344) Merged via squash. Prepared head SHA: bf533f8654bc7975feddc2a3ed8e33890d6e3017 Co-authored-by: Codex Reviewed-by: @joshp123 --- CHANGELOG.md | 1 + src/gateway/server-startup-early.test.ts | 2 +- src/plugins/channel-catalog-registry.ts | 10 +- src/plugins/discovery.ts | 32 ++++- src/plugins/hardlink-policy.test.ts | 51 +++++++ src/plugins/hardlink-policy.ts | 38 ++++++ src/plugins/loader.ts | 16 ++- src/plugins/manifest-registry.test.ts | 25 ++++ src/plugins/manifest-registry.ts | 8 +- .../channel-contract-api.external.test.ts | 125 ++++++++++++------ src/secrets/channel-contract-api.ts | 10 +- 11 files changed, 263 insertions(+), 55 deletions(-) create mode 100644 src/plugins/hardlink-policy.test.ts create mode 100644 src/plugins/hardlink-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c94d7c930..ba782663dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,7 @@ Docs: https://docs.openclaw.ai - Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. +- Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Infra/fetch-timeout: pass `operation` and `url` context to `buildTimeoutAbortSignal` from the music-generate reference fetch and the Matrix guarded redirect transport, so the `fetch timeout reached; aborting operation` warning carries actionable structured fields instead of a bare line. Fixes #79195. Thanks @pandadev66. diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts index 547851db9ba..44dff5751ff 100644 --- a/src/gateway/server-startup-early.test.ts +++ b/src/gateway/server-startup-early.test.ts @@ -50,7 +50,7 @@ describe("startGatewayEarlyRuntime", () => { chatRunBuffers: new Map(), chatDeltaSentAt: new Map(), chatDeltaLastBroadcastLen: new Map(), - removeChatRun: () => {}, + removeChatRun: () => undefined, agentRunSeq: new Map(), nodeSendToSession: () => {}, skillsRefreshDelayMs: 30_000, diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index 6fa47fe70a1..8b336e2ea3d 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,5 +1,6 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { loadPluginManifest, @@ -45,7 +46,14 @@ export function listChannelCatalogEntries( if (!channel?.id) { return []; } - const manifest = loadPluginManifest(candidate.rootDir, candidate.origin !== "bundled"); + const manifest = loadPluginManifest( + candidate.rootDir, + shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env: params.env, + }), + ); if (!manifest.ok) { return []; } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 11e5091ad2b..c01d73b1fd6 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -29,6 +29,7 @@ import { resolvePackageRuntimeExtensionSources, resolvePackageSetupSource, } from "./package-entry-resolution.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -571,6 +572,7 @@ function addCandidate(params: { function discoverBundleInRoot(params: { rootDir: string; origin: PluginOrigin; + env: NodeJS.ProcessEnv; ownershipUid?: number | null; workspaceDir?: string; manifest?: PackageManifest | null; @@ -584,11 +586,17 @@ function discoverBundleInRoot(params: { return "none"; } const rootRealPath = safeRealpathSync(params.rootDir, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: params.rootDir, + env: params.env, + realpathCache: params.realpathCache, + }); const bundleManifest = loadBundleManifest({ rootDir: params.rootDir, ...(rootRealPath !== undefined ? { rootRealPath } : {}), bundleFormat, - rejectHardlinks: params.origin !== "bundled", + rejectHardlinks, }); if (!bundleManifest.ok) { params.diagnostics.push({ @@ -620,6 +628,7 @@ function discoverBundleInRoot(params: { function discoverInDirectory(params: { dir: string; origin: PluginOrigin; + env: NodeJS.ProcessEnv; ownershipUid?: number | null; workspaceDir?: string; candidates: PluginCandidate[]; @@ -684,8 +693,13 @@ function discoverInDirectory(params: { continue; } - const rejectHardlinks = params.origin !== "bundled"; const fullPathRealPath = safeRealpathSync(fullPath, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: fullPath, + env: params.env, + realpathCache: params.realpathCache, + }); const manifest = readCandidatePackageManifest({ dir: fullPath, origin: params.origin, @@ -745,6 +759,7 @@ function discoverInDirectory(params: { const bundleDiscovery = discoverBundleInRoot({ rootDir: fullPath, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, @@ -890,8 +905,13 @@ function discoverFromPath(params: { } if (stat.isDirectory()) { - const rejectHardlinks = params.origin !== "bundled"; const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: resolved, + env: params.env, + realpathCache: params.realpathCache, + }); const manifest = readCandidatePackageManifest({ dir: resolved, origin: params.origin, @@ -951,6 +971,7 @@ function discoverFromPath(params: { const bundleDiscovery = discoverBundleInRoot({ rootDir: resolved, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, @@ -989,6 +1010,7 @@ function discoverFromPath(params: { discoverInDirectory({ dir: resolved, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, candidates: params.candidates, @@ -1062,6 +1084,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.workspace, origin: "workspace", + env, ownershipUid: params.ownershipUid, workspaceDir: workspaceRoot, candidates: result.candidates, @@ -1114,6 +1137,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.stock, origin: "bundled", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, @@ -1131,6 +1155,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: sourceCheckoutExtensionsDir, origin: "bundled", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, @@ -1157,6 +1182,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.global, origin: "global", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, diff --git a/src/plugins/hardlink-policy.test.ts b/src/plugins/hardlink-policy.test.ts new file mode 100644 index 00000000000..cf90a2e4172 --- /dev/null +++ b/src/plugins/hardlink-policy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { isNixStorePluginRoot, shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; + +const nixEnv: NodeJS.ProcessEnv = { OPENCLAW_NIX_MODE: "1" }; + +describe("plugin hardlink policy", () => { + it("does not reject bundled plugin files", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "bundled", + rootDir: "/tmp/plugin", + env: {}, + }), + ).toBe(false); + }); + + it("rejects hardlinked external plugin files by default", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/tmp/plugin", + env: {}, + }), + ).toBe(true); + }); + + it("does not treat OPENCLAW_NIX_MODE as enough by itself", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/tmp/plugin", + env: nixEnv, + }), + ).toBe(true); + }); + + it.runIf(process.platform !== "win32")( + "does not reject hardlinked external plugin files when Nix mode loads from the Nix store", + () => { + expect(isNixStorePluginRoot("/nix/store/abc-openclaw-plugin")).toBe(true); + expect(isNixStorePluginRoot("/tmp/nix/store/abc-openclaw-plugin")).toBe(false); + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/nix/store/abc-openclaw-plugin", + env: nixEnv, + }), + ).toBe(false); + }, + ); +}); diff --git a/src/plugins/hardlink-policy.ts b/src/plugins/hardlink-policy.ts new file mode 100644 index 00000000000..c1f7b72561b --- /dev/null +++ b/src/plugins/hardlink-policy.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { resolveIsNixMode } from "../config/paths.js"; +import { safeRealpathSync } from "./path-safety.js"; +import type { PluginOrigin } from "./plugin-origin.types.js"; + +const NIX_STORE_ROOT = "/nix/store"; + +// Hardlinks are rejected for user/config/workspace plugin roots by default. A +// hardlinked file can appear to live under a plugin root while sharing an inode +// with a file created elsewhere, which weakens the root-boundary checks used +// before loading plugin code. +// +// Two roots are allowed: +// - bundled: plugins shipped with OpenClaw itself, not user-installed code. +// - /nix/store in OPENCLAW_NIX_MODE: immutable Nix package outputs, where +// hardlinked files are normal package-store layout rather than user mutation. +export function isNixStorePluginRoot( + rootDir: string, + realpathCache?: Map, +): boolean { + const rootRealPath = safeRealpathSync(rootDir, realpathCache) ?? path.resolve(rootDir); + return rootRealPath === NIX_STORE_ROOT || rootRealPath.startsWith(`${NIX_STORE_ROOT}/`); +} + +export function shouldRejectHardlinkedPluginFiles(params: { + origin: PluginOrigin; + rootDir: string; + env?: NodeJS.ProcessEnv; + realpathCache?: Map; +}): boolean { + if (params.origin === "bundled") { + return false; + } + if (resolveIsNixMode(params.env) && isNixStorePluginRoot(params.rootDir, params.realpathCache)) { + return false; + } + return true; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3edcdf65cdc..7b90f48367c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -49,6 +49,7 @@ import { } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; import { toSafeImportPath } from "./import-specifier.js"; import { collectPluginManifestCompatCodes } from "./installed-plugin-index-record-builder.js"; @@ -2002,11 +2003,16 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi : runtimeCandidateEntry; const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadEntry.source); const moduleRoot = resolveCanonicalDistRuntimeSource(loadEntry.rootDir); + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + }); const opened = openRootFileSync({ absolutePath: moduleLoadSource, rootPath: moduleRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks, skipLexicalRootCheck: true, }); if (!opened.ok) { @@ -2097,7 +2103,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi absolutePath: runtimeModuleSource, rootPath: runtimeModuleRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks, skipLexicalRootCheck: true, }); if (!runtimeOpened.ok) { @@ -2678,7 +2684,11 @@ export async function loadOpenClawPluginCliRegistry( absolutePath: sourceForCliMetadata, rootPath: pluginRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks: shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + }), skipLexicalRootCheck: true, }); if (!opened.ok) { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index b0f7829fbaf..9a519bc1236 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -2227,6 +2227,31 @@ describe("loadPluginManifestRegistry", () => { expectUnsafeWorkspaceManifestRejected({ id: "unsafe-hardlink", mode: "hardlink" }); }); + it("still rejects config manifest hardlinks outside the Nix store in Nix mode", () => { + if (process.platform === "win32") { + return; + } + const fixture = prepareLinkedManifestFixture({ + id: "unsafe-config-hardlink", + mode: "hardlink", + }); + if (!fixture.linked) { + return; + } + const registry = loadPluginManifestRegistry({ + env: hermeticEnv({ OPENCLAW_NIX_MODE: "1" }), + candidates: [ + createPluginCandidate({ + idHint: "unsafe-config-hardlink", + rootDir: fixture.rootDir, + origin: "config", + }), + ], + }); + expect(registry.plugins).toHaveLength(0); + expect(hasUnsafeManifestDiagnostic(registry)).toBe(true); + }); + it("allows bundled manifest paths that are hardlinked aliases", () => { if (process.platform === "win32") { return; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 08aadabc5ee..0ed2bad75ee 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -11,6 +11,7 @@ import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfigWithResolver } from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; import type { @@ -843,7 +844,12 @@ export function loadPluginManifestRegistry( const currentHostVersion = resolveCompatibilityHostVersion(env); for (const candidate of candidates) { - const rejectHardlinks = candidate.origin !== "bundled"; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + realpathCache, + }); const isBundleRecord = (candidate.format ?? "openclaw") === "bundle"; const manifestRes: | ReturnType diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 8fff506a62c..1f58fe08091 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -5,15 +5,19 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-help const tempDirs: string[] = []; -const { loadPluginMetadataSnapshotMock, loadBundledPluginPublicArtifactModuleSyncMock } = - vi.hoisted(() => ({ - loadPluginMetadataSnapshotMock: vi.fn(), - loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => { - throw new Error( - "Unable to resolve bundled plugin public surface discord/secret-contract-api.js", - ); - }), - })); +const { + loadPluginMetadataSnapshotMock, + loadBundledPluginPublicArtifactModuleSyncMock, + shouldRejectHardlinkedPluginFilesMock, +} = vi.hoisted(() => ({ + loadPluginMetadataSnapshotMock: vi.fn(), + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => { + throw new Error( + "Unable to resolve bundled plugin public surface discord/secret-contract-api.js", + ); + }), + shouldRejectHardlinkedPluginFilesMock: vi.fn(() => true), +})); vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, @@ -23,20 +27,21 @@ vi.mock("../plugins/public-surface-loader.js", () => ({ loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, })); +vi.mock("../plugins/hardlink-policy.js", () => ({ + shouldRejectHardlinkedPluginFiles: shouldRejectHardlinkedPluginFilesMock, +})); + import { loadChannelSecretContractApi } from "./channel-contract-api.js"; -function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) { - const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs); - fs.writeFileSync( - path.join(rootDir, "secret-contract-api.cjs"), - ` +function channelSecretContractModuleSource(channelId: string) { + return ` module.exports = { secretTargetRegistryEntries: [ { - id: "channels.${params.channelId}.token", - targetType: "channels.${params.channelId}.token", + id: "channels.${channelId}.token", + targetType: "channels.${channelId}.token", configFile: "openclaw.json", - pathPattern: "channels.${params.channelId}.token", + pathPattern: "channels.${channelId}.token", secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, @@ -46,14 +51,21 @@ module.exports = { ], collectRuntimeConfigAssignments(params) { params.context.assignments.push({ - path: "channels.${params.channelId}.token", + path: "channels.${channelId}.token", ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, expected: "string", apply() {} }); } }; -`, +`; +} + +function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs); + fs.writeFileSync( + path.join(rootDir, "secret-contract-api.cjs"), + channelSecretContractModuleSource(params.channelId), "utf8", ); return { @@ -69,6 +81,8 @@ describe("external channel secret contract api", () => { beforeEach(() => { loadPluginMetadataSnapshotMock.mockReset(); loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + shouldRejectHardlinkedPluginFilesMock.mockReset(); + shouldRejectHardlinkedPluginFilesMock.mockReturnValue(true); }); afterEach(() => { @@ -103,31 +117,7 @@ describe("external channel secret contract api", () => { fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true }); fs.writeFileSync( path.join(rootDir, "dist", "secret-contract-api.cjs"), - ` -module.exports = { - secretTargetRegistryEntries: [ - { - id: "channels.discord.token", - targetType: "channels.discord.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.token", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true - } - ], - collectRuntimeConfigAssignments(params) { - params.context.assignments.push({ - path: "channels.discord.token", - ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - expected: "string", - apply() {} - }); - } -}; -`, + channelSecretContractModuleSource("discord"), "utf8", ); const record = { @@ -158,6 +148,53 @@ module.exports = { expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); + it.runIf(process.platform !== "win32")( + "loads hardlinked external channel contracts when the plugin hardlink policy allows them", + () => { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract-hardlink", tempDirs); + const outsideDir = makeTrackedTempDir( + "openclaw-channel-secret-contract-hardlink-outside", + tempDirs, + ); + const outsideContractPath = path.join(outsideDir, "secret-contract-api.cjs"); + fs.writeFileSync(outsideContractPath, channelSecretContractModuleSource("discord"), "utf8"); + fs.linkSync(outsideContractPath, path.join(rootDir, "secret-contract-api.cjs")); + shouldRejectHardlinkedPluginFilesMock.mockReturnValue(false); + + const record = { + id: "discord", + origin: "global", + channels: ["discord"], + channelConfigs: {}, + rootDir, + }; + const env = { OPENCLAW_NIX_MODE: "1" }; + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env, + loadablePluginOrigins: new Map([["discord", "global"]]), + }); + + expect(shouldRejectHardlinkedPluginFilesMock).toHaveBeenCalledWith({ + origin: "global", + rootDir, + env, + }); + expect(api?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channels.discord.token", + }), + ]), + ); + }, + ); + it("skips external channel records outside the loadable plugin origin set", () => { const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); loadPluginMetadataSnapshotMock.mockReturnValue({ diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 58741519fff..a47f000b515 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; +import { shouldRejectHardlinkedPluginFiles } from "../plugins/hardlink-policy.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { @@ -117,6 +118,7 @@ function loadPluginContractModule(modulePath: string): BundledChannelContractApi function loadExternalChannelSecretContractFromRecord( record: PluginManifestRecord, + env: NodeJS.ProcessEnv = process.env, ): BundledChannelSecretContractApi | undefined { const contractPath = resolvePluginContractApiPath(record.rootDir); if (!contractPath) { @@ -126,7 +128,11 @@ function loadExternalChannelSecretContractFromRecord( absolutePath: contractPath, rootPath: record.rootDir, boundaryLabel: "plugin root", - rejectHardlinks: record.origin !== "bundled", + rejectHardlinks: shouldRejectHardlinkedPluginFiles({ + origin: record.origin, + rootDir: record.rootDir, + env, + }), skipLexicalRootCheck: true, }); if (!opened.ok) { @@ -209,7 +215,7 @@ export function loadChannelSecretContractApi(params: { env, loadablePluginOrigins: params.loadablePluginOrigins, })) { - const contract = loadExternalChannelSecretContractFromRecord(record); + const contract = loadExternalChannelSecretContractFromRecord(record, env); if (contract) { return contract; } From bbf536c0c65b3b1a6a4deba731f98ac3e2507c38 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:59:59 +0100 Subject: [PATCH 301/806] test: tighten agents preview assertion --- ui/src/ui/views/agents.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index aa2ff0ff36c..a166f65de1f 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -383,7 +383,9 @@ describe("renderAgentFiles", () => { container, ); - expect(container.querySelector(".md-preview-dialog__reader.sidebar-markdown")).not.toBeNull(); + expect(container.querySelectorAll(".md-preview-dialog__reader.sidebar-markdown")).toHaveLength( + 1, + ); expect(container.querySelector(".md-preview-dialog__path")?.textContent?.trim()).toBe( "USER.md", ); From ca2c00bd7b35d2d32f0f7370ea0c8f0462fc81b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:59:55 +0100 Subject: [PATCH 302/806] test: tighten gateway helper assertions --- src/gateway/call.test.ts | 2 +- src/gateway/gateway-cli-backend.live-helpers.test.ts | 8 ++++---- src/gateway/session-utils.fs.test.ts | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index fab3f0b26da..e0cc2b8029c 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -963,7 +963,7 @@ describe("callGateway error details", () => { }); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5); - expect(lastClientOptions).not.toBeNull(); + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(startCalls).toBe(0); }); diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 5a0eb4e88bb..3b54cbdd91b 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,10 +88,10 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(typeof client).toBe("object"); - expect(client).not.toBeNull(); - expect(typeof (client as { start?: unknown }).start).toBe("function"); - expect(typeof (client as { stopAndWait?: unknown }).stopAndWait).toBe("function"); + expect(client).toMatchObject({ + start: expect.any(Function), + stopAndWait: expect.any(Function), + }); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 7a3b7bd9fce..792a78cd0e2 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -2056,9 +2056,7 @@ describe("oversized transcript line guards", () => { 512 * 1024, ); - expect(usage).not.toBeNull(); - expect(usage?.modelProvider).not.toBe("oversized-provider"); - expect(usage?.modelProvider).toBe("test-provider"); + expect(usage).toMatchObject({ modelProvider: "test-provider" }); }); test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => { From 2b6704dedcf75e2b8a9152ebf910b45733783076 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:00:52 +0100 Subject: [PATCH 303/806] test: tighten agents panel assertions --- .../agents-panels-tools-skills.browser.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index 8a64ae7833e..6f6406c369f 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -164,11 +164,11 @@ describe("agents tools panel (browser)", () => { const group = container.querySelector(".agent-tools-group"); const tool = container.querySelector(".agent-tool-card"); - expect(group).not.toBeNull(); - expect(tool).not.toBeNull(); + expect(group?.classList.contains("agent-tools-group")).toBe(true); + expect(tool?.classList.contains("agent-tool-card")).toBe(true); if (!group || !tool) { - return; + throw new Error("expected agent tool group and card"); } group.open = true; @@ -319,13 +319,13 @@ describe("agents tools panel (browser)", () => { '.agent-tools-runtime-chip[href="#agent-tool-read"]', ); - expect(group).not.toBeNull(); - expect(tool).not.toBeNull(); - expect(chip).not.toBeNull(); + expect(group?.classList.contains("agent-tools-group")).toBe(true); + expect(tool?.classList.contains("agent-tool-card")).toBe(true); + expect(chip?.getAttribute("href")).toBe("#agent-tool-read"); if (!group || !tool || !chip) { container.remove(); - return; + throw new Error("expected agent tool runtime chip"); } expect(group.open).toBe(false); From add9b8920e6bd6e621806a1d9f3617e46f21b3fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:02:20 +0100 Subject: [PATCH 304/806] test: clear nullable matcher scan --- .../tools/image-tool.ollama.live.test.ts | 7 ++++-- src/agents/xai.live.test.ts | 3 +-- .../android-node.capabilities.live.test.ts | 25 +++++++++++-------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/agents/tools/image-tool.ollama.live.test.ts b/src/agents/tools/image-tool.ollama.live.test.ts index 341deaa5095..3656aa71927 100644 --- a/src/agents/tools/image-tool.ollama.live.test.ts +++ b/src/agents/tools/image-tool.ollama.live.test.ts @@ -80,9 +80,12 @@ describe.skipIf(!LIVE)("image tool Ollama live", () => { }, }; const tool = createImageTool({ config: cfg, agentDir, workspaceDir }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); + if (!tool) { + throw new Error("expected image tool"); + } - const result = await tool!.execute("live-ollama-image", { + const result = await tool.execute("live-ollama-image", { prompt: "Describe this image in one short sentence.", image: imagePath, }); diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index 0eb973b196f..69f7311ab90 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -130,8 +130,7 @@ describeLive("xai live", () => { : []; expect(payloadTools.length).toBeGreaterThan(0); const firstFunction = payloadTools[0]?.function; - expect(firstFunction).not.toBeNull(); - expect(typeof firstFunction).toBe("object"); + expect(firstFunction).toEqual(expect.any(Object)); expect([undefined, false]).toContain((firstFunction as Record).strict); }); }, 90_000); diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index bd295aa97d8..4bad8ff82b6 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -47,8 +47,7 @@ function asRecord(value: unknown): Record { } function expectRecord(value: unknown, label: string): Record { - expect(typeof value, label).toBe("object"); - expect(value, label).not.toBeNull(); + expect(value, label).toEqual(expect.any(Object)); expect(Array.isArray(value), label).toBe(false); return value as Record; } @@ -57,6 +56,12 @@ function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function expectNonEmptyString(value: unknown, label: string): string { + const text = readString(value); + expect(text, label).toEqual(expect.any(String)); + return text as string; +} + function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -121,8 +126,8 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("canvas.snapshot", payload); - expect(readString(obj.format)).not.toBeNull(); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.format, "canvas.snapshot format"); + expectNonEmptyString(obj.base64, "canvas.snapshot base64"); }, }, "canvas.a2ui.push": { @@ -155,7 +160,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.snap", payload); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.base64, "camera.snap base64"); }, }, "camera.clip": { @@ -164,7 +169,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.clip", payload); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.base64, "camera.clip base64"); }, }, "location.get": { @@ -189,8 +194,8 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("device.info", payload); - expect(readString(obj.systemName)).not.toBeNull(); - expect(readString(obj.systemVersion)).not.toBeNull(); + expectNonEmptyString(obj.systemName, "device.info systemName"); + expectNonEmptyString(obj.systemVersion, "device.info systemVersion"); }, }, "device.permissions": { @@ -249,7 +254,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.logs", payload); - expect(readString(obj.logs)).not.toBeNull(); + expectNonEmptyString(obj.logs, "debug.logs logs"); }, }, "debug.ed25519": { @@ -258,7 +263,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.ed25519", payload); - expect(readString(obj.diagnostics)).not.toBeNull(); + expectNonEmptyString(obj.diagnostics, "debug.ed25519 diagnostics"); }, }, }; From 8f44dc7da8294cd6a2af287191fd9f239390d181 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:02:49 +0100 Subject: [PATCH 305/806] test: tighten chat view assertions --- ui/src/ui/views/chat.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 9ee6647e5a6..023edd0fa95 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -445,7 +445,7 @@ describe("chat loading skeleton", () => { it("shows the skeleton while the initial history load has no rendered content", () => { const container = renderChatView({ loading: true }); - expect(container.querySelector(".chat-loading-skeleton")).not.toBeNull(); + expect(container.querySelectorAll(".chat-loading-skeleton")).toHaveLength(1); expect(container.querySelector(".agent-chat__welcome")).toBeNull(); }); @@ -492,7 +492,7 @@ describe("chat voice controls", () => { it("keeps Talk visible without the stale browser dictation button", () => { const container = renderChatView(); - expect(container.querySelector('[aria-label="Start Talk"]')).not.toBeNull(); + expect(container.querySelectorAll('[aria-label="Start Talk"]')).toHaveLength(1); expect(container.querySelector('[aria-label="Voice input"]')).toBeNull(); }); @@ -676,7 +676,7 @@ describe("chat attachment picker", () => { const nextAttachments = onAttachmentsChange.mock.calls[0]?.[0] ?? []; expect(getChatAttachmentDataUrl(nextAttachments[0])).toMatch(/^data:application\/pdf;base64,/); const preview = renderChatView({ attachments: nextAttachments }); - expect(preview.querySelector(".chat-attachment-thumb--file")).not.toBeNull(); + expect(preview.querySelectorAll(".chat-attachment-thumb--file")).toHaveLength(1); expect(preview.textContent).toContain("brief.pdf"); }); @@ -769,7 +769,6 @@ describe("chat welcome", () => { let container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null }); const avatar = container.querySelector(".agent-chat__avatar"); - expect(avatar).not.toBeNull(); expect(avatar?.tagName).toBe("DIV"); expect(avatar?.textContent).toContain("VC"); expect(avatar?.getAttribute("aria-label")).toBe("Val"); @@ -780,7 +779,6 @@ describe("chat welcome", () => { }); const imageAvatar = container.querySelector("img"); - expect(imageAvatar).not.toBeNull(); expect(imageAvatar?.getAttribute("src")).toBe("blob:identity-avatar"); expect(imageAvatar?.getAttribute("alt")).toBe("Val"); @@ -789,7 +787,6 @@ describe("chat welcome", () => { const fallbackAvatar = container.querySelector( ".agent-chat__avatar--logo img", ); - expect(fallbackAvatar).not.toBeNull(); expect(fallbackAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); expect(fallbackAvatar?.getAttribute("alt")).toBe("Val"); }); From 01c057cc40bfca533b5cd0307150cc83ebe41f98 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:03:52 +0100 Subject: [PATCH 306/806] test: dedupe exec approval modal assertions --- ui/src/ui/views/exec-approval.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts index d916219f861..886422e7fb6 100644 --- a/ui/src/ui/views/exec-approval.test.ts +++ b/ui/src/ui/views/exec-approval.test.ts @@ -242,7 +242,6 @@ describe("approval and confirmation modals", () => { ); const { dialog } = await getRenderedDialog(); - expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); dispatchEscape(dialog); @@ -263,7 +262,6 @@ describe("approval and confirmation modals", () => { ); const { dialog } = await getRenderedDialog(); - expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); dispatchEscape(dialog); From f8e1bafca70692b13d84305b9e0f16b940f60124 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:04:42 +0100 Subject: [PATCH 307/806] test: tighten grouped delete assertions --- ui/src/ui/chat/grouped-render.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 7398ffd95bc..40559189626 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -320,7 +320,7 @@ function renderDeleteConfirmFixture() { { onDelete }, ); const deleteButton = container.querySelector(".chat-group-delete"); - expect(deleteButton).not.toBeNull(); + expect(deleteButton).toBeInstanceOf(HTMLButtonElement); return { container, deleteButton: deleteButton!, onDelete }; } @@ -347,7 +347,7 @@ function setupArmedDeleteConfirm() { const outsideClickListener = getLastCaptureClickListener(addListenerSpy.mock.calls); expect(outsideClickListener).not.toBeNull(); - expect(fixture.container.querySelector(".chat-delete-confirm")).not.toBeNull(); + expect(fixture.container.querySelectorAll(".chat-delete-confirm")).toHaveLength(1); return { ...fixture, outsideClickListener, removeListenerSpy }; } @@ -451,25 +451,23 @@ describe("grouped chat rendering", () => { const userDeleteButton = container.querySelector( ".chat-group.user .chat-group-delete", ); - expect(userDeleteButton).not.toBeNull(); - userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(userDeleteButton).toBeInstanceOf(HTMLButtonElement); + userDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); const userConfirm = container.querySelector( ".chat-group.user .chat-delete-confirm", ); - expect(userConfirm).not.toBeNull(); expect(userConfirm?.classList.contains("chat-delete-confirm--left")).toBe(true); const assistantDeleteButton = container.querySelector( ".chat-group.assistant .chat-group-delete", ); - expect(assistantDeleteButton).not.toBeNull(); - assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(assistantDeleteButton).toBeInstanceOf(HTMLButtonElement); + assistantDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); const assistantConfirm = container.querySelector( ".chat-group.assistant .chat-delete-confirm", ); - expect(assistantConfirm).not.toBeNull(); expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); }); @@ -479,8 +477,8 @@ describe("grouped chat rendering", () => { ".chat-delete-confirm__cancel", ); - expect(cancel).not.toBeNull(); - cancel?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cancel).toBeInstanceOf(HTMLButtonElement); + cancel!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expectDeleteConfirmDismissed(fixture); expect(fixture.onDelete).not.toHaveBeenCalled(); @@ -490,8 +488,8 @@ describe("grouped chat rendering", () => { const fixture = setupArmedDeleteConfirm(); const confirm = fixture.container.querySelector(".chat-delete-confirm__yes"); - expect(confirm).not.toBeNull(); - confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(confirm).toBeInstanceOf(HTMLButtonElement); + confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expectDeleteConfirmDismissed(fixture); expect(fixture.onDelete).toHaveBeenCalledTimes(1); From dbda4782fb075346b3fb18c76cca2272571625f1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:05:21 +0100 Subject: [PATCH 308/806] test: tighten grouped metadata assertions --- ui/src/ui/chat/grouped-render.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 40559189626..a62db5061e3 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -563,7 +563,6 @@ describe("grouped chat rendering", () => { 1_000_000, ); const meta = cached.querySelector("details.msg-meta"); - expect(meta).not.toBeNull(); expect(meta?.open).toBe(false); expect(meta?.querySelector("summary")?.textContent).toContain("Context"); expect(cached.querySelector(".msg-meta__ctx")?.textContent).toBe("44% ctx"); @@ -620,7 +619,6 @@ describe("grouped chat rendering", () => { const time = container.querySelector(".chat-group-timestamp"); const display = formatChatTimestampForDisplay(timestamp); - expect(time).not.toBeNull(); expect(time?.dateTime).toBe(display.dateTime); expect(time?.textContent?.trim()).toBe(display.label); expect(time?.getAttribute("title")).toBe(display.title); From c011300dd4e73b8ece0aa7baff483ca00ad0209b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:04:48 +0100 Subject: [PATCH 309/806] test: tighten config form control assertions --- ui/src/ui/config-form.browser.test.ts | 92 +++++++++++++++------------ 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 563f18df43f..92600faf699 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -34,6 +34,14 @@ const rootSchema = { }; const rootAnalysis = analyzeConfigSchema(rootSchema); +function expectElement(element: T | null | undefined, label: string): T { + expect(element, label).toEqual(expect.any(Element)); + if (!element) { + throw new Error(`missing ${label}`); + } + return element; +} + describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); @@ -53,48 +61,51 @@ describe("config form renderer", () => { container, ); - const tokenInput: HTMLInputElement | null = container.querySelector( - '#config-section-gateway input.cfg-input[type="text"]', + const tokenInput = expectElement( + container.querySelector( + '#config-section-gateway input.cfg-input[type="text"]', + ), + "gateway token input", ); - expect(tokenInput).not.toBeNull(); - if (!tokenInput) { - return; - } tokenInput.value = "abc123"; tokenInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123"); - const tokenButton = Array.from( - container.querySelectorAll(".cfg-segmented__btn"), - ).find((btn) => btn.textContent?.trim() === "token"); - expect(tokenButton).not.toBeUndefined(); - tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const tokenButton = expectElement( + Array.from(container.querySelectorAll(".cfg-segmented__btn")).find( + (btn) => btn.textContent?.trim() === "token", + ), + "token segmented button", + ); + tokenButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); - const checkbox: HTMLInputElement | null = container.querySelector("input[type='checkbox']"); - expect(checkbox).not.toBeNull(); - if (!checkbox) { - return; - } + const checkbox = expectElement( + container.querySelector("input[type='checkbox']"), + "enabled checkbox", + ); checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["enabled"], true); - const addButton = container.querySelector(".cfg-array__add"); - expect(addButton).not.toBeUndefined(); - addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const addButton = expectElement(container.querySelector(".cfg-array__add"), "array add button"); + addButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]); - const removeButton = container.querySelector(".cfg-array__item-remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-array__item-remove"), + "array remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); - const tailnetButton = Array.from( - container.querySelectorAll(".cfg-segmented__btn"), - ).find((btn) => btn.textContent?.trim() === "tailnet"); - expect(tailnetButton).not.toBeUndefined(); - tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const tailnetButton = expectElement( + Array.from(container.querySelectorAll(".cfg-segmented__btn")).find( + (btn) => btn.textContent?.trim() === "tailnet", + ), + "tailnet segmented button", + ); + tailnetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); @@ -168,9 +179,11 @@ describe("config form renderer", () => { container, ); - const removeButton = container.querySelector(".cfg-map__item-remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-map__item-remove"), + "map remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["slack"], {}); }); @@ -324,13 +337,12 @@ describe("config form renderer", () => { container, ); - const apiKeyInput: HTMLInputElement | null = container.querySelector( - "#config-section-models .cfg-map__item-value input.cfg-input[type='text']", + const apiKeyInput = expectElement( + container.querySelector( + "#config-section-models .cfg-map__item-value input.cfg-input[type='text']", + ), + "provider api key input", ); - expect(apiKeyInput).not.toBeNull(); - if (!apiKeyInput) { - return; - } apiKeyInput.value = "new-key"; apiKeyInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["models", "providers", "openai", "apiKey"], "new-key"); @@ -404,9 +416,11 @@ describe("config form renderer", () => { container, ); - const removeButton = container.querySelector(".cfg-map__item-remove"); - expect(removeButton).not.toBeNull(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-map__item-remove"), + "accounts remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["accounts"], {}); }); }); From c0f8eda4ab047475355536c0d892d198dfc2d453 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:06:18 +0100 Subject: [PATCH 310/806] test: fail hard on missing avatar input --- ui/src/ui/views/config-quick.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 624653570d5..281b873c50c 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -310,18 +310,15 @@ describe("renderQuickSettings", () => { const input = Array.from(container.querySelectorAll('input[type="file"]')).find( (node) => !node.closest(".qs-identity-card--assistant"), ) as HTMLInputElement | null; - expect(input).not.toBeNull(); - if (!input) { - return; - } + expect(input).toBeInstanceOf(HTMLInputElement); const file = new File([new Uint8Array(1_500_001)], "avatar.png", { type: "image/png" }); - Object.defineProperty(input, "files", { + Object.defineProperty(input!, "files", { configurable: true, value: [file], }); - input.dispatchEvent(new Event("change")); + input!.dispatchEvent(new Event("change")); expect(fileReader).not.toHaveBeenCalled(); expect(onUserAvatarChange).not.toHaveBeenCalled(); From 54b625e76129fb47c192c7ee28db704949033a06 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:07:13 +0100 Subject: [PATCH 311/806] test: tighten run controls dom counts --- ui/src/ui/chat/run-controls.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 9e1c8df57e5..abbea1923d8 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -285,7 +285,7 @@ describe("context notice", () => { expect(icon?.classList.contains("context-notice__icon")).toBe(true); expect(icon?.getAttribute("width")).toBe("16"); expect(icon?.getAttribute("height")).toBe("16"); - expect(icon?.querySelector("path")).not.toBeNull(); + expect(icon?.querySelectorAll("path")).toHaveLength(1); const onCompact = vi.fn(); render(renderContextNotice(session, 200_000, { onCompact }), container); @@ -365,6 +365,6 @@ describe("side result render", () => { container, ); - expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); + expect(container.querySelectorAll(".chat-side-result--error")).toHaveLength(1); }); }); From c8af77a28004f2e46f31594b1a0c223967f9d28c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:08:04 +0100 Subject: [PATCH 312/806] test: tighten config browser assertions --- ui/src/ui/views/config.browser.test.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index d2512cb4be9..c0e8686d766 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -225,7 +225,7 @@ describe("config view", () => { let { clearButton, applyButton } = findActionButtons(container); expect(busyButton.disabled).toBe(true); expect(busyButton.getAttribute("aria-busy")).toBe("true"); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); expect(clearButton?.disabled).toBe(false); expect(applyButton?.disabled).toBe(false); @@ -233,14 +233,14 @@ describe("config view", () => { busyButton = findButtonContainingText(container, "Applying…"); ({ clearButton } = findActionButtons(container)); expect(busyButton.disabled).toBe(true); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); expect(clearButton?.disabled).toBe(false); renderCase({ updating: true }); busyButton = findButtonContainingText(container, "Updating…"); ({ clearButton } = findActionButtons(container)); expect(busyButton.disabled).toBe(true); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); expect(clearButton?.disabled).toBe(false); }); @@ -402,12 +402,10 @@ describe("config view", () => { expect(icon?.closest(".config-search__input-row")).not.toBeNull(); const input = container.querySelector(".config-search__input"); - expect(input).not.toBeNull(); - if (!input) { - return; - } - (input as HTMLInputElement).value = "gateway"; - input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input).toBeInstanceOf(HTMLInputElement); + const searchInput = input as HTMLInputElement; + searchInput.value = "gateway"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onSearchChange).toHaveBeenCalledWith("gateway"); }); From 4d385e70650df90634cb599ec58afa129190b9fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:08:57 +0100 Subject: [PATCH 313/806] test: require ui navigation controls --- ui/src/ui/lazy-view.browser.test.ts | 18 ++++-- ui/src/ui/navigation.browser.test.ts | 85 +++++++++++++++------------- 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/ui/src/ui/lazy-view.browser.test.ts b/ui/src/ui/lazy-view.browser.test.ts index f33bf7fb898..66b662cbd38 100644 --- a/ui/src/ui/lazy-view.browser.test.ts +++ b/ui/src/ui/lazy-view.browser.test.ts @@ -7,6 +7,17 @@ async function flushPromises() { await Promise.resolve(); } +function expectButtonWithText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; +} + describe("lazy view rendering", () => { it("renders a loading panel until the view module resolves", async () => { const onChange = vi.fn(); @@ -52,11 +63,8 @@ describe("lazy view rendering", () => { expect(container.textContent).toContain("Panel failed to load"); expect(container.textContent).toContain("chunk 404"); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Retry", - ); - expect(retry).not.toBeUndefined(); - retry?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const retry = expectButtonWithText(container, "Retry"); + retry.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await flushPromises(); render( renderLazyView(view, (mod) => mod.label), diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index f807bec34e9..09edb15c3c5 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -13,16 +13,33 @@ function nextFrame() { }); } -function findConfirmButton(app: ReturnType) { - return Array.from(app.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Confirm", +function expectElement( + app: ReturnType, + selector: string, + constructor: new () => T, +): T { + const element = app.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; +} + +function expectButtonWithText(app: ReturnType, text: string): HTMLButtonElement { + const button = Array.from(app.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; } async function confirmPendingGatewayChange(app: ReturnType) { - const confirmButton = findConfirmButton(app); - expect(confirmButton).not.toBeUndefined(); - confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const confirmButton = expectButtonWithText(app, "Confirm"); + confirmButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; } @@ -48,13 +65,14 @@ describe("control UI routing", () => { const app = mountApp("/channels"); await app.updateComplete; - const breadcrumb = app.querySelector( + const breadcrumb = expectElement( + app, "dashboard-header .dashboard-header__breadcrumb-link", + HTMLAnchorElement, ); - expect(breadcrumb).toBeInstanceOf(HTMLAnchorElement); - expect(breadcrumb?.getAttribute("href")).toBe("/overview"); + expect(breadcrumb.getAttribute("href")).toBe("/overview"); - breadcrumb?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + breadcrumb.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; expect(app.tab).toBe("overview"); @@ -65,11 +83,12 @@ describe("control UI routing", () => { const app = mountApp("/ui/channels"); await app.updateComplete; - const breadcrumb = app.querySelector( + const breadcrumb = expectElement( + app, "dashboard-header .dashboard-header__breadcrumb-link", + HTMLAnchorElement, ); - expect(breadcrumb).toBeInstanceOf(HTMLAnchorElement); - expect(breadcrumb?.getAttribute("href")).toBe("/ui/overview"); + expect(breadcrumb.getAttribute("href")).toBe("/ui/overview"); }); it("renders the dreaming view on the /dreaming route", async () => { @@ -295,17 +314,13 @@ describe("control UI routing", () => { app.requestUpdate(); await app.updateComplete; - const toggle = app.querySelector(".dreams__phase-toggle--on"); - expect(toggle).not.toBeNull(); - toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const toggle = expectElement(app, ".dreams__phase-toggle--on", HTMLButtonElement); + toggle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; expect(request).not.toHaveBeenCalledWith("config.patch", expect.anything()); - const confirmRestart = Array.from(app.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Confirm Restart", - ); - expect(confirmRestart).not.toBeUndefined(); - confirmRestart?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const confirmRestart = expectButtonWithText(app, "Confirm Restart"); + confirmRestart.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await nextFrame(); await app.updateComplete; @@ -410,9 +425,8 @@ describe("control UI routing", () => { expect(nav.classList.contains("shell-nav")).toBe(true); expect(toggle.getAttribute("aria-expanded")).toBe("true"); - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const link = expectElement(app, 'a.nav-item[href="/channels"]', HTMLAnchorElement); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("channels"); @@ -489,7 +503,7 @@ describe("control UI routing", () => { expect(app.chatMobileControlsOpen).toBe(false); expect(closedDropdown?.classList.contains("open")).toBe(false); - app.querySelector(".chat-controls-mobile-toggle")?.click(); + expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement).click(); await app.updateComplete; expect(app.chatMobileControlsOpen).toBe(true); @@ -502,9 +516,8 @@ describe("control UI routing", () => { const app = mountApp("/sessions?session=agent:main:subagent:task-123"); await app.updateComplete; - const link = app.querySelector('a.nav-item[href="/chat"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const link = expectElement(app, 'a.nav-item[href="/chat"]', HTMLAnchorElement); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("chat"); @@ -516,16 +529,14 @@ describe("control UI routing", () => { expect(shell).not.toBeNull(); expect(shell?.classList.contains("shell--chat-focus")).toBe(false); - const toggle = app.querySelector('button[title^="Toggle focus mode"]'); - expect(toggle).not.toBeNull(); - toggle?.click(); + const toggle = expectElement(app, 'button[title^="Toggle focus mode"]', HTMLButtonElement); + toggle.click(); await app.updateComplete; expect(shell?.classList.contains("shell--chat-focus")).toBe(true); - const channelsLink = app.querySelector('a.nav-item[href="/channels"]'); - expect(channelsLink).not.toBeNull(); - channelsLink?.dispatchEvent( + const channelsLink = expectElement(app, 'a.nav-item[href="/channels"]', HTMLAnchorElement); + channelsLink.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), ); @@ -533,10 +544,8 @@ describe("control UI routing", () => { expect(app.tab).toBe("channels"); expect(shell?.classList.contains("shell--chat-focus")).toBe(false); - const chatLink = app.querySelector('a.nav-item[href="/chat"]'); - chatLink?.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), - ); + const chatLink = expectElement(app, 'a.nav-item[href="/chat"]', HTMLAnchorElement); + chatLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("chat"); From a1f80a4c8238a7402358dac7d864b87bb8ce41bf Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:09:06 +0100 Subject: [PATCH 314/806] test: tighten chat control assertions --- ui/src/ui/views/chat.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 023edd0fa95..cefb77beea0 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -518,14 +518,14 @@ describe("chat voice controls", () => { describe("chat slash menu accessibility", () => { function inputDraft(container: HTMLElement, value: string) { const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); + expect(textarea).toBeInstanceOf(HTMLTextAreaElement); textarea!.value = value; textarea!.dispatchEvent(new Event("input", { bubbles: true })); } function keydownComposer(container: HTMLElement, key: string) { const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); + expect(textarea).toBeInstanceOf(HTMLTextAreaElement); textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); } @@ -656,12 +656,12 @@ describe("chat attachment picker", () => { const input = container.querySelector(".agent-chat__file-input"); const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); Object.defineProperty(input!, "files", { configurable: true, value: [file], }); - input?.dispatchEvent(new Event("change", { bubbles: true })); + input!.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => { expect(onAttachmentsChange).toHaveBeenCalledWith([ @@ -686,12 +686,12 @@ describe("chat attachment picker", () => { const input = container.querySelector(".agent-chat__file-input"); const file = new File(["video"], "clip.mp4", { type: "video/mp4" }); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); Object.defineProperty(input!, "files", { configurable: true, value: [file], }); - input?.dispatchEvent(new Event("change", { bubbles: true })); + input!.dispatchEvent(new Event("change", { bubbles: true })); expect(onAttachmentsChange).not.toHaveBeenCalled(); }); @@ -868,7 +868,7 @@ describe("chat session controls", () => { const agentSelect = container.querySelector( 'select[data-chat-agent-filter="true"]', ); - expect(agentSelect).not.toBeNull(); + expect(agentSelect).toBeInstanceOf(HTMLSelectElement); agentSelect!.value = "beta"; agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); @@ -888,7 +888,7 @@ describe("chat session controls", () => { expect(notice?.getAttribute("role")).toBe("status"); expect(notice?.getAttribute("aria-live")).toBe("polite"); expect(notice?.textContent?.trim()).toBe("Switched to Coding"); - expect(container.querySelector(".chat-controls__session-row--flash")).not.toBeNull(); + expect(container.querySelectorAll(".chat-controls__session-row--flash")).toHaveLength(1); }); it("shows the active agent main session instead of a blank select when no row exists yet", () => { From 65f72255fdb40665e6765f24dc6e0405db535a81 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:09:59 +0100 Subject: [PATCH 315/806] test: tighten allowed values assertions --- src/config/allowed-values.test.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/config/allowed-values.test.ts b/src/config/allowed-values.test.ts index f62b95dae9b..addb8d2fe33 100644 --- a/src/config/allowed-values.test.ts +++ b/src/config/allowed-values.test.ts @@ -4,24 +4,16 @@ import { summarizeAllowedValues } from "./allowed-values.js"; describe("summarizeAllowedValues", () => { it("does not collapse mixed-type entries that stringify similarly", () => { const summary = summarizeAllowedValues([1, "1", 1, "1"]); - expect(summary).not.toBeNull(); - if (!summary) { - return; - } - expect(summary.hiddenCount).toBe(0); - expect(summary.formatted).toContain('1, "1"'); - expect(summary.values).toHaveLength(2); + expect(summary?.hiddenCount).toBe(0); + expect(summary?.formatted).toContain('1, "1"'); + expect(summary?.values).toHaveLength(2); }); it("keeps distinct long values even when labels truncate the same way", () => { const prefix = "a".repeat(200); const summary = summarizeAllowedValues([`${prefix}x`, `${prefix}y`]); - expect(summary).not.toBeNull(); - if (!summary) { - return; - } - expect(summary.hiddenCount).toBe(0); - expect(summary.values).toHaveLength(2); - expect(summary.values[0]).not.toBe(summary.values[1]); + expect(summary?.hiddenCount).toBe(0); + expect(summary?.values).toHaveLength(2); + expect(summary?.values[0]).not.toBe(summary?.values[1]); }); }); From cfdcd730bff17e32bed18bf112a3dfea0a4fc37b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:11:00 +0100 Subject: [PATCH 316/806] test: dedupe discord allowlist guards --- extensions/discord/src/monitor.test.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 540c1bea257..fba3440c173 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -38,6 +38,18 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; +function expectNormalizedAllowList( + entries: string[], + prefixes: string[], +): NonNullable> { + const allow = normalizeDiscordAllowList(entries, prefixes); + expect(allow).not.toBeNull(); + if (!allow) { + throw new Error("Expected allow list to be normalized"); + } + return allow; +} + const makeEntries = ( entries: Record>, ): Record => { @@ -226,14 +238,10 @@ describe("discord allowlist helpers", () => { }); it("matches ids by default and names only when enabled", () => { - const allow = normalizeDiscordAllowList( + const allow = expectNormalizedAllowList( ["123", "steipete", "Friends of OpenClaw"], ["discord:", "user:", "guild:", "channel:"], ); - expect(allow).not.toBeNull(); - if (!allow) { - throw new Error("Expected allow list to be normalized"); - } expect(allowListMatches(allow, { id: "123" })).toBe(true); expect(allowListMatches(allow, { name: "steipete" })).toBe(false); expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false); @@ -245,11 +253,7 @@ describe("discord allowlist helpers", () => { }); it("matches pk-prefixed allowlist entries", () => { - const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]); - expect(allow).not.toBeNull(); - if (!allow) { - throw new Error("Expected allow list to be normalized"); - } + const allow = expectNormalizedAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]); expect(allowListMatches(allow, { id: "member-123" })).toBe(true); expect(allowListMatches(allow, { id: "member-999" })).toBe(false); }); From a9ea60db5d01c39282a422dc40dbad9070865680 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:11:06 +0100 Subject: [PATCH 317/806] test: require config view action buttons --- ui/src/ui/views/config.browser.test.ts | 125 +++++++++++++------------ 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index c0e8686d766..ffdbf604fc9 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -76,6 +76,17 @@ describe("config view", () => { }; } + function requireActionButton( + button: HTMLButtonElement | undefined, + text: string, + ): HTMLButtonElement { + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${text} action button`); + } + return button; + } + function renderConfigView(overrides: Partial = {}): { container: HTMLElement; props: ConfigProps; @@ -153,10 +164,11 @@ describe("config view", () => { formMode: "form", formValue: { mixed: "x" }, }); - let { saveButton, applyButton } = findActionButtons(container); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + let actionButtons = findActionButtons(container); + let saveButton = requireActionButton(actionButtons.saveButton, "Save"); + let applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(saveButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); renderCase({ schema: null, @@ -164,24 +176,24 @@ describe("config view", () => { formValue: { gateway: { mode: "local" } }, originalValue: {}, }); - ({ saveButton, applyButton } = findActionButtons(container)); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(true); - expect(applyButton?.disabled).toBe(true); + actionButtons = findActionButtons(container); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(saveButton.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); renderCase({ formMode: "raw", raw: "{\n}\n", originalRaw: "{\n}\n", }); - let clearButton: HTMLButtonElement | undefined; - ({ clearButton, saveButton, applyButton } = findActionButtons(container)); - expect(clearButton).not.toBeUndefined(); - expect(saveButton).not.toBeUndefined(); - expect(applyButton).not.toBeUndefined(); - expect(clearButton?.disabled).toBe(true); - expect(saveButton?.disabled).toBe(true); - expect(applyButton?.disabled).toBe(true); + actionButtons = findActionButtons(container); + let clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(clearButton.disabled).toBe(true); + expect(saveButton.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); const onReset = vi.fn(); renderCase({ @@ -190,14 +202,15 @@ describe("config view", () => { originalRaw: "{\n}\n", onReset, }); - ({ clearButton, saveButton, applyButton } = findActionButtons(container)); - expect(saveButton).not.toBeUndefined(); - expect(applyButton).not.toBeUndefined(); - expect(clearButton?.disabled).toBe(false); - expect(saveButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(clearButton.disabled).toBe(false); + expect(saveButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); - clearButton?.click(); + clearButton.click(); expect(onReset).toHaveBeenCalledTimes(1); }); @@ -222,26 +235,30 @@ describe("config view", () => { renderCase({ saving: true }); let busyButton = findButtonContainingText(container, "Saving…"); - let { clearButton, applyButton } = findActionButtons(container); + let actionButtons = findActionButtons(container); + let clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + let applyButton = requireActionButton(actionButtons.applyButton, "Apply"); expect(busyButton.disabled).toBe(true); expect(busyButton.getAttribute("aria-busy")).toBe("true"); expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); - expect(clearButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + expect(clearButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); renderCase({ applying: true }); busyButton = findButtonContainingText(container, "Applying…"); - ({ clearButton } = findActionButtons(container)); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); expect(busyButton.disabled).toBe(true); expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); - expect(clearButton?.disabled).toBe(false); + expect(clearButton.disabled).toBe(false); renderCase({ updating: true }); busyButton = findButtonContainingText(container, "Updating…"); - ({ clearButton } = findActionButtons(container)); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); expect(busyButton.disabled).toBe(true); expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); - expect(clearButton?.disabled).toBe(false); + expect(clearButton.disabled).toBe(false); }); it("switches mode via the sidebar toggle", () => { @@ -281,14 +298,10 @@ describe("config view", () => { originalValue: { gateway: { mode: "local" } }, }); - const formButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Form", - ); - const rawButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Raw", - ); - expect(formButton?.classList.contains("active")).toBe(true); - expect(rawButton?.disabled).toBe(true); + const formButton = findButtonByText(container, "Form"); + const rawButton = findButtonByText(container, "Raw"); + expect(formButton.classList.contains("active")).toBe(true); + expect(rawButton.disabled).toBe(true); const rawNotice = container.querySelector(".config-actions__notice"); const actionButtons = container.querySelector(".config-actions__buttons"); expect(rawNotice).not.toBeNull(); @@ -300,7 +313,7 @@ describe("config view", () => { ); expect(container.querySelector(".config-raw-field")).toBeNull(); - rawButton?.click(); + rawButton.click(); expect(onFormModeChange).not.toHaveBeenCalled(); }); @@ -935,16 +948,14 @@ describe("config view", () => { onOpenCustomThemeImport, }); - const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Import", - ); + const customButton = findButtonByText(container, "Import"); - expect(customButton?.disabled).toBe(false); + expect(customButton.disabled).toBe(false); expect(normalizedText(container)).toContain( "Click Import to add one browser-local tweakcn theme", ); - customButton?.click(); + customButton.click(); expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1); }); @@ -957,11 +968,9 @@ describe("config view", () => { customThemeImportFocusToken: 1, }); - const importButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Import theme"), - ); + const importButton = findButtonContainingText(container, "Import theme"); - expect(importButton?.disabled).toBe(true); + expect(importButton.disabled).toBe(true); expect(container.querySelector(".settings-theme-import__input")).not.toBeNull(); expect( container.querySelector(".settings-theme-import__external")?.href, @@ -987,21 +996,15 @@ describe("config view", () => { onCustomThemeImportUrlChange, }); - const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Light Green", - ); - expect(customButton?.disabled).toBe(false); - customButton?.click(); + const customButton = findButtonByText(container, "Light Green"); + expect(customButton.disabled).toBe(false); + customButton.click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); - const replaceButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Replace Light Green"), - ); - const clearButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Clear Light Green"), - ); - replaceButton?.click(); - clearButton?.click(); + const replaceButton = findButtonContainingText(container, "Replace Light Green"); + const clearButton = findButtonContainingText(container, "Clear Light Green"); + replaceButton.click(); + clearButton.click(); expect(onImportCustomTheme).toHaveBeenCalledTimes(1); expect(onClearCustomTheme).toHaveBeenCalledTimes(1); From 9ecb7fd5e9b58fc6a5b97087d3b42f986bf5fb66 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:11:46 +0100 Subject: [PATCH 318/806] test: tighten line group context assertions --- extensions/line/src/bot-message-context.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/extensions/line/src/bot-message-context.test.ts b/extensions/line/src/bot-message-context.test.ts index 2ea9a261aef..632f7315205 100644 --- a/extensions/line/src/bot-message-context.test.ts +++ b/extensions/line/src/bot-message-context.test.ts @@ -107,13 +107,9 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - if (!context) { - throw new Error("context missing"); - } - expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1"); - expect(context.ctxPayload.To).toBe("line:group:group-1"); + expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-1"); + expect(context?.ctxPayload.To).toBe("line:group:group-1"); }); it("routes group postback replies to the group id", async () => { From 5534233b0812aff56d62529b67e67cd8eadcf8a9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:12:59 +0100 Subject: [PATCH 319/806] test: tighten qa channel media context assertion --- extensions/qa-channel/src/channel.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 7164fa12c7b..2f237f0a252 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -382,17 +382,13 @@ describe("qa-channel plugin", () => { timeoutMs: 15_000, }); - expect(dispatchedCtx).not.toBeNull(); - if (!dispatchedCtx) { - throw new Error("expected dispatched context"); - } const mediaCtx: { MediaPath?: string; MediaPaths?: string[]; MediaType?: string; MediaTypes?: string[]; - } = dispatchedCtx; - expect(mediaCtx.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); + } | null = dispatchedCtx; + expect(mediaCtx?.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); expect(mediaCtx.MediaType).toBe("image/png"); expect(mediaCtx.MediaPaths).toEqual([mediaCtx.MediaPath]); expect(mediaCtx.MediaTypes).toEqual(["image/png"]); From 57d987a55f230bdd8103583e803cea5f5db5f973 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:13:59 +0100 Subject: [PATCH 320/806] test: require config raw controls --- ui/src/ui/views/config.browser.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index ffdbf604fc9..ba58592d059 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -529,14 +529,11 @@ describe("config view", () => { revealButton.click(); const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); + expect(textarea).toBeInstanceOf(HTMLTextAreaElement); expect(textarea?.value).toContain("supersecret"); - if (!textarea) { - return; - } - textarea.value = textarea.value.replace("supersecret", "updatedsecret"); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - expect(onRawChange).toHaveBeenCalledWith(textarea.value); + textarea!.value = textarea!.value.replace("supersecret", "updatedsecret"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + expect(onRawChange).toHaveBeenCalledWith(textarea!.value); }); it("opens raw pending changes without sending a fake raw edit", () => { @@ -860,7 +857,7 @@ describe("config view", () => { }); const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); expect(input?.readOnly).toBe(true); expect(input?.value).toBe(""); expect(input?.placeholder).toContain("Structured value (SecretRef)"); @@ -926,7 +923,7 @@ describe("config view", () => { }); const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); expect(input?.readOnly).toBe(false); expect(input?.value).toContain("malformed"); expect(input?.value).not.toBe("[object Object]"); From 7bad53eca01f59c0d31a3fc422a34750a0a861d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:13:58 +0100 Subject: [PATCH 321/806] test: require cron view action elements --- ui/src/ui/views/cron.test.ts | 129 ++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 6cbb3add085..701bfa0345c 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -77,10 +77,39 @@ function createProps(overrides: Partial = {}): CronProps { }; } -function getButtonByText(container: Element, text: string) { - return Array.from(container.querySelectorAll("button")).find( +function getButtonByText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( (btn) => btn.textContent?.trim() === text, ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; +} + +function getButtonByAnyText(container: Element, texts: string[]): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find((btn) => + texts.includes(btn.textContent?.trim() ?? ""), + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text ${texts.join(" or ")}`); + } + return button; +} + +function getElement( + container: Element, + selector: string, + constructor: new () => T, +): T { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; } describe("cron view", () => { @@ -165,9 +194,12 @@ describe("cron view", () => { container, ); - const reset = container.querySelector('button[data-test-id="cron-jobs-filters-reset"]'); - expect(reset).not.toBeNull(); - reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const reset = getElement( + container, + 'button[data-test-id="cron-jobs-filters-reset"]', + HTMLButtonElement, + ); + reset.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); @@ -202,16 +234,18 @@ describe("cron view", () => { const selected = container.querySelector(".list-item-selected"); expect(selected).not.toBeNull(); - const row = container.querySelector(".list-item-clickable"); - expect(row).not.toBeNull(); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = getElement(container, ".list-item-clickable", HTMLElement); + row.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledWith("job-1"); const historyButton = Array.from(container.querySelectorAll("button")).find( (btn) => btn.textContent?.trim() === "History", ); - expect(historyButton).not.toBeUndefined(); - historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(historyButton).toBeInstanceOf(HTMLButtonElement); + if (!(historyButton instanceof HTMLButtonElement)) { + throw new Error("Expected History button"); + } + historyButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledTimes(2); expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); @@ -228,11 +262,14 @@ describe("cron view", () => { const runHistoryCard = cards.find( (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", ); - expect(runHistoryCard).not.toBeUndefined(); + expect(runHistoryCard).toBeInstanceOf(Element); + if (!(runHistoryCard instanceof Element)) { + throw new Error("Expected run history card"); + } - const summaries = Array.from( - runHistoryCard?.querySelectorAll(".cron-run-entry__body") ?? [], - ).map((el) => (el.textContent ?? "").trim()); + const summaries = Array.from(runHistoryCard.querySelectorAll(".cron-run-entry__body")).map( + (el) => (el.textContent ?? "").trim(), + ); expect(summaries[0]).toBe("newer run"); expect(summaries[1]).toBe("older run"); }); @@ -288,9 +325,13 @@ describe("cron view", () => { render(renderCron(expandedProps), container); - const collapseButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); - expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); - collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const collapseButton = getElement( + container, + '[data-test-id="cron-form-collapse-toggle"]', + HTMLButtonElement, + ); + expect(collapseButton.getAttribute("aria-expanded")).toBe("true"); + collapseButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenCalledWith(true); expect(container.querySelector(".cron-form")).not.toBeNull(); @@ -303,14 +344,18 @@ describe("cron view", () => { render(renderCron(collapsedProps), container); - const collapsedButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); + const collapsedButton = getElement( + container, + '[data-test-id="cron-form-collapse-toggle"]', + HTMLButtonElement, + ); expect(container.querySelectorAll(".cron-workspace--form-collapsed")).toHaveLength(1); expect(container.querySelectorAll(".cron-workspace-form--collapsed")).toHaveLength(1); - expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false"); + expect(collapsedButton.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".cron-form")?.hasAttribute("hidden")).toBe(true); expect(container.querySelector(".cron-form-actions")?.hasAttribute("hidden")).toBe(true); - collapsedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + collapsedButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenLastCalledWith(false); }); @@ -386,17 +431,17 @@ describe("cron view", () => { container, ); - const prompt = container.querySelector(".cron-job-detail-value.chat-text"); - expect(prompt?.querySelector("strong")?.textContent).toBe("Ship"); - expect(prompt?.querySelector("a")?.getAttribute("href")).toBe("https://example.com"); - expect(prompt?.querySelector("script")).toBeNull(); + const prompt = getElement(container, ".cron-job-detail-value.chat-text", HTMLElement); + expect(prompt.querySelector("strong")?.textContent).toBe("Ship"); + expect(prompt.querySelector("a")?.getAttribute("href")).toBe("https://example.com"); + expect(prompt.querySelector("script")).toBeNull(); - const promptLink = prompt?.querySelector("a"); - promptLink?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const promptLink = getElement(prompt, "a", HTMLAnchorElement); + promptLink.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).not.toHaveBeenCalled(); - const row = container.querySelector(".cron-job"); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = getElement(container, ".cron-job", HTMLElement); + row.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledWith("job-md"); const runBody = container.querySelector(".cron-run-entry__body.chat-text"); @@ -474,8 +519,7 @@ describe("cron view", () => { ); const editButton = getButtonByText(container, "Edit"); - expect(editButton).not.toBeUndefined(); - editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + editButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onEdit).toHaveBeenCalledWith(job); expect(onLoadRuns).toHaveBeenCalledWith("job-3"); @@ -483,8 +527,7 @@ describe("cron view", () => { expect(container.textContent).toContain("Save changes"); const cancelButton = getButtonByText(container, "Cancel"); - expect(cancelButton).not.toBeUndefined(); - cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cancelButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onCancelEdit).toHaveBeenCalledTimes(1); }); @@ -597,11 +640,8 @@ describe("cron view", () => { expect(container.textContent).toContain("Can't add job yet"); expect(container.textContent).toContain("Fix 3 fields to continue."); - const saveButton = Array.from(container.querySelectorAll("button")).find((btn) => - ["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""), - ); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(true); + const saveButton = getButtonByAnyText(container, ["Add job", "Save changes"]); + expect(saveButton.disabled).toBe(true); render( renderCron( @@ -662,24 +702,19 @@ describe("cron view", () => { ); const cloneButton = getButtonByText(container, "Clone"); - expect(cloneButton).not.toBeUndefined(); - cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cloneButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const enableButton = getButtonByText(container, "Disable"); - expect(enableButton).not.toBeUndefined(); - enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + enableButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const runButton = getButtonByText(container, "Run"); - expect(runButton).not.toBeUndefined(); - runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const runDueButton = getButtonByText(container, "Run if due"); - expect(runDueButton).not.toBeUndefined(); - runDueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + runDueButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const removeButton = getButtonByText(container, "Remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClone).toHaveBeenCalledWith(actionJob); expect(onToggle).toHaveBeenCalledWith(actionJob, false); From ca34143a9d7bf2724ef35bf0cfb3c61946f0f7f3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:14:54 +0100 Subject: [PATCH 322/806] test: require cron filter controls --- ui/src/ui/views/cron.test.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 701bfa0345c..3b68354f2b3 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -145,13 +145,11 @@ describe("cron view", () => { expect(container.textContent).toContain("All delivery"); expect(container.textContent).not.toContain("multi-select"); - const statusOk = container.querySelector( + const statusOk = getElement( + container, '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', + HTMLInputElement, ); - expect(statusOk).not.toBeNull(); - if (!(statusOk instanceof HTMLInputElement)) { - return; - } statusOk.checked = true; statusOk.dispatchEvent(new Event("change", { bubbles: true })); @@ -160,25 +158,21 @@ describe("cron view", () => { expect(container.textContent).toContain("Due"); expect(container.textContent).not.toContain("Next 13"); - const scheduleSelect = container.querySelector( + const scheduleSelect = getElement( + container, 'select[data-test-id="cron-jobs-schedule-filter"]', + HTMLSelectElement, ); - expect(scheduleSelect).not.toBeNull(); - if (!(scheduleSelect instanceof HTMLSelectElement)) { - return; - } scheduleSelect.value = "cron"; scheduleSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsScheduleKindFilter: "cron" }); - const lastRunSelect = container.querySelector( + const lastRunSelect = getElement( + container, 'select[data-test-id="cron-jobs-last-status-filter"]', + HTMLSelectElement, ); - expect(lastRunSelect).not.toBeNull(); - if (!(lastRunSelect instanceof HTMLSelectElement)) { - return; - } lastRunSelect.value = "error"; lastRunSelect.dispatchEvent(new Event("change", { bubbles: true })); From 2f17faf4c79c1989ca5bd686ce8b9eaca1510481 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:16:41 +0100 Subject: [PATCH 323/806] test: tighten extension context assertions --- .../matrix/src/matrix/subagent-hooks.test.ts | 2 +- extensions/qa-channel/src/channel.test.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index 4f27fee8531..64fb89769c0 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -135,7 +135,7 @@ describe("handleMatrixSubagentSpawning", () => { fakeApi, makeSpawnEvent({ channel: " Matrix " }), ); - expect(result).not.toBeUndefined(); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("returns error when thread bindings are disabled", async () => { diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 2f237f0a252..836a253599b 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -21,6 +21,14 @@ function installQaChannelTestRegistry() { ); } +function expectDispatchedContext(ctx: Record | null): Record { + expect(ctx).not.toBeNull(); + if (ctx === null) { + throw new Error("Expected dispatched context"); + } + return ctx; +} + function createMockQaRuntime(params?: { onDispatch?: (ctx: Record) => void; }): PluginRuntime { @@ -382,13 +390,13 @@ describe("qa-channel plugin", () => { timeoutMs: 15_000, }); - const mediaCtx: { + const mediaCtx = expectDispatchedContext(dispatchedCtx) as { MediaPath?: string; MediaPaths?: string[]; MediaType?: string; MediaTypes?: string[]; - } | null = dispatchedCtx; - expect(mediaCtx?.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); + }; + expect(mediaCtx.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); expect(mediaCtx.MediaType).toBe("image/png"); expect(mediaCtx.MediaPaths).toEqual([mediaCtx.MediaPath]); expect(mediaCtx.MediaTypes).toEqual(["image/png"]); From 434a682677b134db16f91bedd0312b09a72b3f70 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:16:50 +0100 Subject: [PATCH 324/806] test: tighten node pairing token assertion --- src/infra/node-pairing.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index eb074a85297..91301e42cb9 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -27,11 +27,8 @@ async function setupPairedNode(baseDir: string): Promise { baseDir, ); const paired = await getPairedNode("node-1", baseDir); - expect(paired).not.toBeNull(); - if (!paired) { - throw new Error("expected node to be paired"); - } - return paired.token; + expect(paired?.token).toEqual(expect.any(String)); + return paired!.token; } const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" }); From 1c4a20d581c00bf707134512cdd8bb247d9bcb23 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:18:04 +0100 Subject: [PATCH 325/806] test: tighten memory cli json payload assertions --- extensions/memory-core/src/cli.test.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index eb22c05b394..f98ec42d6f2 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -865,12 +865,8 @@ describe("memory cli", () => { await runMemoryCli(["status", "--json"]); const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } expect(Array.isArray(payload)).toBe(true); - expect((payload[0] as Record)?.agentId).toBe("main"); + expect((payload?.[0] as Record)?.agentId).toBe("main"); expect(probeVectorAvailability).not.toHaveBeenCalled(); expect(probeEmbeddingAvailability).not.toHaveBeenCalled(); expect(close).toHaveBeenCalled(); @@ -885,10 +881,6 @@ describe("memory cli", () => { await runMemoryCli(["status", "--json"]); const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } expect(Array.isArray(payload)).toBe(true); expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); }); @@ -1000,12 +992,8 @@ describe("memory cli", () => { await runMemoryCli(["search", "hello", "--json"]); const payload = firstWrittenJsonArg<{ results: unknown[] }>(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload.results)).toBe(true); - expect(payload.results).toHaveLength(1); + expect(Array.isArray(payload?.results)).toBe(true); + expect(payload?.results).toHaveLength(1); expect(close).toHaveBeenCalled(); }); @@ -1062,12 +1050,8 @@ describe("memory cli", () => { ]); const payload = firstWrittenJsonArg<{ candidates: unknown[] }>(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload.candidates)).toBe(true); - expect(payload.candidates).toHaveLength(1); + expect(Array.isArray(payload?.candidates)).toBe(true); + expect(payload?.candidates).toHaveLength(1); expect(close).toHaveBeenCalled(); }); }); From ffcb7bf7a0c3f18fc1acb4633a2fbef7ba741a3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:18:15 +0100 Subject: [PATCH 326/806] test: require modal dialog helpers --- ui/src/ui/components/modal-dialog.test.ts | 16 +++++++++++----- ui/src/ui/views/exec-approval.test.ts | 16 +++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/ui/components/modal-dialog.test.ts index 1e3f52e93db..53a5d35f035 100644 --- a/ui/src/ui/components/modal-dialog.test.ts +++ b/ui/src/ui/components/modal-dialog.test.ts @@ -60,12 +60,18 @@ async function renderModal() { container, ); const modal = container.querySelector("openclaw-modal-dialog"); - expect(modal).not.toBeNull(); - await modal!.updateComplete; + expect(modal).toBeInstanceOf(HTMLElement); + if (!modal) { + throw new Error("Expected openclaw-modal-dialog"); + } + await modal.updateComplete; await nextFrame(); - const dialog = modal!.shadowRoot?.querySelector("dialog"); - expect(dialog).not.toBeNull(); - return { modal: modal!, dialog: dialog! }; + const dialog = modal.shadowRoot?.querySelector("dialog"); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected rendered dialog"); + } + return { modal, dialog }; } describe("openclaw-modal-dialog", () => { diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts index 886422e7fb6..c2ad7ad877e 100644 --- a/ui/src/ui/views/exec-approval.test.ts +++ b/ui/src/ui/views/exec-approval.test.ts @@ -50,12 +50,18 @@ function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDes async function getRenderedDialog() { const modal = container.querySelector("openclaw-modal-dialog"); - expect(modal).not.toBeNull(); - await modal!.updateComplete; + expect(modal).toBeInstanceOf(HTMLElement); + if (!modal) { + throw new Error("Expected openclaw-modal-dialog"); + } + await modal.updateComplete; await nextFrame(); - const dialog = modal!.shadowRoot?.querySelector("dialog"); - expect(dialog).not.toBeNull(); - return { modal: modal!, dialog: dialog! }; + const dialog = modal.shadowRoot?.querySelector("dialog"); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected rendered dialog"); + } + return { modal, dialog }; } function dispatchEscape(target: EventTarget) { From c6aad445e42f5f6dca7c0e3fdc61cd19eb821ed5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:19:39 +0100 Subject: [PATCH 327/806] test: require navigation chat containers --- ui/src/ui/navigation.browser.test.ts | 51 ++++++++++++---------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 09edb15c3c5..b95232c43e7 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -412,10 +412,7 @@ describe("control UI routing", () => { expect(toggle.getAttribute("aria-label")).toEqual(expect.stringMatching(/\S/u)); const nav = app.querySelector(".shell-nav"); - expect(nav).not.toBeNull(); - if (!nav) { - return; - } + expect(nav).toBeInstanceOf(HTMLElement); expect(shell.classList.contains("shell--nav-drawer-open")).toBe(false); toggle.click(); @@ -560,34 +557,32 @@ describe("control UI routing", () => { const app = mountApp("/chat"); await app.updateComplete; - const initialContainer: HTMLElement | null = app.querySelector(".chat-thread"); - expect(initialContainer).not.toBeNull(); - if (!initialContainer) { - return; - } - initialContainer.style.maxHeight = "180px"; - initialContainer.style.overflow = "auto"; + const initialContainer = app.querySelector(".chat-thread"); + expect(initialContainer).toBeInstanceOf(HTMLElement); + const initialThread = initialContainer!; + initialThread.style.maxHeight = "180px"; + initialThread.style.overflow = "auto"; let scrollTop = 0; - Object.defineProperty(initialContainer, "clientHeight", { + Object.defineProperty(initialThread, "clientHeight", { configurable: true, get: () => 180, }); - Object.defineProperty(initialContainer, "scrollHeight", { + Object.defineProperty(initialThread, "scrollHeight", { configurable: true, get: () => 2400, }); - Object.defineProperty(initialContainer, "scrollTop", { + Object.defineProperty(initialThread, "scrollTop", { configurable: true, get: () => scrollTop, set: (value: number) => { scrollTop = value; }, }); - initialContainer.scrollTo = ((options?: ScrollToOptions | number, y?: number) => { + initialThread.scrollTo = ((options?: ScrollToOptions | number, y?: number) => { const top = typeof options === "number" ? (y ?? 0) : typeof options?.top === "number" ? options.top : 0; scrollTop = Math.max(0, Math.min(top, 2400 - 180)); - }) as typeof initialContainer.scrollTo; + }) as typeof initialThread.scrollTo; app.chatMessages = Array.from({ length: 3 }, (_, index) => ({ role: "assistant", @@ -600,35 +595,33 @@ describe("control UI routing", () => { await nextFrame(); } - const container = app.querySelector(".chat-thread"); - expect(container).not.toBeNull(); - if (!container) { - return; - } + const container = app.querySelector(".chat-thread"); + expect(container).toBeInstanceOf(HTMLElement); + const thread = container!; let finalScrollTop = 0; - Object.defineProperty(container, "clientHeight", { + Object.defineProperty(thread, "clientHeight", { value: 180, configurable: true, }); - Object.defineProperty(container, "scrollHeight", { + Object.defineProperty(thread, "scrollHeight", { value: 960, configurable: true, }); - Object.defineProperty(container, "scrollTop", { + Object.defineProperty(thread, "scrollTop", { configurable: true, get: () => finalScrollTop, set: (value: number) => { finalScrollTop = value; }, }); - Object.defineProperty(container, "scrollTo", { + Object.defineProperty(thread, "scrollTo", { configurable: true, value: ({ top }: { top: number }) => { finalScrollTop = top; }, }); - const targetScrollTop = container.scrollHeight; - expect(targetScrollTop).toBeGreaterThan(container.clientHeight); + const targetScrollTop = thread.scrollHeight; + expect(targetScrollTop).toBeGreaterThan(thread.clientHeight); app.chatMessages = [ ...app.chatMessages, { @@ -639,12 +632,12 @@ describe("control UI routing", () => { ]; await app.updateComplete; for (let i = 0; i < 10; i++) { - if (container.scrollTop === targetScrollTop) { + if (thread.scrollTop === targetScrollTop) { break; } await nextFrame(); } - expect(container.scrollTop).toBe(targetScrollTop); + expect(thread.scrollTop).toBe(targetScrollTop); }); it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => { From 838b546778c0ccfdfb0beeb90a6938fc258c92d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:20:06 +0100 Subject: [PATCH 328/806] test: require chat control buttons --- ui/src/ui/app-render.helpers.browser.test.ts | 41 ++++++++++----- ui/src/ui/chat/run-controls.test.ts | 54 ++++++++++++-------- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index f267df681e8..e172eeeb9f7 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -63,8 +63,22 @@ function renderRefreshButton(overrides: Partial = {}) { const button = container.querySelector( `.chat-controls .btn--icon[data-tooltip="${t("chat.refreshTitle")}"]`, ); - expect(button).not.toBeNull(); - return button!; + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected chat refresh button"); + } + return button; +} + +function requireButton( + button: HTMLButtonElement | null | undefined, + label: string, +): HTMLButtonElement { + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${label} button`); + } + return button; } describe("chat header controls (browser)", () => { @@ -140,14 +154,12 @@ describe("chat header controls (browser)", () => { ); expect(buttons).toHaveLength(4); - const cronButton = buttons.at(-1); - expect(cronButton?.classList.contains("active")).toBe(true); - expect(cronButton?.getAttribute("aria-pressed")).toBe("true"); - expect(cronButton?.getAttribute("title")).toBe( - t("chat.showCronSessionsHidden", { count: "1" }), - ); + const cronButton = requireButton(buttons.at(-1), "cron sessions"); + expect(cronButton.classList.contains("active")).toBe(true); + expect(cronButton.getAttribute("aria-pressed")).toBe("true"); + expect(cronButton.getAttribute("title")).toBe(t("chat.showCronSessionsHidden", { count: "1" })); - cronButton?.click(); + cronButton.click(); expect(state.sessionsHideCron).toBe(false); }); @@ -202,14 +214,17 @@ describe("chat header controls (browser)", () => { render(renderChatMobileToggle(state), container); await Promise.resolve(); - const toggle = container.querySelector(".chat-controls-mobile-toggle"); + const toggle = requireButton( + container.querySelector(".chat-controls-mobile-toggle"), + "mobile controls toggle", + ); const dropdown = container.querySelector(".chat-controls-dropdown"); - expect(toggle?.getAttribute("aria-expanded")).toBe("false"); - expect(toggle?.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(toggle.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); expect(dropdown?.id).toBe("chat-mobile-controls-dropdown"); expect(dropdown?.classList.contains("open")).toBe(false); - toggle?.click(); + toggle.click(); expect(setChatMobileControlsOpen).toHaveBeenCalledWith(true, { trigger: toggle }); expect(dropdown?.classList.contains("open")).toBe(false); diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index abbea1923d8..61336933695 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -37,6 +37,15 @@ function createProps(overrides: Partial = {}): ChatRunCont }; } +function getButton(container: Element, selector: string): HTMLButtonElement { + const button = container.querySelector(selector); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button matching ${selector}`); + } + return button; +} + describe("chat run controls", () => { it("switches between idle and abort actions", () => { const container = document.createElement("div"); @@ -57,11 +66,11 @@ describe("chat run controls", () => { container, ); - const queueButton = container.querySelector('button[title="Queue"]'); - const stopButton = container.querySelector('button[title="Stop"]'); - expect(queueButton?.disabled).toBe(true); - expect(stopButton?.title).toBe("Stop"); - stopButton?.click(); + const queueButton = getButton(container, 'button[title="Queue"]'); + const stopButton = getButton(container, 'button[title="Stop"]'); + expect(queueButton.disabled).toBe(true); + expect(stopButton.title).toBe("Stop"); + stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("New session"); @@ -81,16 +90,14 @@ describe("chat run controls", () => { container, ); - const newSessionButton = container.querySelector( - 'button[title="New session"]', - ); - expect(newSessionButton?.title).toBe("New session"); - newSessionButton?.click(); + const newSessionButton = getButton(container, 'button[title="New session"]'); + expect(newSessionButton.title).toBe("New session"); + newSessionButton.click(); expect(onNewSession).toHaveBeenCalledTimes(1); - const sendButton = container.querySelector('button[title="Send"]'); - expect(sendButton?.title).toBe("Send"); - sendButton?.click(); + const sendButton = getButton(container, 'button[title="Send"]'); + expect(sendButton.title).toBe("Send"); + sendButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" run this "); expect(onSend).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("Stop"); @@ -112,9 +119,9 @@ describe("chat run controls", () => { container, ); - const queueButton = container.querySelector('button[title="Queue"]'); - expect(queueButton?.disabled).toBe(false); - queueButton?.click(); + const queueButton = getButton(container, 'button[title="Queue"]'); + expect(queueButton.disabled).toBe(false); + queueButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" follow up "); expect(onSend).toHaveBeenCalledTimes(1); }); @@ -133,9 +140,9 @@ describe("chat run controls", () => { container, ); - const stopButton = container.querySelector('button[title="Stop"]'); - expect(stopButton?.disabled).toBe(false); - stopButton?.click(); + const stopButton = getButton(container, 'button[title="Stop"]'); + expect(stopButton.disabled).toBe(false); + stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); }); }); @@ -290,7 +297,7 @@ describe("context notice", () => { const onCompact = vi.fn(); render(renderContextNotice(session, 200_000, { onCompact }), container); expect(container.textContent).toContain("Compact"); - container.querySelector(".context-notice__action")?.click(); + getButton(container, ".context-notice__action").click(); expect(onCompact).toHaveBeenCalledTimes(1); expect( @@ -348,8 +355,11 @@ describe("side result render", () => { expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); const button = container.querySelector(".chat-side-result__dismiss"); - expect(button).not.toBeNull(); - button?.click(); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected side result dismiss button"); + } + button.click(); expect(onDismissSideResult).toHaveBeenCalledTimes(1); render( From f56f1dd16170ea30fac18cc1c3e635db9e8ca749 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:20:20 +0100 Subject: [PATCH 329/806] test: tighten qa lab staged root assertion --- extensions/qa-lab/src/gateway-child.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 8157ccca89e..df644bb0d1b 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -972,11 +972,7 @@ describe("qa bundled plugin dir", () => { expect(stagedRoot).toBe( path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)), ); - expect(stagedRoot).not.toBeNull(); - if (!stagedRoot) { - throw new Error("expected staged runtime root"); - } - await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain( + await expect(readFile(path.join(stagedRoot!, "package.json"), "utf8")).resolves.toContain( '"name": "openclaw"', ); await expect( From d82500bd7be63ef04bb20758e02fba891e463bef Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:22:04 +0100 Subject: [PATCH 330/806] test: simplify plugin inspect guard --- src/plugins/status.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 06f6f3dbfa9..348e1f973ce 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -151,8 +151,7 @@ function expectInspectReport( pluginId: string, ): NonNullable> { const inspect = buildPluginInspectReport({ id: pluginId }); - expect(inspect).not.toBeNull(); - if (!inspect) { + if (inspect === null) { throw new Error(`expected inspect report for ${pluginId}`); } return inspect; From 7a39059dc0999f7dc0b3d9936503f6b3b701d3e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:22:11 +0100 Subject: [PATCH 331/806] test: tighten app stream lifecycle assertions --- ui/src/ui/app-tool-stream.node.test.ts | 18 +++++++++++++++--- ui/src/ui/navigation.browser.test.ts | 3 +-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index 6784d8a63e3..95dbad6a015 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -54,7 +54,11 @@ function expectCompactionCompleteAndAutoClears(host: MutableHost) { startedAt: expect.any(Number), completedAt: expect.any(Number), }); - expect(host.compactionClearTimer).not.toBeNull(); + expect(host.compactionClearTimer).toMatchObject({ + hasRef: expect.any(Function), + ref: expect.any(Function), + unref: expect.any(Function), + }); vi.advanceTimersByTime(5_000); expect(host.compactionStatus).toBeNull(); @@ -141,9 +145,17 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus).not.toBeNull(); + expect(host.fallbackStatus).toMatchObject({ + phase: "active", + selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + active: "deepinfra/moonshotai/Kimi-K2.5", + }); vi.advanceTimersByTime(7_999); - expect(host.fallbackStatus).not.toBeNull(); + expect(host.fallbackStatus).toMatchObject({ + phase: "active", + selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + active: "deepinfra/moonshotai/Kimi-K2.5", + }); vi.advanceTimersByTime(1); expect(host.fallbackStatus).toBeNull(); vi.useRealTimers(); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index b95232c43e7..da9a7a25d70 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -411,8 +411,7 @@ describe("control UI routing", () => { expect(actions.querySelector(".topbar-search")).not.toBeNull(); expect(toggle.getAttribute("aria-label")).toEqual(expect.stringMatching(/\S/u)); - const nav = app.querySelector(".shell-nav"); - expect(nav).toBeInstanceOf(HTMLElement); + const nav = expectElement(app, ".shell-nav", HTMLElement); expect(shell.classList.contains("shell--nav-drawer-open")).toBe(false); toggle.click(); From 41514e8393e7248f16316e49b835323b2c178c7b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:22:45 +0100 Subject: [PATCH 332/806] test: simplify discord allowlist helper --- extensions/discord/src/monitor.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index fba3440c173..87080e5b14e 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -43,8 +43,7 @@ function expectNormalizedAllowList( prefixes: string[], ): NonNullable> { const allow = normalizeDiscordAllowList(entries, prefixes); - expect(allow).not.toBeNull(); - if (!allow) { + if (allow === null) { throw new Error("Expected allow list to be normalized"); } return allow; From 007b366fb6ae10426b6558eff11c246fa441a1b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:23:32 +0100 Subject: [PATCH 333/806] test: require dreaming view elements --- ui/src/ui/views/dreaming.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 5c1bb53fcf3..8be2e836a44 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -197,12 +197,20 @@ function renderInto(props: DreamingProps): HTMLDivElement { return container; } +function expectElement(container: Element, selector: string): Element { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(Element); + if (!(element instanceof Element)) { + throw new Error(`Expected element matching ${selector}`); + } + return element; +} + describe("dreaming view", () => { it("renders the active dream scene chrome and status", () => { const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" })); - const svg = container.querySelector(".dreams__lobster svg"); - expect(svg).not.toBeNull(); + expectElement(container, ".dreams__lobster svg"); const zs = container.querySelectorAll(".dreams__z"); expect(zs.length).toBe(3); @@ -210,7 +218,7 @@ describe("dreaming view", () => { const stars = container.querySelectorAll(".dreams__star"); expect(stars.length).toBe(12); - expect(container.querySelector(".dreams__moon")).not.toBeNull(); + expectElement(container, ".dreams__moon"); const phases = [...container.querySelectorAll(".dreams__phase-name")].map((node) => node.textContent?.trim(), @@ -225,7 +233,7 @@ describe("dreaming view", () => { expect(buttons).not.toContain("Backfill"); expect(buttons).not.toContain("Reset"); expect(buttons).not.toContain("Clear Replayed"); - expect(container.querySelector(".dreams__bubble")).not.toBeNull(); + expectElement(container, ".dreams__bubble"); const text = container.querySelector(".dreams__bubble-text"); expect(text?.textContent).toBe("reindexing old chats\u2026"); const label = container.querySelector(".dreams__status-label"); @@ -243,7 +251,7 @@ describe("dreaming view", () => { const idleContainer = renderInto(buildProps({ active: false })); expect(idleContainer.querySelector(".dreams__bubble")).toBeNull(); expect(idleContainer.querySelector(".dreams__status-label")?.textContent).toBe("Dreaming Idle"); - expect(idleContainer.querySelector(".dreams--idle")).not.toBeNull(); + expectElement(idleContainer, ".dreams--idle"); const unknownPhaseContainer = renderInto(buildProps({ phases: undefined })); const statuses = [...unknownPhaseContainer.querySelectorAll(".dreams__phase-next")].map( @@ -375,8 +383,7 @@ describe("dreaming view", () => { const title = container.querySelector(".dreams-diary__title"); expect(title?.textContent).toContain("Dream Diary"); - const entry = container.querySelector(".dreams-diary__entry"); - expect(entry).not.toBeNull(); + expectElement(container, ".dreams-diary__entry"); const date = container.querySelector(".dreams-diary__date"); expect(date?.textContent).toContain("April 5, 2026"); const body = container.querySelector(".dreams-diary__para"); From 2e50223efa587b9bbf0ca5feac54c3602acb9089 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:23:43 +0100 Subject: [PATCH 334/806] test: require quick settings buttons --- ui/src/ui/views/config-quick.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 281b873c50c..4c10a1a6674 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -256,8 +256,8 @@ describe("renderQuickSettings", () => { const clear = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Clear override", ); - expect(clear?.textContent?.trim()).toBe("Clear override"); - clear?.dispatchEvent(new Event("click")); + expect(clear).toBeInstanceOf(HTMLButtonElement); + clear!.dispatchEvent(new Event("click")); expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); }); @@ -358,7 +358,8 @@ describe("renderQuickSettings", () => { const customButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Import", ); - customButton?.click(); + expect(customButton).toBeInstanceOf(HTMLButtonElement); + customButton!.click(); expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1); expect(setTheme).not.toHaveBeenCalled(); @@ -385,7 +386,8 @@ describe("renderQuickSettings", () => { const customButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Light Green", ); - customButton?.click(); + expect(customButton).toBeInstanceOf(HTMLButtonElement); + customButton!.click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); expect(onOpenCustomThemeImport).not.toHaveBeenCalled(); From fd443f8becccea11248cce0f4a1d9e316b21f01b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:24:26 +0100 Subject: [PATCH 335/806] test: require chat action buttons --- ui/src/ui/views/chat.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index cefb77beea0..4ff5a1fffed 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -427,7 +427,8 @@ describe("chat compaction divider", () => { const button = container.querySelector(".chat-divider__action"); expect(button?.textContent).toContain("Open checkpoints"); - button?.click(); + expect(button).toBeInstanceOf(HTMLButtonElement); + button!.click(); expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1); }); @@ -509,7 +510,9 @@ describe("chat voice controls", () => { 'Realtime voice provider "openai" is not configured', ); - container.querySelector('[aria-label="Dismiss error"]')?.click(); + const dismiss = container.querySelector('[aria-label="Dismiss error"]'); + expect(dismiss).toBeInstanceOf(HTMLButtonElement); + dismiss!.click(); expect(onDismissError).toHaveBeenCalledTimes(1); }); From 4a2081c675f9e862186d7f83f6ea80671bdf8104 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:25:20 +0100 Subject: [PATCH 336/806] test: require chat view model picker --- ui/src/ui/views/chat.test.ts | 57 +++++++++++++++++------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 4ff5a1fffed..9946554d240 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -331,6 +331,17 @@ async function flushTasks() { await vi.dynamicImportSettled(); } +function getChatModelSelect(container: Element): HTMLSelectElement { + const select = container.querySelector( + 'select[data-chat-model-select="true"]', + ); + expect(select).toBeInstanceOf(HTMLSelectElement); + if (!(select instanceof HTMLSelectElement)) { + throw new Error("Expected chat model select"); + } + return select; +} + function renderChatView(overrides: Partial[0]> = {}) { const container = document.createElement("div"); render( @@ -485,7 +496,7 @@ describe("chat loading skeleton", () => { }); expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); - expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); + expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1); }); }); @@ -936,14 +947,11 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe(""); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.value).toBe(""); - modelSelect!.value = "openai/gpt-5-mini"; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = "openai/gpt-5-mini"; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", @@ -966,14 +974,11 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe("openai/gpt-5-mini"); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.value).toBe("openai/gpt-5-mini"); - modelSelect!.value = ""; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = ""; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", @@ -991,11 +996,8 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.disabled).toBe(true); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.disabled).toBe(true); }); it("keeps the selected model visible when the active session is absent from sessions.list", async () => { @@ -1003,20 +1005,15 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); + const modelSelect = getChatModelSelect(container); - modelSelect!.value = "openai/gpt-5-mini"; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = "openai/gpt-5-mini"; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); render(renderChatSessionSelect(state), container); - const rerendered = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(rerendered?.value).toBe("openai/gpt-5-mini"); + const rerendered = getChatModelSelect(container); + expect(rerendered.value).toBe("openai/gpt-5-mini"); }); it("uses default thinking options when the active session is absent", () => { From 19ac69bba33e809e5c91c0f330554df02525746f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:25:19 +0100 Subject: [PATCH 337/806] test: require session view controls --- ui/src/ui/views/sessions.test.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index d2a9140db7a..ac401c45790 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -191,7 +191,8 @@ describe("sessions view", () => { expect(toggle?.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".sessions-filter-bar")).toBeNull(); - toggle?.click(); + expect(toggle).toBeInstanceOf(HTMLButtonElement); + toggle!.click(); expect(onToggleFiltersCollapsed).toHaveBeenCalledTimes(1); }); @@ -492,8 +493,9 @@ describe("sessions view", () => { ); await Promise.resolve(); - const row = container.querySelector("tbody tr.session-data-row"); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = container.querySelector("tbody tr.session-data-row"); + expect(row).toBeInstanceOf(HTMLTableRowElement); + row!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).toHaveBeenCalledWith("agent:main:main"); const tokenCell = container.querySelector(".session-token-cell"); @@ -531,7 +533,8 @@ describe("sessions view", () => { expect(trigger?.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".session-checkpoint-toggle")).toBeNull(); - trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(trigger).toBeInstanceOf(HTMLButtonElement); + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).toHaveBeenCalledWith("agent:main:main"); }); @@ -623,9 +626,11 @@ describe("sessions view", () => { await Promise.resolve(); const rows = container.querySelectorAll("tbody tr.session-data-row"); - const checkbox = rows[0]?.querySelector("input[type=checkbox]"); - checkbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - rows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const checkbox = rows[0]?.querySelector("input[type=checkbox]"); + expect(checkbox).toBeInstanceOf(HTMLInputElement); + expect(rows[1]).toBeInstanceOf(HTMLTableRowElement); + checkbox!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + rows[1]!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).not.toHaveBeenCalled(); }); @@ -727,8 +732,9 @@ describe("sessions view", () => { ); await Promise.resolve(); - const headerCheckbox = container.querySelector("thead input[type=checkbox]"); - headerCheckbox?.dispatchEvent(new Event("change", { bubbles: true })); + const headerCheckbox = container.querySelector("thead input[type=checkbox]"); + expect(headerCheckbox).toBeInstanceOf(HTMLInputElement); + headerCheckbox!.dispatchEvent(new Event("change", { bubbles: true })); expect(onDeselectPage).toHaveBeenCalledWith(["page-0"]); expect(onDeselectAll).not.toHaveBeenCalled(); From d4278fcaf7e000a64a9bd4d33b3a68abde08359f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:26:51 +0100 Subject: [PATCH 338/806] test: require channel action buttons --- ui/src/ui/views/channels.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/views/channels.test.ts b/ui/src/ui/views/channels.test.ts index 1db3175b419..d353aabe277 100644 --- a/ui/src/ui/views/channels.test.ts +++ b/ui/src/ui/views/channels.test.ts @@ -173,7 +173,9 @@ describe("WhatsApp card actions", () => { expect(labels).not.toContain("Relink"); expect(labels).not.toContain("Wait for scan"); - buttons.find((button) => button.textContent?.trim() === "Show QR")?.click(); + const showQr = buttons.find((button) => button.textContent?.trim() === "Show QR"); + expect(showQr).toBeInstanceOf(HTMLButtonElement); + showQr!.click(); expect(onWhatsAppStart).toHaveBeenCalledWith(false); }); @@ -187,7 +189,9 @@ describe("WhatsApp card actions", () => { expect(labels).toContain("Relink"); expect(labels).not.toContain("Show QR"); - buttons.find((button) => button.textContent?.trim() === "Relink")?.click(); + const relink = buttons.find((button) => button.textContent?.trim() === "Relink"); + expect(relink).toBeInstanceOf(HTMLButtonElement); + relink!.click(); expect(onWhatsAppStart).toHaveBeenCalledWith(true); }); From a13ffb9d9f084bc36a37e5f961ab0326b54dc48f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:27:13 +0100 Subject: [PATCH 339/806] test: require cron view dom elements --- ui/src/ui/views/cron.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 3b68354f2b3..b17e84966d8 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -225,8 +225,7 @@ describe("cron view", () => { container, ); - const selected = container.querySelector(".list-item-selected"); - expect(selected).not.toBeNull(); + getElement(container, ".list-item-selected", HTMLElement); const row = getElement(container, ".list-item-clickable", HTMLElement); row.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -327,7 +326,7 @@ describe("cron view", () => { expect(collapseButton.getAttribute("aria-expanded")).toBe("true"); collapseButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenCalledWith(true); - expect(container.querySelector(".cron-form")).not.toBeNull(); + getElement(container, ".cron-form", HTMLElement); const collapsedProps = createProps() as CronProps & { cronFormCollapsed: boolean; @@ -440,7 +439,7 @@ describe("cron view", () => { const runBody = container.querySelector(".cron-run-entry__body.chat-text"); expect(runBody?.querySelector("strong")?.textContent).toBe("markdown"); - expect(runBody?.querySelector("table")).not.toBeNull(); + expect(runBody?.querySelectorAll("table")).toHaveLength(1); }); it("shows run errors in one place when no summary exists", () => { @@ -565,9 +564,8 @@ describe("cron view", () => { expect(container.textContent).toContain("Execution"); expect(container.textContent).toContain("Delivery"); - const checkboxLabel = container.querySelector(".cron-checkbox"); - expect(checkboxLabel).not.toBeNull(); - const firstElement = checkboxLabel?.firstElementChild; + const checkboxLabel = getElement(container, ".cron-checkbox", HTMLLabelElement); + const firstElement = checkboxLabel.firstElementChild; expect(firstElement?.tagName.toLowerCase()).toBe("input"); render( From e101ca9ed1ea0c3e984aa152a7966094d5d123c3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:27:37 +0100 Subject: [PATCH 340/806] test: require command palette controls --- ui/src/ui/views/command-palette.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts index 984bf02b404..7bb590c9ca7 100644 --- a/ui/src/ui/views/command-palette.test.ts +++ b/ui/src/ui/views/command-palette.test.ts @@ -180,7 +180,8 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input?.dispatchEvent(tab); + expect(input).toBeInstanceOf(HTMLInputElement); + input!.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(input); @@ -189,7 +190,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input?.dispatchEvent(escape); + input!.dispatchEvent(escape); expect(escape.defaultPrevented).toBe(true); expect(onToggle).toHaveBeenCalledTimes(1); @@ -204,16 +205,17 @@ describe("command palette", () => { const dialog = container.querySelector("dialog.cmd-palette-overlay"); const input = container.querySelector("#cmd-palette-input"); expect(dialog?.open).toBe(true); - expect(input?.id).toBe("cmd-palette-input"); + expect(input).toBeInstanceOf(HTMLInputElement); - input?.dispatchEvent( + input!.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); - dialog?.dispatchEvent(new Event("cancel", { cancelable: true })); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + dialog!.dispatchEvent(new Event("cancel", { cancelable: true })); expect(onToggle).toHaveBeenCalledTimes(1); }); From fce7b95d194d3d3ef20a7801caeb74f6fbfb51f6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:28:52 +0100 Subject: [PATCH 341/806] test: require skills view buttons --- ui/src/ui/views/skills.test.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index 25e1d6f6773..f8e73bb6c6a 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -148,7 +148,11 @@ describe("renderSkills", () => { expect(showModal).toHaveBeenCalledTimes(1); expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); - container.querySelector(".md-preview-dialog__header .btn")?.click(); + const closeButton = container.querySelector( + ".md-preview-dialog__header .btn", + ); + expect(closeButton).toBeInstanceOf(HTMLButtonElement); + closeButton!.click(); expect(onDetailClose).toHaveBeenCalledTimes(1); @@ -178,10 +182,12 @@ describe("renderSkills", () => { expect(text).toContain("GitHub integration for OpenClaw"); expect(text).toContain("v1.2.3"); - container.querySelector(".list-item")?.click(); - container - .querySelector(".list-item .btn.btn--sm") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const resultItem = container.querySelector(".list-item"); + const installButton = container.querySelector(".list-item .btn.btn--sm"); + expect(resultItem).toBeInstanceOf(HTMLElement); + expect(installButton).toBeInstanceOf(HTMLButtonElement); + resultItem!.click(); + installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1); expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); @@ -234,9 +240,11 @@ describe("renderSkills", () => { expect(text).toContain("Platforms: macos, linux"); expect(text).toContain("Added search support"); - container - .querySelector(".md-preview-dialog__body .btn.primary") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const detailInstallButton = container.querySelector( + ".md-preview-dialog__body .btn.primary", + ); + expect(detailInstallButton).toBeInstanceOf(HTMLButtonElement); + detailInstallButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClawHubInstall).toHaveBeenCalledTimes(1); expect(onClawHubInstall).toHaveBeenCalledWith("github"); From 43d9b44c7aaf4d3e1ce34303055942ff84e5d1be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:29:22 +0100 Subject: [PATCH 342/806] test: tighten talk and session controls --- ui/src/ui/app.talk.test.ts | 5 ++++- ui/src/ui/views/sessions.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts index ac0750d198c..e1811b4c878 100644 --- a/ui/src/ui/app.talk.test.ts +++ b/ui/src/ui/app.talk.test.ts @@ -59,6 +59,9 @@ describe("OpenClawApp Talk controls", () => { expect(startMock).toHaveBeenCalledOnce(); expect(stopMock).not.toHaveBeenCalled(); expect(app.realtimeTalkStatus).toBe("connecting"); - expect(app.realtimeTalkSession).not.toBeNull(); + expect(app.realtimeTalkSession).toMatchObject({ + start: startMock, + stop: stopMock, + }); }); }); diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index ac401c45790..c0de9fa6ab5 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -629,8 +629,11 @@ describe("sessions view", () => { const checkbox = rows[0]?.querySelector("input[type=checkbox]"); expect(checkbox).toBeInstanceOf(HTMLInputElement); expect(rows[1]).toBeInstanceOf(HTMLTableRowElement); - checkbox!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - rows[1]!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + if (!(checkbox instanceof HTMLInputElement) || !(rows[1] instanceof HTMLTableRowElement)) { + throw new Error("Expected checkpoint toggle row controls"); + } + checkbox.dispatchEvent(new MouseEvent("click", { bubbles: true })); + rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).not.toHaveBeenCalled(); }); From 7b377d23dc0c4821144261f5d0e8896bf7bd7369 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:29:38 +0100 Subject: [PATCH 343/806] test: require agents preview controls --- ui/src/ui/views/agents.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index a166f65de1f..3f502698aec 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -488,14 +488,17 @@ describe("renderAgentFiles", () => { const panel = container.querySelector(".md-preview-dialog__panel"); const expandButton = container.querySelector(".md-preview-expand-btn"); - expandButton?.click(); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + expect(panel).toBeInstanceOf(HTMLElement); + expect(expandButton).toBeInstanceOf(HTMLButtonElement); + expandButton!.click(); expect(panel?.classList.contains("fullscreen")).toBe(true); expect(expandButton?.classList.contains("is-fullscreen")).toBe(true); expect(expandButton?.getAttribute("aria-pressed")).toBe("true"); expect(expandButton?.getAttribute("aria-label")).toBe("Collapse preview"); - dialog?.dispatchEvent(new Event("close")); + dialog!.dispatchEvent(new Event("close")); expect(panel?.classList.contains("fullscreen")).toBe(false); expect(expandButton?.classList.contains("is-fullscreen")).toBe(false); From a7ecc7bcd92f4619c117f5d184eb2814d57d0a29 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:30:53 +0100 Subject: [PATCH 344/806] test: require dreaming diary buttons --- ui/src/ui/views/dreaming.test.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 8be2e836a44..410f776c801 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -294,9 +294,11 @@ describe("dreaming view", () => { content: "# ChatGPT Export: BA flight receipts process", }); const container = renderInto(buildProps({ onOpenWikiPage })); - container - .querySelectorAll(".dreams-diary__insight-actions .btn")[1] - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const openSourceButton = container.querySelectorAll( + ".dreams-diary__insight-actions .btn", + )[1]; + expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); + openSourceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); expect(onOpenWikiPage).toHaveBeenCalledWith("sources/chatgpt-2026-04-10-alpha.md"); setDreamDiarySubTab("dreams"); @@ -322,9 +324,11 @@ describe("dreaming view", () => { }); rerender(); - container - .querySelectorAll(".dreams-diary__insight-actions .btn")[1] - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const openSourceButton = container.querySelectorAll( + ".dreams-diary__insight-actions .btn", + )[1]; + expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); + openSourceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); await Promise.resolve(); @@ -332,9 +336,11 @@ describe("dreaming view", () => { "6001 total lines", ); - container - .querySelector(".dreams-diary__preview-header .btn") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const closePreviewButton = container.querySelector( + ".dreams-diary__preview-header .btn", + ); + expect(closePreviewButton).toBeInstanceOf(HTMLButtonElement); + closePreviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); setDreamDiarySubTab("dreams"); setDreamSubTab("scene"); }); @@ -368,9 +374,11 @@ describe("dreaming view", () => { expect(container.textContent).toContain("Memory Wiki is not enabled"); expect(container.textContent).toContain("plugins.entries.memory-wiki.enabled = true"); - container - .querySelector(".dreams-diary__empty-actions .btn") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const configButton = container.querySelector( + ".dreams-diary__empty-actions .btn", + ); + expect(configButton).toBeInstanceOf(HTMLButtonElement); + configButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onOpenConfig).toHaveBeenCalledTimes(1); setDreamDiarySubTab("dreams"); setDreamSubTab("scene"); From 5ad0b7f92053c0c108d0a71b43fe9556699aa5a8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:31:46 +0100 Subject: [PATCH 345/806] test: require grouped render action targets --- ui/src/ui/chat/grouped-render.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index a62db5061e3..1ec219af077 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -1103,8 +1103,8 @@ describe("grouped chat rendering", () => { try { renderAssistantImage("https://example.com/cat.png"); let image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith( @@ -1116,14 +1116,14 @@ describe("grouped chat rendering", () => { openSpy.mockClear(); renderAssistantImage("javascript:alert(1)"); image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).not.toHaveBeenCalled(); renderAssistantImage("data:image/svg+xml,"); image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).not.toHaveBeenCalled(); } finally { openSpy.mockRestore(); @@ -1720,10 +1720,10 @@ describe("grouped chat rendering", () => { ); const sidebarButton = container.querySelector(".chat-tool-card__action-btn"); - sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(sidebarButton).toBeInstanceOf(HTMLButtonElement); + sidebarButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull(); - expect(sidebarButton).not.toBeNull(); expect(onOpenSidebar).toHaveBeenCalledWith( expect.objectContaining({ kind: "markdown", From 1b9431f0c4ac9741f16617b7b61b815553f55b76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:31:42 +0100 Subject: [PATCH 346/806] test: require chat responsive geometry --- .../ui/chat/chat-responsive.browser.test.ts | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 21bd8f125ca..26e3f560a20 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -17,6 +17,42 @@ const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : let browser: Browser; +type ControlRect = { + x: number; + y: number; + width: number; + height: number; + text?: string; + display?: string; +}; + +async function getBoundingBox(page: Page, selector: string) { + const box = await page.locator(selector).boundingBox(); + expect(box).toMatchObject({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + if (box === null) { + throw new Error(`Expected bounding box for ${selector}`); + } + return box; +} + +function expectControlRect(rect: ControlRect | null, label: string): ControlRect { + expect(rect).toMatchObject({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + if (rect === null) { + throw new Error(`Expected ${label} control rect`); + } + return rect; +} + function readUiCss(): string { const files = [ "ui/src/styles/base.css", @@ -252,9 +288,11 @@ describeBrowserLayout("chat responsive browser layout", () => { ].filter((value): value is number => typeof value === "number"); expect(rowY.length).toBe(5); expect(Math.max(...rowY) - Math.min(...rowY)).toBeLessThanOrEqual(4); - expect(controls.agent!.x).toBeLessThan(controls.session!.x); - expect(controls.session!.width / controls.agent!.width).toBeGreaterThan(1.25); - expect(controls.session!.width / controls.agent!.width).toBeLessThan(1.55); + const agent = expectControlRect(controls.agent, "agent"); + const session = expectControlRect(controls.session, "session"); + expect(agent.x).toBeLessThan(session.x); + expect(session.width / agent.width).toBeGreaterThan(1.25); + expect(session.width / agent.width).toBeLessThan(1.55); } finally { await page.close(); } @@ -285,9 +323,8 @@ describeBrowserLayout("chat responsive browser layout", () => { const page = await openFixture(width, height); try { await expectNoHorizontalOverflow(page); - const code = await page.locator(".chat-text pre").boundingBox(); - expect(code).not.toBeNull(); - expect(code!.x + code!.width).toBeLessThanOrEqual(width + 1); + const code = await getBoundingBox(page, ".chat-text pre"); + expect(code.x + code.width).toBeLessThanOrEqual(width + 1); } finally { await page.close(); } @@ -302,10 +339,9 @@ describeBrowserLayout("chat responsive browser layout", () => { (mode) => document.documentElement.setAttribute("data-theme-mode", mode), themeMode, ); - const dropdown = await page.locator(".chat-controls-dropdown.open").boundingBox(); - expect(dropdown).not.toBeNull(); - expect(dropdown!.x).toBeGreaterThanOrEqual(8); - expect(dropdown!.x + dropdown!.width).toBeLessThanOrEqual(312); + const dropdown = await getBoundingBox(page, ".chat-controls-dropdown.open"); + expect(dropdown.x).toBeGreaterThanOrEqual(8); + expect(dropdown.x + dropdown.width).toBeLessThanOrEqual(312); await expectNoHorizontalOverflow(page); const mobileControls = await page.evaluate(() => { const rectFor = (selector: string) => { @@ -331,12 +367,12 @@ describeBrowserLayout("chat responsive browser layout", () => { .length, }; }); - expect(mobileControls.agent).not.toBeNull(); - expect(mobileControls.session).not.toBeNull(); - expect(mobileControls.session!.y).toBe(mobileControls.agent!.y); - expect(mobileControls.agent!.x).toBeLessThan(mobileControls.session!.x); - expect(mobileControls.session!.width / mobileControls.agent!.width).toBeGreaterThan(1.25); - expect(mobileControls.session!.width / mobileControls.agent!.width).toBeLessThan(1.55); + const agent = expectControlRect(mobileControls.agent, "agent"); + const session = expectControlRect(mobileControls.session, "session"); + expect(session.y).toBe(agent.y); + expect(agent.x).toBeLessThan(session.x); + expect(session.width / agent.width).toBeGreaterThan(1.25); + expect(session.width / agent.width).toBeLessThan(1.55); expect(mobileControls.thinkingFull?.display).not.toBe("none"); expect(mobileControls.thinkingFull?.text).toBe("Default (high)"); expect(mobileControls.compactCount).toBe(0); @@ -386,15 +422,12 @@ describeBrowserLayout("chat responsive browser layout", () => { try { await expectNoHorizontalOverflow(page); expect(await page.locator('[data-chat-agent-filter="true"]').count()).toBe(0); - const session = await page.locator('[data-chat-session-select="true"]').boundingBox(); - const model = await page.locator('[data-chat-model-select="true"]').boundingBox(); - const thinking = await page.locator('[data-chat-thinking-select="true"]').boundingBox(); - expect(session).not.toBeNull(); - expect(model).not.toBeNull(); - expect(thinking).not.toBeNull(); - expect(thinking!.x).toBeGreaterThan(session!.x); - expect(model!.y).toBeGreaterThan(session!.y); - expect(model!.width).toBeGreaterThan(session!.width); + const session = await getBoundingBox(page, '[data-chat-session-select="true"]'); + const model = await getBoundingBox(page, '[data-chat-model-select="true"]'); + const thinking = await getBoundingBox(page, '[data-chat-thinking-select="true"]'); + expect(thinking.x).toBeGreaterThan(session.x); + expect(model.y).toBeGreaterThan(session.y); + expect(model.width).toBeGreaterThan(session.width); } finally { await page.close(); } From 14b480defcd69c2fd60413a96aa3dc9abc31167f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:32:34 +0100 Subject: [PATCH 347/806] test: require tool card controls --- ui/src/ui/chat/tool-cards.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index 5e77a0b0d9a..3c852112cda 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -129,7 +129,8 @@ describe("tool-cards", () => { expect(rawToggle?.getAttribute("aria-expanded")).toBe("false"); expect(rawBody?.hidden).toBe(true); - rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(rawToggle).toBeInstanceOf(HTMLButtonElement); + rawToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(rawToggle?.getAttribute("aria-expanded")).toBe("true"); expect(rawBody?.hidden).toBe(false); @@ -173,8 +174,9 @@ describe("tool-cards", () => { ); const sidebarButton = container.querySelector(".chat-tool-card__action-btn"); + expect(sidebarButton).toBeInstanceOf(HTMLButtonElement); expect(sidebarButton?.classList.contains("chat-tool-card__action-btn")).toBe(true); - sidebarButton?.click(); + sidebarButton!.click(); expect(onOpenSidebar).toHaveBeenCalledWith( expect.objectContaining({ From 590363cb93661dc6bd8bcd6af10465e4bab1bc10 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:33:39 +0100 Subject: [PATCH 348/806] test: tighten secret target assertions --- src/secrets/target-registry.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 2802a2bcad1..61c6915157c 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -33,7 +33,6 @@ describe("secret target registry", () => { it("resolves config targets by exact path including sibling ref metadata", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("channels.googlechat.serviceAccount"); expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); }); @@ -58,7 +57,6 @@ describe("secret target registry", () => { "apiKey", ]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("plugins.entries.exa.config.webSearch.apiKey"); const fetchTarget = resolveConfigSecretTargetByPath([ @@ -69,7 +67,6 @@ describe("secret target registry", () => { "webFetch", "apiKey", ]); - expect(fetchTarget).not.toBeNull(); expect(fetchTarget?.entry?.id).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); }); @@ -88,7 +85,6 @@ describe("secret target registry", () => { "apiKey", ]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("plugins.entries.voice-call.config.tts.providers.*.apiKey"); }); }); From 14a9164e392d48e2e167745e895605f7cca4dda4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:34:23 +0100 Subject: [PATCH 349/806] test: tighten secret fast path assertion --- src/secrets/target-registry.fast-path.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 98f1567087c..d9ac40bfec0 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -53,7 +53,6 @@ describe("secret target registry fast path", () => { it("resolves bundled channel targets by explicit channel id without manifest scans", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target).not.toBeNull(); expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ From 64eff58248f302bc7d7852b492a5e7e1428a949e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:35:15 +0100 Subject: [PATCH 350/806] test: require navigation browser elements --- ui/src/ui/navigation.browser.test.ts | 130 +++++++++++---------------- ui/src/ui/views/dreaming.test.ts | 10 ++- 2 files changed, 59 insertions(+), 81 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index da9a7a25d70..485b9856ba9 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -14,11 +14,11 @@ function nextFrame() { } function expectElement( - app: ReturnType, + root: Element, selector: string, constructor: new () => T, ): T { - const element = app.querySelector(selector); + const element = root.querySelector(selector); expect(element).toBeInstanceOf(constructor); if (!(element instanceof constructor)) { throw new Error(`Expected ${selector} to match ${constructor.name}`); @@ -57,8 +57,7 @@ describe("control UI routing", () => { expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); - const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); - expect(dreamsLink).not.toBeNull(); + expectElement(app, 'a.nav-item[href="/dreaming"]', HTMLAnchorElement); }); it("renders the dashboard breadcrumb as an overview link", async () => { @@ -153,8 +152,8 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.tab).toBe("dreams"); - expect(app.querySelector(".dreams__tab")).not.toBeNull(); - expect(app.querySelector(".dreams__lobster")).not.toBeNull(); + expectElement(app, ".dreams__tab", HTMLElement); + expectElement(app, ".dreams__lobster", HTMLElement); }); it("requires confirmation before sending dreaming restart patch", async () => { @@ -337,18 +336,18 @@ describe("control UI routing", () => { const app = mountApp("/chat"); await app.updateComplete; - expect(app.querySelector(".topnav-shell")).not.toBeNull(); - expect(app.querySelector(".topnav-shell__content")).not.toBeNull(); - expect(app.querySelector(".topnav-shell__actions")).not.toBeNull(); + expectElement(app, ".topnav-shell", HTMLElement); + expectElement(app, ".topnav-shell__content", HTMLElement); + expectElement(app, ".topnav-shell__actions", HTMLElement); expect(app.querySelector(".topnav-shell .brand-title")).toBeNull(); - expect(app.querySelector(".sidebar-shell")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__header")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__body")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull(); + expectElement(app, ".sidebar-shell", HTMLElement); + expectElement(app, ".sidebar-shell__header", HTMLElement); + expectElement(app, ".sidebar-shell__body", HTMLElement); + expectElement(app, ".sidebar-shell__footer", HTMLElement); + expectElement(app, ".sidebar-brand", HTMLElement); + expectElement(app, ".sidebar-brand__logo", HTMLElement); + expectElement(app, ".sidebar-brand__copy", HTMLElement); app.hello = { ok: true, @@ -357,58 +356,42 @@ describe("control UI routing", () => { app.requestUpdate(); await app.updateComplete; - const version = app.querySelector(".sidebar-version"); - const statusDot = app.querySelector(".sidebar-version__status"); - expect(version).not.toBeNull(); - expect(statusDot).not.toBeNull(); - expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + expectElement(app, ".sidebar-version", HTMLElement); + const statusDot = expectElement(app, ".sidebar-version__status", HTMLElement); + expect(statusDot.getAttribute("aria-label")).toContain("Online"); app.applySettings({ ...app.settings, navWidth: 360 }); await app.updateComplete; expect(app.querySelector(".sidebar-resizer")).toBeNull(); - const shell = app.querySelector(".shell"); - expect(shell?.style.getPropertyValue("--shell-nav-width")).toBe(""); + const shell = expectElement(app, ".shell", HTMLElement); + expect(shell.style.getPropertyValue("--shell-nav-width")).toBe(""); - const split = app.querySelector(".chat-split-container"); - expect(split).not.toBeNull(); - if (split) { - split.classList.add("chat-split-container--open"); - await app.updateComplete; - expect(split.classList.contains("chat-split-container--open")).toBe(true); - } + const split = expectElement(app, ".chat-split-container", HTMLElement); + split.classList.add("chat-split-container--open"); + await app.updateComplete; + expect(split.classList.contains("chat-split-container--open")).toBe(true); - const chatMain = app.querySelector(".chat-main"); - expect(chatMain).not.toBeNull(); + expectElement(app, ".chat-main", HTMLElement); - const topShell = app.querySelector(".topnav-shell"); - const content = app.querySelector(".topnav-shell__content"); - expect(topShell).not.toBeNull(); - expect(content).not.toBeNull(); - if (!topShell || !content) { - return; - } + const topShell = expectElement(app, ".topnav-shell", HTMLElement); + const content = expectElement(app, ".topnav-shell__content", HTMLElement); expect(topShell.classList.contains("topnav-shell")).toBe(true); expect(content.classList.contains("topnav-shell__content")).toBe(true); - expect(topShell.querySelector(".topbar-nav-toggle")).not.toBeNull(); + expectElement(topShell, ".topbar-nav-toggle", HTMLElement); expect(topShell.children[1]).toBe(content); - expect(topShell.querySelector(".topnav-shell__actions")).not.toBeNull(); + expectElement(topShell, ".topnav-shell__actions", HTMLElement); - const toggle = app.querySelector(".topbar-nav-toggle"); - const actions = app.querySelector(".topnav-shell__actions"); - expect(toggle).not.toBeNull(); - expect(actions).not.toBeNull(); - if (!toggle || !actions || !shell) { - return; - } + const toggle = expectElement(app, ".topbar-nav-toggle", HTMLElement); + const actions = expectElement(app, ".topnav-shell__actions", HTMLElement); expect(toggle.classList.contains("topbar-nav-toggle")).toBe(true); expect(toggle.classList.contains("sidebar-menu-trigger")).toBe(true); expect(actions.classList.contains("topnav-shell__actions")).toBe(true); expect(topShell.firstElementChild).toBe(toggle); expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle); - expect(actions.querySelector(".topbar-search")).not.toBeNull(); + expectElement(actions, ".topbar-search", HTMLElement); expect(toggle.getAttribute("aria-label")).toEqual(expect.stringMatching(/\S/u)); const nav = expectElement(app, ".shell-nav", HTMLElement); @@ -434,37 +417,26 @@ describe("control UI routing", () => { expect(app.querySelector(".nav-section__label")).toBeNull(); expect(app.querySelector(".sidebar-brand__logo")).toBeNull(); - expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull(); - expect(app.querySelector(".sidebar-utility-link")).not.toBeNull(); + expectElement(app, ".sidebar-shell__footer", HTMLElement); + expectElement(app, ".sidebar-utility-link", HTMLElement); - const item = app.querySelector(".sidebar .nav-item"); - const header = app.querySelector(".sidebar-shell__header"); - const sidebar = app.querySelector(".sidebar"); - expect(item).not.toBeNull(); - expect(header).not.toBeNull(); - expect(sidebar).not.toBeNull(); - if (!item || !header || !sidebar) { - return; - } + const item = expectElement(app, ".sidebar .nav-item", HTMLElement); + const header = expectElement(app, ".sidebar-shell__header", HTMLElement); + const sidebar = expectElement(app, ".sidebar", HTMLElement); expect(sidebar.classList.contains("sidebar--collapsed")).toBe(true); - expect(item.querySelector(".nav-item__icon")).not.toBeNull(); + expectElement(item, ".nav-item__icon", HTMLElement); expect(item.querySelector(".nav-item__text")).toBeNull(); expect(app.querySelector(".sidebar-brand__copy")).toBeNull(); - expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull(); + expectElement(header, ".nav-collapse-toggle", HTMLElement); }); it("closes mobile chat controls on Escape, outside pointerdown, and tab changes", async () => { const app = mountApp("/chat"); await app.updateComplete; - const toggle = app.querySelector(".chat-controls-mobile-toggle"); - const dropdown = app.querySelector(".chat-controls-dropdown"); - expect(toggle).not.toBeNull(); - expect(dropdown).not.toBeNull(); - if (!toggle || !dropdown) { - return; - } + const toggle = expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement); + const dropdown = expectElement(app, ".chat-controls-dropdown", HTMLElement); toggle.focus(); toggle.click(); @@ -521,15 +493,14 @@ describe("control UI routing", () => { expect(window.location.pathname).toBe("/chat"); expect(window.location.search).toBe("?session=agent%3Amain%3Asubagent%3Atask-123"); - const shell = app.querySelector(".shell"); - expect(shell).not.toBeNull(); - expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + const shell = expectElement(app, ".shell", HTMLElement); + expect(shell.classList.contains("shell--chat-focus")).toBe(false); const toggle = expectElement(app, 'button[title^="Toggle focus mode"]', HTMLButtonElement); toggle.click(); await app.updateComplete; - expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + expect(shell.classList.contains("shell--chat-focus")).toBe(true); const channelsLink = expectElement(app, 'a.nav-item[href="/channels"]', HTMLAnchorElement); channelsLink.dispatchEvent( @@ -538,14 +509,14 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.tab).toBe("channels"); - expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + expect(shell.classList.contains("shell--chat-focus")).toBe(false); const chatLink = expectElement(app, 'a.nav-item[href="/chat"]', HTMLAnchorElement); chatLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("chat"); - expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + expect(shell.classList.contains("shell--chat-focus")).toBe(true); }); it("auto-scrolls chat history to the latest message", async () => { @@ -659,12 +630,13 @@ describe("control UI routing", () => { undefined, ); - const gatewayUrlInput = refreshed.querySelector( + const gatewayUrlInput = expectElement( + refreshed, 'input[placeholder="ws://100.x.y.z:18789"]', + HTMLInputElement, ); - expect(gatewayUrlInput).not.toBeNull(); - gatewayUrlInput!.value = "wss://other-gateway.example/openclaw"; - gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true })); + 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"); diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 410f776c801..7df4e601f37 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -298,7 +298,10 @@ describe("dreaming view", () => { ".dreams-diary__insight-actions .btn", )[1]; expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); - openSourceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + if (!(openSourceButton instanceof HTMLButtonElement)) { + throw new Error("Expected imported source button"); + } + openSourceButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); expect(onOpenWikiPage).toHaveBeenCalledWith("sources/chatgpt-2026-04-10-alpha.md"); setDreamDiarySubTab("dreams"); @@ -328,7 +331,10 @@ describe("dreaming view", () => { ".dreams-diary__insight-actions .btn", )[1]; expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); - openSourceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + if (!(openSourceButton instanceof HTMLButtonElement)) { + throw new Error("Expected imported source button"); + } + openSourceButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); await Promise.resolve(); From 39f33ed7bc232859bf5227c5c78cd09adda9dc93 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:35:30 +0100 Subject: [PATCH 351/806] test: tighten twitch account assertions --- extensions/twitch/src/config.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 97bb6989f50..472b769e144 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -36,21 +36,18 @@ describe("getAccountConfig", () => { it("returns account config for valid account ID (multi-account)", () => { const result = getAccountConfig(mockMultiAccountConfig, "default"); - expect(result).not.toBeNull(); expect(result?.username).toBe("testbot"); }); it("returns account config for default account (simplified config)", () => { const result = getAccountConfig(mockSimplifiedConfig, "default"); - expect(result).not.toBeNull(); expect(result?.username).toBe("testbot"); }); it("returns non-default account from multi-account config", () => { const result = getAccountConfig(mockMultiAccountConfig, "secondary"); - expect(result).not.toBeNull(); expect(result?.username).toBe("secondbot"); }); From 82ebd54afe42a644b09eef0670c7dc68f7d4eac2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:36:18 +0100 Subject: [PATCH 352/806] test: tighten feishu comment turn assertion --- extensions/feishu/src/monitor.comment.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts index a1ed3e9808d..e9f590ad978 100644 --- a/extensions/feishu/src/monitor.comment.test.ts +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -224,7 +224,6 @@ describe("resolveDriveCommentEventTurn", () => { createClient: () => client as never, }); - expect(turn).not.toBeNull(); expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b"); expect(turn?.messageId).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877"); expect(turn?.fileType).toBe("docx"); From af9ae6b244a4d167015cd04ef7a087bf2e2ba2d1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:37:02 +0100 Subject: [PATCH 353/806] test: tighten twitch client message assertion --- extensions/twitch/src/twitch-client.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index 0f279ba79d6..d11362c5e3c 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -436,7 +436,6 @@ describe("TwitchClientManager", () => { id: "msg123", }); - expect(capturedMessage).not.toBeNull(); expect(capturedMessage?.username).toBe("testuser"); expect(capturedMessage?.displayName).toBe("TestUser"); expect(capturedMessage?.userId).toBe("12345"); From a54ec4572edab9803bb0f0bf257af7079f252f66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:37:29 +0100 Subject: [PATCH 354/806] test: require config browser elements --- ui/src/ui/views/config.browser.test.ts | 94 ++++++++++++-------------- 1 file changed, 42 insertions(+), 52 deletions(-) diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index ba58592d059..85bb57ee5a6 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -132,9 +132,14 @@ describe("config view", () => { return button; } - function queryRequired(container: HTMLElement, selector: string): Element { + function queryRequired( + container: HTMLElement, + selector: string, + constructor: new () => T, + ): T { const element = container.querySelector(selector); - if (!element) { + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { throw new Error(`Expected element matching "${selector}"`); } return element; @@ -302,12 +307,10 @@ describe("config view", () => { const rawButton = findButtonByText(container, "Raw"); expect(formButton.classList.contains("active")).toBe(true); expect(rawButton.disabled).toBe(true); - const rawNotice = container.querySelector(".config-actions__notice"); - const actionButtons = container.querySelector(".config-actions__buttons"); - expect(rawNotice).not.toBeNull(); - expect(actionButtons).not.toBeNull(); - expect(actionButtons?.textContent).toContain("Reload"); - expect(actionButtons?.textContent).toContain("Update"); + queryRequired(container, ".config-actions__notice", HTMLElement); + const actionButtons = queryRequired(container, ".config-actions__buttons", HTMLElement); + expect(actionButtons.textContent).toContain("Reload"); + expect(actionButtons.textContent).toContain("Update"); expect(normalizedText(container)).toContain( "Raw mode disabled (snapshot cannot safely round-trip raw text).", ); @@ -379,7 +382,7 @@ describe("config view", () => { }, }); - const content = queryRequired(container, ".config-content") as HTMLElement; + const content = queryRequired(container, ".config-content", HTMLElement); content.scrollTop = 280; content.scrollLeft = 24; content.scrollTo = vi.fn(({ top, left }: { top?: number; left?: number }) => { @@ -410,9 +413,8 @@ describe("config view", () => { container, ); - const icon = container.querySelector(".config-search__icon"); - expect(icon).not.toBeNull(); - expect(icon?.closest(".config-search__input-row")).not.toBeNull(); + const icon = queryRequired(container, ".config-search__icon", SVGElement); + expect(icon.closest(".config-search__input-row")).toBeInstanceOf(HTMLElement); const input = container.querySelector(".config-search__input"); expect(input).toBeInstanceOf(HTMLInputElement); @@ -522,18 +524,14 @@ describe("config view", () => { expect(text).not.toContain("supersecret"); expect(container.querySelector("textarea")).toBeNull(); - const revealButton = container.querySelector(".config-raw-toggle"); - if (!revealButton) { - throw new Error("Expected raw config reveal button"); - } + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); revealButton.click(); - const textarea = container.querySelector("textarea"); - expect(textarea).toBeInstanceOf(HTMLTextAreaElement); - expect(textarea?.value).toContain("supersecret"); - textarea!.value = textarea!.value.replace("supersecret", "updatedsecret"); - textarea!.dispatchEvent(new Event("input", { bubbles: true })); - expect(onRawChange).toHaveBeenCalledWith(textarea!.value); + const textarea = queryRequired(container, "textarea", HTMLTextAreaElement); + expect(textarea.value).toContain("supersecret"); + textarea.value = textarea.value.replace("supersecret", "updatedsecret"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(onRawChange).toHaveBeenCalledWith(textarea.value); }); it("opens raw pending changes without sending a fake raw edit", () => { @@ -573,10 +571,9 @@ describe("config view", () => { expect(normalizedText(container)).toContain("View pending changes"); expect(normalizedText(container)).not.toContain("gateway.mode"); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(updateCount).toBe(1); @@ -648,10 +645,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(text).toContain("channels.discord.token.id"); @@ -659,9 +655,8 @@ describe("config view", () => { expect(text).not.toContain("TOKEN_BEFORE"); expect(text).not.toContain("TOKEN_AFTER"); - const revealButton = container.querySelector(".config-raw-toggle"); - expect(revealButton).not.toBeNull(); - revealButton!.click(); + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); + revealButton.click(); const revealedText = normalizedText(container); expect(revealedText).toContain("TOKEN_BEFORE"); @@ -696,13 +691,11 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); - const revealButton = container.querySelector(".config-raw-toggle"); - expect(revealButton).not.toBeNull(); - revealButton!.click(); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); + revealButton.click(); expect(normalizedText(container)).toContain("TOKEN_A_AFTER"); props.configPath = "/tmp/openclaw-b.json5"; @@ -759,10 +752,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(text).toContain("integrations.foo.bar.credential"); @@ -799,10 +791,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); expect(normalizedText(container)).toContain("gateway.mode"); props.raw = props.originalRaw; @@ -886,9 +877,8 @@ describe("config view", () => { container, ); - const rawUnavailableInput = container.querySelector(".cfg-input"); - expect(rawUnavailableInput).not.toBeNull(); - expect(rawUnavailableInput?.placeholder).toBe( + const rawUnavailableInput = queryRequired(container, ".cfg-input", HTMLInputElement); + expect(rawUnavailableInput.placeholder).toBe( "Structured value (SecretRef) - edit the config file directly", ); }); @@ -968,7 +958,7 @@ describe("config view", () => { const importButton = findButtonContainingText(container, "Import theme"); expect(importButton.disabled).toBe(true); - expect(container.querySelector(".settings-theme-import__input")).not.toBeNull(); + queryRequired(container, ".settings-theme-import__input", HTMLInputElement); expect( container.querySelector(".settings-theme-import__external")?.href, ).toBe("https://tweakcn.com/editor/theme"); From b1bfb8652070dfd361f452e030e0628857cad2a1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:37:59 +0100 Subject: [PATCH 355/806] test: tighten qqbot audio assertions --- extensions/qqbot/src/engine/utils/audio.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts index 03792b6d12e..8f1e281e150 100644 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -197,10 +197,9 @@ describe("engine/utils/audio", () => { const pcm = Buffer.from([0x01, 0x00, 0x02, 0x00]); const wav = buildMinimalWav(pcm, 24000, 1); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); - expect(result!.length).toBe(4); - expect(result![0]).toBe(0x01); - expect(result![1]).toBe(0x00); + expect(result?.length).toBe(4); + expect(result?.[0]).toBe(0x01); + expect(result?.[1]).toBe(0x00); }); it("returns null for buffers shorter than 44 bytes", () => { @@ -243,8 +242,7 @@ describe("engine/utils/audio", () => { const pcm48k = Buffer.alloc(8); const wav = buildMinimalWav(pcm48k, 48000, 1); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); - expect(result!.length).toBe(4); // 2 samples × 2 bytes + expect(result?.length).toBe(4); // 2 samples × 2 bytes }); }); }); From 62c283576762c89d96efc8a84a12a7cbf988eefb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:38:49 +0100 Subject: [PATCH 356/806] test: tighten line routing assertions --- .../line/src/bot-message-context.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/extensions/line/src/bot-message-context.test.ts b/extensions/line/src/bot-message-context.test.ts index 632f7315205..9dd718f2e05 100644 --- a/extensions/line/src/bot-message-context.test.ts +++ b/extensions/line/src/bot-message-context.test.ts @@ -202,7 +202,6 @@ describe("buildLineMessageContext", () => { commandAuthorized: false, }); - expect(context).not.toBeNull(); expect(context?.ctxPayload.CommandAuthorized).toBe(false); }); @@ -280,9 +279,8 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("line-group-agent"); - expect(context!.route.matchedBy).toBe("binding.peer"); + expect(context?.route.agentId).toBe("line-group-agent"); + expect(context?.route.matchedBy).toBe("binding.peer"); }); it("room peer binding matches raw roomId without prefix (#21907)", async () => { @@ -318,9 +316,8 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("line-room-agent"); - expect(context!.route.matchedBy).toBe("binding.peer"); + expect(context?.route.agentId).toBe("line-room-agent"); + expect(context?.route.matchedBy).toBe("binding.peer"); }); it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", () => { @@ -393,9 +390,8 @@ describe("buildLineMessageContext", () => { commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("codex"); - expect(context!.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123"); - expect(context!.route.matchedBy).toBe("binding.channel"); + expect(context?.route.agentId).toBe("codex"); + expect(context?.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123"); + expect(context?.route.matchedBy).toBe("binding.channel"); }); }); From 18b6015d71bc25a5db50334a44e359d4d2c14895 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:40:13 +0100 Subject: [PATCH 357/806] test: tighten telegram topic agent assertions --- .../telegram/src/bot-message-context.topic-agentid.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts index 93cae5ee7de..0a90778d18a 100644 --- a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -63,7 +63,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { it("uses group-level agent when no topic agentId is set", async () => { const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); }); @@ -72,7 +71,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:"); expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3"); }); @@ -98,7 +96,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { topicConfig: { agentId: " ", systemPrompt: "Be nice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); }); @@ -113,7 +110,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:"); }); @@ -138,7 +134,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:"); }); }); From 9ce5a6db5b84e3546d8b28949dda09d1561435cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:40:26 +0100 Subject: [PATCH 358/806] test: require grouped render elements --- ui/src/ui/chat/grouped-render.test.ts | 62 ++++++++++++++++++--------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 1ec219af077..e51671d6256 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -91,6 +91,19 @@ vi.mock("../tool-display.ts", () => ({ type RenderMessageGroupOptions = Parameters[1]; +function expectElement( + container: Element, + selector: string, + constructor: new () => T, +): T { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; +} + function renderAssistantMessage( container: HTMLElement, message: unknown, @@ -292,6 +305,15 @@ function getLastCaptureClickListener(calls: readonly unknown[][]) { return null; } +function expectLastCaptureClickListener(calls: readonly unknown[][]): unknown { + const listener = getLastCaptureClickListener(calls); + expect(listener).toEqual(expect.any(Function)); + if (listener === null) { + throw new Error("Expected capture click listener"); + } + return listener; +} + function countCaptureClickListenerRemovals(calls: readonly unknown[][], listener: unknown) { return calls.filter( ([type, removedListener, options]) => @@ -345,8 +367,7 @@ function setupArmedDeleteConfirm() { openDeleteConfirm(fixture.deleteButton); flushAnimationFrames(); - const outsideClickListener = getLastCaptureClickListener(addListenerSpy.mock.calls); - expect(outsideClickListener).not.toBeNull(); + const outsideClickListener = expectLastCaptureClickListener(addListenerSpy.mock.calls); expect(fixture.container.querySelectorAll(".chat-delete-confirm")).toHaveLength(1); return { ...fixture, outsideClickListener, removeListenerSpy }; @@ -712,7 +733,7 @@ describe("grouped chat rendering", () => { isToolMessageExpanded: () => false, }); - expect(container.querySelector(".chat-bubble--tool-shell")).not.toBeNull(); + expectElement(container, ".chat-bubble--tool-shell", HTMLElement); const summary = container.querySelector(".chat-tool-msg-summary"); expect(summary?.textContent).toContain("Tool call"); expect(container.textContent).not.toContain('"thread": true'); @@ -845,8 +866,8 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-reply-pill")?.textContent).toContain( "Replying to current message", ); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); - expect(container.querySelector("audio")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); + expectElement(container, "audio", HTMLAudioElement); expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent).toContain( "Voice note", ); @@ -1085,8 +1106,8 @@ describe("grouped chat rendering", () => { { showToolCalls: false }, ); - expect(container.querySelector(".chat-bubble")).not.toBeNull(); - expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); + expectElement(container, ".chat-bubble", HTMLElement); + expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); expect(container.textContent).toContain("Tic-Tac-Toe"); }); @@ -1297,7 +1318,7 @@ describe("grouped chat rendering", () => { "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&meta=1", expect.objectContaining({ credentials: "same-origin", method: "GET" }), ); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); expect( container.querySelector(".chat-message-image")?.getAttribute("src"), ).toBe( @@ -1487,7 +1508,7 @@ describe("grouped chat rendering", () => { await flushAssistantAttachmentAvailabilityChecks(); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); expect(container.textContent).not.toContain("Unavailable"); vi.useRealTimers(); @@ -1568,12 +1589,12 @@ describe("grouped chat rendering", () => { { showToolCalls: true }, ); - const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); expect(allPreviews).toHaveLength(1); - expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); - expect(assistantBubble?.textContent).toContain("This item is ready."); - expect(assistantBubble?.textContent).toContain("Live history preview"); + const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement); + expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(bubble.textContent).toContain("This item is ready."); + expect(bubble.textContent).toContain("Live history preview"); }); it("renders hidden assistant_message canvas results with the configured sandbox", () => { @@ -1602,10 +1623,9 @@ describe("grouped chat rendering", () => { renderCanvas({ suffix: "default" }); - let iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe?.getAttribute("src")).toBe( + let iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); + expect(iframe.getAttribute("src")).toBe( "/__openclaw__/canvas/documents/cv_inline_default/index.html", ); expect(container.textContent).toContain("Inline canvas result."); @@ -1613,8 +1633,8 @@ describe("grouped chat rendering", () => { expect(container.textContent).toContain("Raw details"); renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" }); - iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); + iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); }); it("renders assistant_message canvas results in the assistant bubble even when tool rows are visible", () => { @@ -1663,10 +1683,10 @@ describe("grouped chat rendering", () => { }, ); - const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); expect(allPreviews).toHaveLength(1); - expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); + const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement); + expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement); expect(container.textContent).toContain("Tool output"); expect(container.textContent).toContain("canvas_render"); expect(container.textContent).toContain("Inline canvas result."); From 8c8dc84aadb0344dc5d141c708eb7aa4f52fc4ac Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:41:12 +0100 Subject: [PATCH 359/806] test: tighten telegram forward context assertions --- extensions/telegram/src/bot/helpers.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index 9a75bbe8b26..b44dabdcf53 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -225,7 +225,6 @@ describe("normalizeForwardedContext", () => { date: 123, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Ada Lovelace (@ada)"); expect(ctx?.fromType).toBe("user"); expect(ctx?.fromId).toBe("42"); @@ -238,7 +237,6 @@ describe("normalizeForwardedContext", () => { const ctx = normalizeForwardedContext({ forward_origin: { type: "hidden_user", sender_user_name: "Hidden Name", date: 456 }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Hidden Name"); expect(ctx?.fromType).toBe("hidden_user"); expect(ctx?.fromTitle).toBe("Hidden Name"); @@ -260,7 +258,6 @@ describe("normalizeForwardedContext", () => { message_id: 42, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Tech News (Editor)"); expect(ctx?.fromType).toBe("channel"); expect(ctx?.fromId).toBe("-1001234"); @@ -285,7 +282,6 @@ describe("normalizeForwardedContext", () => { author_signature: "Admin", }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Discussion Group (Admin)"); expect(ctx?.fromType).toBe("chat"); expect(ctx?.fromId).toBe("-1005678"); @@ -305,7 +301,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.fromSignature).toBe("New Sig"); expect(ctx?.from).toBe("My Channel (New Sig)"); }); @@ -320,7 +315,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.fromSignature).toBeUndefined(); expect(ctx?.from).toBe("Updates"); }); @@ -334,7 +328,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("News"); expect(ctx?.fromSignature).toBeUndefined(); expect(ctx?.fromChatType).toBe("channel"); From f1ba8da3954f1d2b3cf4fd322cf233e01b0658ad Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:42:00 +0100 Subject: [PATCH 360/806] test: tighten telegram reply target assertions --- extensions/telegram/src/bot/helpers.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index b44dabdcf53..ef4c7ce8be9 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -357,7 +357,6 @@ describe("describeReplyTarget", () => { from: { id: 42, first_name: "Alice", is_bot: false }, }, } as any); - expect(result).not.toBeNull(); expect(result?.body).toBe("Original message"); expect(result?.sender).toBe("Alice"); expect(result?.id).toBe("1"); @@ -486,7 +485,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.body).toBe("This is the forwarded content"); expect(result?.id).toBe("2"); expect(result?.forwardedFrom).toMatchObject({ @@ -517,7 +515,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.forwardedFrom).toMatchObject({ from: "Tech News (Editor)", fromType: "channel", @@ -577,7 +574,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.id).toBe("4"); expect(result?.forwardedFrom?.from).toBe("Eve Stone (@eve)"); expect(result?.forwardedFrom?.fromType).toBe("user"); From e6fa674b75ddcee5bc39433f5e74666f77ce0298 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:42:20 +0100 Subject: [PATCH 361/806] test: tighten parser null assertions --- src/infra/npm-registry-spec.test.ts | 7 +++++-- src/logging/parse-log-line.test.ts | 13 +++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index 3b0a3ca6ef1..ff75dea03ba 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -12,8 +12,11 @@ import { function parseSpecOrThrow(spec: string) { const parsed = parseRegistryNpmSpec(spec); - expect(parsed).not.toBeNull(); - return parsed!; + expect(parsed).toEqual(expect.any(Object)); + if (parsed === null) { + throw new Error(`Expected ${spec} to parse`); + } + return parsed; } describe("npm registry spec validation", () => { diff --git a/src/logging/parse-log-line.test.ts b/src/logging/parse-log-line.test.ts index 46465e11f4a..4db1b4c31b7 100644 --- a/src/logging/parse-log-line.test.ts +++ b/src/logging/parse-log-line.test.ts @@ -15,12 +15,13 @@ describe("parseLogLine", () => { const parsed = parseLogLine(line); - expect(parsed).not.toBeNull(); - expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); - expect(parsed?.level).toBe("info"); - expect(parsed?.subsystem).toBe("gateway/channels/demo-channel"); - expect(parsed?.message).toBe('{"subsystem":"gateway/channels/demo-channel"} connected'); - expect(parsed?.raw).toBe(line); + expect(parsed).toMatchObject({ + time: "2026-01-09T01:38:41.523Z", + level: "info", + subsystem: "gateway/channels/demo-channel", + message: '{"subsystem":"gateway/channels/demo-channel"} connected', + raw: line, + }); }); it("falls back to meta timestamp when top-level time is missing", () => { From 69b43a71b82dfbf9c2e408de8c496caeef77c2c4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:43:07 +0100 Subject: [PATCH 362/806] test: tighten telegram dm thread assertions --- .../telegram/src/bot-message-context.dm-threads.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index c42ea232b21..3cb7b0b9c04 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -81,7 +81,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); @@ -104,7 +103,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }, ); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -136,7 +134,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }, ); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -173,7 +170,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -187,7 +183,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); From 2866eeb1a6a8ff368fe5a4609567ebdf4ccccb54 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:44:12 +0100 Subject: [PATCH 363/806] test: tighten telegram topic name assertions --- .../telegram/src/bot-message-context.dm-threads.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index 3cb7b0b9c04..33a46b7a23c 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -233,8 +233,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctxWithThread).not.toBeNull(); - expect(ctxWithoutThread).not.toBeNull(); // Both messages should use the same session key expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey); }); @@ -256,7 +254,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: { resolveStorePath }, }); - expect(ctx).not.toBeNull(); expect(ctx?.isForum).toBe(false); expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); expect(resolveStorePath).toHaveBeenCalledTimes(1); @@ -272,7 +269,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); // Session key SHOULD include :topic:99 for forums expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); @@ -292,7 +288,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); }); @@ -315,7 +310,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: null, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); }); @@ -357,7 +351,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -405,7 +398,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: null, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); From 46214d973f0762e25baa7f7ab0b3d9a5fa9e5b23 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:45:24 +0100 Subject: [PATCH 364/806] test: tighten telegram route thread assertions --- .../src/bot-message-context.dm-topic-threadid.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index 955e8f3d998..3ca63a75c15 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -67,7 +67,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload).toBeDefined(); expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234", threadId: "42" }); @@ -80,7 +80,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload).toBeDefined(); expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234" }); @@ -97,7 +97,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload).toBeDefined(); expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:99", threadId: "99" }); @@ -113,7 +113,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload).toBeDefined(); expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:1", threadId: "1" }); From 9c584567b342c4a64f239edc0b2a8b19e3e2223c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:46:37 +0100 Subject: [PATCH 365/806] test: tighten telegram reaction assertions --- extensions/telegram/src/bot-message-context.reactions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/telegram/src/bot-message-context.reactions.test.ts b/extensions/telegram/src/bot-message-context.reactions.test.ts index 88c939cb089..8c4b2ba809b 100644 --- a/extensions/telegram/src/bot-message-context.reactions.test.ts +++ b/extensions/telegram/src/bot-message-context.reactions.test.ts @@ -101,7 +101,6 @@ describe("buildTelegramMessageContext reactions", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ackReactionPromise).toBeNull(); expect(ctx?.statusReactionController).toBeNull(); expect(createStatusReactionController).not.toHaveBeenCalled(); From 8bf721f307e9b5857d4e1ec32df8abfb803adf53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:46:52 +0100 Subject: [PATCH 366/806] test: tighten extension media assertions --- extensions/openai/media-understanding-provider.test.ts | 9 +++------ extensions/qa-channel/src/channel.test.ts | 2 +- extensions/qa-lab/src/gateway-child.test.ts | 2 +- .../senseaudio/media-understanding-provider.test.ts | 9 +++------ 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/extensions/openai/media-understanding-provider.test.ts b/extensions/openai/media-understanding-provider.test.ts index 602f1498af7..ac9049b7505 100644 --- a/extensions/openai/media-understanding-provider.test.ts +++ b/extensions/openai/media-understanding-provider.test.ts @@ -75,12 +75,9 @@ describe("transcribeOpenAiAudio", () => { expect(form.get("language")).toBe("en"); expect(form.get("prompt")).toBe("hello"); const file = form.get("file") as Blob | { type?: string; name?: string } | null; - expect(file).not.toBeNull(); - if (file) { - expect(file.type).toBe("audio/wav"); - if ("name" in file && typeof file.name === "string") { - expect(file.name).toBe("voice.wav"); - } + expect(file).toEqual(expect.objectContaining({ type: "audio/wav" })); + if (file && "name" in file && typeof file.name === "string") { + expect(file.name).toBe("voice.wav"); } }); diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 836a253599b..19787a4a192 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -22,7 +22,7 @@ function installQaChannelTestRegistry() { } function expectDispatchedContext(ctx: Record | null): Record { - expect(ctx).not.toBeNull(); + expect(ctx).toEqual(expect.any(Object)); if (ctx === null) { throw new Error("Expected dispatched context"); } diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index df644bb0d1b..8498fa9d9fc 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -972,7 +972,7 @@ describe("qa bundled plugin dir", () => { expect(stagedRoot).toBe( path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)), ); - await expect(readFile(path.join(stagedRoot!, "package.json"), "utf8")).resolves.toContain( + await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain( '"name": "openclaw"', ); await expect( diff --git a/extensions/senseaudio/media-understanding-provider.test.ts b/extensions/senseaudio/media-understanding-provider.test.ts index a70dd16f68e..60614c682bb 100644 --- a/extensions/senseaudio/media-understanding-provider.test.ts +++ b/extensions/senseaudio/media-understanding-provider.test.ts @@ -78,12 +78,9 @@ describe("transcribeSenseAudioAudio", () => { expect(form.get("language")).toBe("en"); expect(form.get("prompt")).toBe("hello"); const file = form.get("file") as Blob | { type?: string; name?: string } | null; - expect(file).not.toBeNull(); - if (file) { - expect(file.type).toBe("audio/wav"); - if ("name" in file && typeof file.name === "string") { - expect(file.name).toBe("voice.wav"); - } + expect(file).toEqual(expect.objectContaining({ type: "audio/wav" })); + if (file && "name" in file && typeof file.name === "string") { + expect(file.name).toBe("voice.wav"); } }); From faceeb8cd65a9d5744c6745ebf7eb8731ba7d2e6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:47:20 +0100 Subject: [PATCH 367/806] test: tighten telegram acp binding assertions --- extensions/telegram/src/bot-message-context.acp-bindings.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 896c7dd6930..77007aa6e10 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -165,7 +165,6 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }, }); - expect(ctx).not.toBeNull(); expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); From fbf71abcfdd13dd08f56dca4d8055150d9c1f00e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:48:32 +0100 Subject: [PATCH 368/806] test: tighten telegram thread binding assertion --- .../telegram/src/bot-message-context.thread-binding.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/telegram/src/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts index db38e7921df..e2b131bfc77 100644 --- a/extensions/telegram/src/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -119,7 +119,6 @@ describe("buildTelegramMessageContext thread binding override", () => { senderId: "42", }), ); - expect(ctx).not.toBeNull(); expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-2"); From 03ac05a3cde8542aae4b771d49003da1ce7b0741 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:48:37 +0100 Subject: [PATCH 369/806] test: tighten core helper assertions --- src/entry.respawn.test.ts | 32 ++++++++++++------- .../command-analysis/inline-eval.test.ts | 15 ++++++--- src/process/supervisor/registry.test.ts | 26 +++++++++------ 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts index 8d5eb8b940f..1d2f560994d 100644 --- a/src/entry.respawn.test.ts +++ b/src/entry.respawn.test.ts @@ -10,6 +10,16 @@ import { runCliRespawnPlan, } from "./entry.respawn.js"; +type CliRespawnPlan = NonNullable>; + +function expectCliRespawnPlan(plan: ReturnType): CliRespawnPlan { + expect(plan).toEqual(expect.any(Object)); + if (plan === null) { + throw new Error("Expected CLI respawn plan"); + } + return plan; +} + describe("buildCliRespawnPlan", () => { it("returns null when respawn policy skips the argv", () => { expect( @@ -30,12 +40,12 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan).not.toBeNull(); - expect(plan?.command).toBe(process.execPath); - expect(plan?.argv[0]).toBe(EXPERIMENTAL_WARNING_FLAG); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); - expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); - expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1"); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.command).toBe(process.execPath); + expect(respawnPlan.argv[0]).toBe(EXPERIMENTAL_WARNING_FLAG); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(respawnPlan.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); + expect(respawnPlan.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1"); }); it.each(["tui", "terminal", "chat"] as const)( @@ -48,11 +58,11 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan).not.toBeNull(); - expect(plan?.argv).toEqual(["openclaw", command]); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); - expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); - expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBeUndefined(); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.argv).toEqual(["openclaw", command]); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(respawnPlan.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); + expect(respawnPlan.env[OPENCLAW_NODE_OPTIONS_READY]).toBeUndefined(); }, ); diff --git a/src/infra/command-analysis/inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts index 8c684da53c6..2bdae3e039c 100644 --- a/src/infra/command-analysis/inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from "vitest"; +import type { InterpreterInlineEvalHit } from "./inline-eval.js"; import { describeInterpreterInlineEval, detectInterpreterInlineEvalArgv, isInterpreterLikeAllowlistPattern, } from "./inline-eval.js"; +function expectInlineEvalDescription(hit: InterpreterInlineEvalHit | null, expected: string) { + expect(hit).toEqual(expect.any(Object)); + if (hit === null) { + throw new Error(`Expected inline eval hit for ${expected}`); + } + expect(describeInterpreterInlineEval(hit)).toBe(expected); +} + describe("exec inline eval detection", () => { it.each([ { argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" }, @@ -15,8 +24,7 @@ describe("exec inline eval detection", () => { { argv: ["gawk", "-F", ",", "{print $1}", "data.csv"], expected: "gawk inline program" }, ] as const)("detects interpreter eval flags for %j", ({ argv, expected }) => { const hit = detectInterpreterInlineEvalArgv([...argv]); - expect(hit).not.toBeNull(); - expect(describeInterpreterInlineEval(hit!)).toBe(expected); + expectInlineEvalDescription(hit, expected); }); it.each([ @@ -46,8 +54,7 @@ describe("exec inline eval detection", () => { { argv: ["sed", "-es/.*/id/e", "/dev/null"], expected: "sed -e" }, ] as const)("detects command carriers for %j", ({ argv, expected }) => { const hit = detectInterpreterInlineEvalArgv([...argv]); - expect(hit).not.toBeNull(); - expect(describeInterpreterInlineEval(hit!)).toBe(expected); + expectInlineEvalDescription(hit, expected); }); it("ignores normal script execution", () => { diff --git a/src/process/supervisor/registry.test.ts b/src/process/supervisor/registry.test.ts index 27206374a74..59bea5a8b8f 100644 --- a/src/process/supervisor/registry.test.ts +++ b/src/process/supervisor/registry.test.ts @@ -42,17 +42,23 @@ describe("process supervisor run registry", () => { exitSignal: null, }); - expect(first).not.toBeNull(); - expect(first?.firstFinalize).toBe(true); - expect(first?.record.terminationReason).toBe("overall-timeout"); - expect(first?.record.exitCode).toBeNull(); - expect(first?.record.exitSignal).toBe("SIGKILL"); + expect(first).toEqual({ + firstFinalize: true, + record: expect.objectContaining({ + terminationReason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + }), + }); - expect(second).not.toBeNull(); - expect(second?.firstFinalize).toBe(false); - expect(second?.record.terminationReason).toBe("overall-timeout"); - expect(second?.record.exitCode).toBeNull(); - expect(second?.record.exitSignal).toBe("SIGKILL"); + expect(second).toEqual({ + firstFinalize: false, + record: expect.objectContaining({ + terminationReason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + }), + }); }); it("prunes oldest exited records once retention cap is exceeded", () => { From e554bf737675a63cf19da1f1fdfea6dc5acb5390 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:49:15 +0100 Subject: [PATCH 370/806] test: tighten telegram mention assertions --- .../telegram/src/bot-message-context.require-mention.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts index deed88a9901..90a7de251a8 100644 --- a/extensions/telegram/src/bot-message-context.require-mention.test.ts +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -72,7 +72,7 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload).toBeDefined(); expect(resolveGroupActivation).toHaveBeenCalledWith( expect.objectContaining({ chatId: -1001234567890, From 15ad70356c5f92b78bb44c251a2b90dbd45d1e42 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:51:01 +0100 Subject: [PATCH 371/806] test: tighten telegram media retry assertions --- .../telegram/src/bot/delivery.resolve-media-retry.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index b6a9da89908..abad770a277 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -294,7 +294,7 @@ describe("resolveMedia getFile retry", () => { it("still retries transient errors even after encountering file too big in different call", async () => { const result = await expectTransientGetFileRetrySuccess(); // Should retry transient errors. - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_0.oga"); }); it("retries getFile for stickers on transient failure", async () => { @@ -368,7 +368,7 @@ describe("resolveMedia getFile retry", () => { transport: callerTransport, }); - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_42---uuid.pdf"); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ fetchImpl: callerFetch, @@ -402,7 +402,7 @@ describe("resolveMedia getFile retry", () => { transport: callerTransport, }); - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_0.webp"); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ fetchImpl: callerFetch, From ddaf9178c5ac6417a44dcf722ac015ed22ceca03 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:51:10 +0100 Subject: [PATCH 372/806] test: tighten extension helper assertions --- .../canvas/src/host/file-resolver.test.ts | 18 +++++- .../codex/src/app-server/trajectory.test.ts | 33 +++++++--- .../discord/src/monitor/presence.test.ts | 61 ++++++++++++------- extensions/memory-wiki/src/tool.test.ts | 6 +- 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/extensions/canvas/src/host/file-resolver.test.ts b/extensions/canvas/src/host/file-resolver.test.ts index 800a189b318..819d62306ff 100644 --- a/extensions/canvas/src/host/file-resolver.test.ts +++ b/extensions/canvas/src/host/file-resolver.test.ts @@ -4,6 +4,8 @@ import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plug import { describe, expect, it } from "vitest"; import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js"; +type ResolvedFile = NonNullable>>; + async function withCanvasTemp(prefix: string, run: (dir: string) => Promise): Promise { return await withTempWorkspace( { rootDir: resolvePreferredOpenClawTmpDir(), prefix }, @@ -11,6 +13,16 @@ async function withCanvasTemp(prefix: string, run: (dir: string) => Promise>, +): ResolvedFile { + expect(result).toEqual(expect.objectContaining({ handle: expect.any(Object) })); + if (result === null) { + throw new Error("Expected resolved file within root"); + } + return result; +} + describe("resolveFileWithinRoot", () => { it("normalizes URL paths", () => { expect(normalizeUrlPath("/nested/../file.txt")).toBe("/file.txt"); @@ -23,11 +35,11 @@ describe("resolveFileWithinRoot", () => { await fs.writeFile(path.join(root, "docs", "index.html"), "

docs

"); const result = await resolveFileWithinRoot(root, "/docs"); - expect(result).not.toBeNull(); + const resolved = expectResolvedFile(result); try { - await expect(result?.handle.readFile({ encoding: "utf8" })).resolves.toBe("

docs

"); + await expect(resolved.handle.readFile({ encoding: "utf8" })).resolves.toBe("

docs

"); } finally { - await result?.handle.close().catch(() => {}); + await resolved.handle.close().catch(() => {}); } }); }); diff --git a/extensions/codex/src/app-server/trajectory.test.ts b/extensions/codex/src/app-server/trajectory.test.ts index 91b0e3a3076..ab611db3a73 100644 --- a/extensions/codex/src/app-server/trajectory.test.ts +++ b/extensions/codex/src/app-server/trajectory.test.ts @@ -8,6 +8,8 @@ import { resolveCodexTrajectoryPointerFlags, } from "./trajectory.js"; +type CodexTrajectoryRecorder = NonNullable>; + const tempDirs: string[] = []; function makeTempDir(): string { @@ -22,6 +24,16 @@ afterEach(() => { } }); +function expectTrajectoryRecorder( + recorder: ReturnType, +): CodexTrajectoryRecorder { + expect(recorder).toEqual(expect.objectContaining({ recordEvent: expect.any(Function) })); + if (recorder === null) { + throw new Error("Expected Codex trajectory recorder"); + } + return recorder; +} + describe("Codex trajectory recorder", () => { it("keeps write flags usable when O_NOFOLLOW is unavailable", () => { const constants = { @@ -52,13 +64,13 @@ describe("Codex trajectory recorder", () => { env: {}, }); - expect(recorder).not.toBeNull(); - recorder?.recordEvent("session.started", { + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started", { apiKey: "secret", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", }); - await recorder?.flush(); + await trajectoryRecorder.flush(); const filePath = path.join(tmpDir, "session.trajectory.jsonl"); const content = fs.readFileSync(filePath, "utf8"); @@ -82,8 +94,9 @@ describe("Codex trajectory recorder", () => { env: { OPENCLAW_TRAJECTORY_DIR: tmpDir }, }); - recorder?.recordEvent("session.started"); - await recorder?.flush(); + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started"); + await trajectoryRecorder.flush(); expect(fs.existsSync(path.join(tmpDir, "___evil_session.jsonl"))).toBe(true); }); @@ -119,8 +132,9 @@ describe("Codex trajectory recorder", () => { env: {}, }); - recorder?.recordEvent("session.started"); - await recorder?.flush(); + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started"); + await trajectoryRecorder.flush(); expect(fs.existsSync(path.join(targetDir, "session.trajectory.jsonl"))).toBe(false); }); @@ -137,12 +151,13 @@ describe("Codex trajectory recorder", () => { env: {}, }); - recorder?.recordEvent("context.compiled", { + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("context.compiled", { fields: Object.fromEntries( Array.from({ length: 100 }, (_, index) => [`field-${index}`, "x".repeat(3_000)]), ), }); - await recorder?.flush(); + await trajectoryRecorder.flush(); const parsed = JSON.parse( fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"), diff --git a/extensions/discord/src/monitor/presence.test.ts b/extensions/discord/src/monitor/presence.test.ts index 1ea06f9dc28..6c1d117105b 100644 --- a/extensions/discord/src/monitor/presence.test.ts +++ b/extensions/discord/src/monitor/presence.test.ts @@ -1,44 +1,61 @@ import { describe, expect, it } from "vitest"; import { resolveDiscordPresenceUpdate } from "./presence.js"; +type DiscordPresenceUpdate = NonNullable>; + +function expectPresenceUpdate( + result: ReturnType, +): DiscordPresenceUpdate { + expect(result).toEqual(expect.objectContaining({ activities: expect.any(Array) })); + if (result === null) { + throw new Error("Expected Discord presence update"); + } + return result; +} + describe("resolveDiscordPresenceUpdate", () => { it("returns online presence when no config is provided", () => { - const result = resolveDiscordPresenceUpdate({}); - expect(result).not.toBeNull(); - expect(result!.status).toBe("online"); - expect(result!.activities).toEqual([]); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({})); + expect(result.status).toBe("online"); + expect(result.activities).toEqual([]); }); it("uses configured status", () => { - const result = resolveDiscordPresenceUpdate({ status: "dnd" }); - expect(result!.status).toBe("dnd"); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({ status: "dnd" })); + expect(result.status).toBe("dnd"); }); it("includes activity when configured", () => { - const result = resolveDiscordPresenceUpdate({ activity: "Helping humans" }); - expect(result!.status).toBe("online"); - expect(result!.activities).toHaveLength(1); - expect(result!.activities[0].state).toBe("Helping humans"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ activity: "Helping humans" }), + ); + expect(result.status).toBe("online"); + expect(result.activities).toHaveLength(1); + expect(result.activities[0].state).toBe("Helping humans"); }); it("uses custom activity type by default", () => { - const result = resolveDiscordPresenceUpdate({ activity: "test" }); - expect(result!.activities[0].type).toBe(4); - expect(result!.activities[0].name).toBe("Custom Status"); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({ activity: "test" })); + expect(result.activities[0].type).toBe(4); + expect(result.activities[0].name).toBe("Custom Status"); }); it("respects explicit activityType", () => { - const result = resolveDiscordPresenceUpdate({ activity: "test", activityType: 3 }); - expect(result!.activities[0].type).toBe(3); - expect(result!.activities[0].name).toBe("test"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ activity: "test", activityType: 3 }), + ); + expect(result.activities[0].type).toBe(3); + expect(result.activities[0].name).toBe("test"); }); it("sets streaming URL for type 1", () => { - const result = resolveDiscordPresenceUpdate({ - activity: "Live", - activityType: 1, - activityUrl: "https://twitch.tv/test", - }); - expect(result!.activities[0].url).toBe("https://twitch.tv/test"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ + activity: "Live", + activityType: 1, + activityUrl: "https://twitch.tv/test", + }), + ); + expect(result.activities[0].url).toBe("https://twitch.tv/test"); }); }); diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index 9fd0acf780e..5c0888862b0 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,9 +3,11 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { - expect(typeof value).toBe("object"); - expect(value).not.toBeNull(); + expect(value).toEqual(expect.any(Object)); expect(Array.isArray(value)).toBe(false); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Expected JSON schema object"); + } return value as Record; } From 7cc0b21e4df5d5a994f49085b5893d1e5b270670 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:23:37 +0100 Subject: [PATCH 373/806] test: restore node 26 test compatibility --- .../browser/src/browser/pw-session.test.ts | 7 ++++ ...-core.waits-next-download-saves-it.test.ts | 6 ++- .../browser/src/browser/server-middleware.ts | 11 ++++- .../server.agent-contract-core.test.ts | 2 +- .../browser/src/sdk-security-runtime.ts | 38 ++++++++++++++++- .../monitor/message-handler.process.test.ts | 2 +- .../src/reply-stream-controller.test.ts | 4 +- extensions/qqbot/src/engine/utils/stt.test.ts | 21 ++-------- .../tts-local-cli/speech-provider.test.ts | 2 +- .../bash-tools.exec-host-node-phases.ts | 5 ++- src/agents/harness-runtimes.ts | 32 ++++++++++++++- .../missing-configured-plugin-install.ts | 5 ++- src/infra/fs-safe.ts | 41 ++++++++++++++++++- test/scripts/bench-gateway-startup.test.ts | 5 ++- ui/src/ui/app-tool-stream.node.test.ts | 28 +++++++++---- ui/src/ui/gateway.node.test.ts | 2 + 16 files changed, 170 insertions(+), 41 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index 2d41cf97d08..0568d911e74 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -168,6 +168,13 @@ describe("pw-session ensurePageState", () => { expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/); expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA); expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB); + for (const call of [saveAsA.mock.calls[0], saveAsB.mock.calls[0]]) { + const savedParentName = path.basename(path.dirname(String(call?.[0]))); + expect( + savedParentName.includes("fs-safe-output") || + savedParentName === path.basename(DEFAULT_DOWNLOAD_DIR), + ).toBe(true); + } await expect(fs.readFile(managedPathA ?? "", "utf8")).resolves.toBe("download-a"); await expect(fs.readFile(managedPathB ?? "", "utf8")).resolves.toBe("download-b"); }); diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 80ff45f8041..17b4efeded8 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -137,7 +137,11 @@ describe("pw-tools-core", () => { const savedPath = params.saveAs.mock.calls[0]?.[0]; expect(typeof savedPath).toBe("string"); expect(savedPath).not.toBe(params.targetPath); - expect(path.basename(path.dirname(String(savedPath)))).toContain("fs-safe-output"); + const savedParentName = path.basename(path.dirname(String(savedPath))); + expect( + savedParentName.includes("fs-safe-output") || + savedParentName === path.basename(path.dirname(params.targetPath)), + ).toBe(true); expect(path.basename(String(savedPath))).toContain(path.basename(params.targetPath)); expect(path.basename(String(savedPath))).toMatch(/\.part$/); expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content); diff --git a/extensions/browser/src/browser/server-middleware.ts b/extensions/browser/src/browser/server-middleware.ts index 3efd67e352e..d245069fccb 100644 --- a/extensions/browser/src/browser/server-middleware.ts +++ b/extensions/browser/src/browser/server-middleware.ts @@ -27,8 +27,15 @@ export function installBrowserCommonMiddleware(app: Express) { abort(); } }); - // Make the signal available to browser route handlers (best-effort). - (req as unknown as { signal?: AbortSignal }).signal = ctrl.signal; + // Make the signal available to browser route handlers on Node versions + // whose IncomingMessage does not already expose a native read-only signal. + const requestWithSignal = req as Request & { signal?: AbortSignal }; + if (!(requestWithSignal.signal instanceof AbortSignal)) { + Object.defineProperty(req, "signal", { + value: ctrl.signal, + configurable: true, + }); + } next(); }); app.use(express.json({ limit: "1mb" })); diff --git a/extensions/browser/src/browser/server.agent-contract-core.test.ts b/extensions/browser/src/browser/server.agent-contract-core.test.ts index 957c1d634bf..0050894a935 100644 --- a/extensions/browser/src/browser/server.agent-contract-core.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -51,7 +51,7 @@ const pwMocks = getPwMocks(); describe("browser control server", () => { installAgentContractHooks(); - const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000; + const slowTimeoutMs = 60_000; it( "returns ACT_KIND_REQUIRED when kind is missing", diff --git a/extensions/browser/src/sdk-security-runtime.ts b/extensions/browser/src/sdk-security-runtime.ts index f64a1d4b641..d636d657cb2 100644 --- a/extensions/browser/src/sdk-security-runtime.ts +++ b/extensions/browser/src/sdk-security-runtime.ts @@ -1,9 +1,15 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + findExistingAncestor, + pathScope as sdkPathScope, +} from "openclaw/plugin-sdk/security-runtime"; + export { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; export { ensurePortAvailable, extractErrorCode, formatErrorMessage, - ensureAbsoluteDirectory, hasProxyEnvConfigured, isNotFoundPathError, isPathInside, @@ -28,3 +34,33 @@ export { wrapExternalContent, } from "openclaw/plugin-sdk/security-runtime"; export type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/security-runtime"; + +export async function ensureAbsoluteDirectory( + dirPath: string, + options?: { scopeLabel?: string; mode?: number }, +): Promise<{ ok: true; path: string } | { ok: false; error: Error }> { + const absolutePath = path.resolve(dirPath); + const scopeLabel = options?.scopeLabel ?? "directory"; + const existingAncestor = await findExistingAncestor(absolutePath); + if (!existingAncestor) { + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + if (existingAncestor === absolutePath) { + try { + const stat = await fs.lstat(absolutePath); + if (!stat.isSymbolicLink() && stat.isDirectory()) { + return { ok: true, path: absolutePath }; + } + } catch { + // Fall through to the uniform invalid-path result below. + } + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + const result = await sdkPathScope(existingAncestor, { + label: options?.scopeLabel ?? "directory", + }).ensureDir(path.relative(existingAncestor, absolutePath), { mode: options?.mode }); + if (result.ok) { + return result; + } + return { ok: false, error: new Error(result.error) }; +} diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 16343b8c7b4..3ad9e08f0f9 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1680,7 +1680,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 3d4ebe9b4fb..316e0f059ea 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,7 +320,9 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( + "Working\n- tool: exec", + ); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 66f7459d70c..1461bcf1d88 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -2,7 +2,6 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveSTTConfig, transcribeAudio } from "./stt.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); @@ -10,6 +9,8 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); +import { resolveSTTConfig, transcribeAudio } from "./stt.js"; + describe("engine/utils/stt", () => { afterEach(() => { fetchWithSsrFGuardMock.mockReset(); @@ -102,8 +103,8 @@ describe("engine/utils/stt", () => { expect(transcript).toBe("hello from audio"); expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( expect.objectContaining({ - auditContext: "qqbot-stt", url: "https://api.example.test/v1/audio/transcriptions", + auditContext: "qqbot-stt", init: expect.objectContaining({ method: "POST", headers: { Authorization: "Bearer secret" }, @@ -112,21 +113,5 @@ describe("engine/utils/stt", () => { }), ); expect(release).toHaveBeenCalledTimes(1); - const [{ init }] = fetchWithSsrFGuardMock.mock.calls[0] as [ - { - init: { - body: FormData; - headers: Record; - method: string; - }; - }, - ]; - expect(init).toEqual( - expect.objectContaining({ - method: "POST", - headers: { Authorization: "Bearer secret" }, - body: expect.any(FormData), - }), - ); }); }); diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts index b0d9ca23f26..4537e41e2d3 100644 --- a/extensions/tts-local-cli/speech-provider.test.ts +++ b/extensions/tts-local-cli/speech-provider.test.ts @@ -90,7 +90,7 @@ describe("buildCliSpeechProvider", () => { ? ".pcm" : forcedFormat ? `.${forcedFormat}` - : path.extname(outputPath); + : path.extname(outputPath.replace(/\.part$/, "")); writeFileSync(outputPath, Buffer.from(`converted:${extension}`)); }); }); diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 7b7654a0928..1e38b355a01 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -247,9 +247,10 @@ function buildLocalPreparedNodeRun(params: { request: ExecuteNodeHostCommandParams; target: NodeExecutionTarget; }): PreparedNodeRun { + const rawCommand = formatExecCommand(params.target.argv); const command = resolveSystemRunCommandRequest({ command: params.target.argv, - rawCommand: params.request.command, + rawCommand, }); if (!command.ok) { throw new Error(command.message); @@ -258,7 +259,7 @@ function buildLocalPreparedNodeRun(params: { throw new Error("command required"); } const commandText = formatExecCommand(command.argv); - const previewText = command.previewText?.trim(); + const previewText = params.request.command.trim() || command.previewText?.trim(); const commandPreview = previewText && previewText !== commandText ? previewText : null; const plan = { argv: [...command.argv], diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index bfdf17b02c0..3583d318db4 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -107,19 +107,49 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set): void { + const pushRuntimeId = (value: unknown) => { + const runtime = normalizeRuntimeId(value); + if (runtime && runtime !== "auto" && runtime !== "pi") { + runtimes.add(runtime); + } + }; + + pushRuntimeId(config.agents?.defaults?.agentRuntime?.id); + for (const agent of config.agents?.list ?? []) { + pushRuntimeId(agent.agentRuntime?.id); + } +} + +export type ConfiguredAgentHarnessRuntimeOptions = { + includeEnvRuntime?: boolean; + includeLegacyAgentRuntimes?: boolean; +}; + export function collectConfiguredAgentHarnessRuntimes( config: OpenClawConfig, env: NodeJS.ProcessEnv, + options: ConfiguredAgentHarnessRuntimeOptions = {}, ): string[] { const runtimes = new Set(); + const includeEnvRuntime = options.includeEnvRuntime ?? true; + const includeLegacyAgentRuntimes = options.includeLegacyAgentRuntimes ?? true; const pushCodexForOpenAIModel = (model: unknown, agentId?: string) => { if (hasOpenAIModelRef(config, model, agentId)) { runtimes.add("codex"); } }; - void env; + if (includeEnvRuntime) { + const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME); + if (envRuntime && envRuntime !== "auto" && envRuntime !== "pi") { + runtimes.add(envRuntime); + } + } pushConfiguredModelRuntimeIds(config, runtimes); + if (includeLegacyAgentRuntimes) { + pushLegacyAgentRuntimeIds(config, runtimes); + } const defaultsModel = config.agents?.defaults?.model; pushCodexForOpenAIModel(defaultsModel); if (Array.isArray(config.agents?.list)) { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 4114a8d09f6..f61451d1387 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -109,7 +109,10 @@ function addConfiguredAgentRuntimePluginIds( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): void { - for (const runtime of collectConfiguredAgentHarnessRuntimes(cfg, env ?? process.env)) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(cfg, env ?? process.env, { + includeEnvRuntime: false, + includeLegacyAgentRuntimes: false, + })) { addConfiguredPluginId(ids, runtime); } } diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index d7699cf3bd4..136aa3bba32 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -1,13 +1,17 @@ import "./fs-safe-defaults.js"; +import fs from "node:fs/promises"; import path from "node:path"; -import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced"; +import { + ensureDirectoryWithinRoot, + findExistingAncestor, + writeViaSiblingTempPath, +} from "@openclaw/fs-safe/advanced"; import { root as fsSafeRoot, type ReadResult } from "@openclaw/fs-safe/root"; export { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe/errors"; export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, - ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, @@ -63,6 +67,39 @@ export type ExternalFileWriteResult = { path: string; }; +export async function ensureAbsoluteDirectory( + dirPath: string, + options?: { scopeLabel?: string; mode?: number }, +): Promise<{ ok: true; path: string } | { ok: false; error: Error }> { + const absolutePath = path.resolve(dirPath); + const scopeLabel = options?.scopeLabel ?? "directory"; + const existingAncestor = await findExistingAncestor(absolutePath); + if (!existingAncestor) { + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + if (existingAncestor === absolutePath) { + try { + const stat = await fs.lstat(absolutePath); + if (!stat.isSymbolicLink() && stat.isDirectory()) { + return { ok: true, path: absolutePath }; + } + } catch { + // Fall through to the uniform invalid-path result below. + } + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + const result = await ensureDirectoryWithinRoot({ + rootDir: existingAncestor, + requestedPath: path.relative(existingAncestor, absolutePath), + scopeLabel, + mode: options?.mode, + }); + if (result.ok) { + return result; + } + return { ok: false, error: new Error(result.error) }; +} + export async function writeExternalFileWithinRoot( options: ExternalFileWriteOptions, ): Promise { diff --git a/test/scripts/bench-gateway-startup.test.ts b/test/scripts/bench-gateway-startup.test.ts index 29ac4588d7b..adbddf975d2 100644 --- a/test/scripts/bench-gateway-startup.test.ts +++ b/test/scripts/bench-gateway-startup.test.ts @@ -9,7 +9,10 @@ describe("gateway startup benchmark script", () => { { cwd: process.cwd(), encoding: "utf8", - env: process.env, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, }, ); diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index 95dbad6a015..b114f7ddf77 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts"; type ToolStreamHost = Parameters[0]; @@ -65,6 +65,10 @@ function expectCompactionCompleteAndAutoClears(host: MutableHost) { expect(host.compactionClearTimer).toBeNull(); } +function useToolStreamFakeTimers(): void { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); +} + describe("app-tool-stream fallback lifecycle handling", () => { beforeAll(() => { const globalWithWindow = globalThis as typeof globalThis & { @@ -75,8 +79,16 @@ describe("app-tool-stream fallback lifecycle handling", () => { } }); + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("accepts session-scoped fallback lifecycle events when no run is active", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -104,7 +116,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("rejects idle fallback lifecycle events for other sessions", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -127,7 +139,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("auto-clears fallback status after toast duration", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -162,7 +174,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("builds previous fallback label from provider + model on fallback_cleared", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -188,7 +200,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("keeps compaction in retry-pending state until the matching lifecycle end", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); @@ -234,7 +246,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("treats lifecycle error as terminal for retry-pending compaction", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); @@ -263,7 +275,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("does not surface retrying or complete when retry compaction failed", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 58d8fc3da1d..0d31467b194 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -196,6 +196,8 @@ async function expectRetriedDeviceTokenConnect(params: { describe("GatewayBrowserClient", () => { beforeEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); const storage = createStorageMock(); wsInstances.length = 0; loadOrCreateDeviceIdentityMock.mockReset(); From bbd6d9e25464cfe1cfa07aebdf5a61ab5bf35b9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:52:18 +0100 Subject: [PATCH 374/806] test: stabilize node 26 full-suite edge cases --- .../browser/src/browser/output-files.ts | 5 +++++ .../monitor/message-handler.process.test.ts | 3 ++- .../src/reply-stream-controller.test.ts | 4 +--- src/agents/session-write-lock.test.ts | 20 +++++++++---------- src/agents/session-write-lock.ts | 11 +++++++--- src/config/plugin-auto-enable.shared.ts | 6 ++++-- src/plugins/gateway-startup-plugin-ids.ts | 5 ++++- 7 files changed, 34 insertions(+), 20 deletions(-) diff --git a/extensions/browser/src/browser/output-files.ts b/extensions/browser/src/browser/output-files.ts index 442236f860e..9b5436de0e1 100644 --- a/extensions/browser/src/browser/output-files.ts +++ b/extensions/browser/src/browser/output-files.ts @@ -21,6 +21,11 @@ export async function writeExternalFileWithinOutputRoot(params: { rootDir, path: outputPath, write: params.write, + }).catch((err: unknown) => { + if (err instanceof Error && /file not found/i.test(err.message)) { + throw new Error("output directory changed while writing file"); + } + throw err; }); return result.path; } diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3ad9e08f0f9..9e0ead5762d 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1680,7 +1680,8 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenNthCalledWith(1, "Clawing...\n🧩 First\n🧩 Second"); + expect(draftStream.update).toHaveBeenNthCalledWith(2, "🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 316e0f059ea..3d4ebe9b4fb 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,9 +320,7 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( - "Working\n- tool: exec", - ); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 9893047a113..6e508a86484 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -11,16 +11,6 @@ let resetSessionWriteLockStateForTest: typeof import("./session-write-lock.js"). let resolveSessionLockMaxHoldFromTimeout: typeof import("./session-write-lock.js").resolveSessionLockMaxHoldFromTimeout; let resolveSessionWriteLockAcquireTimeoutMs: typeof import("./session-write-lock.js").resolveSessionWriteLockAcquireTimeoutMs; -vi.mock("../shared/pid-alive.js", async () => { - const original = - await vi.importActual("../shared/pid-alive.js"); - return { - ...original, - // Keep liveness checks real; only pin process start time for PID recycle coverage. - getProcessStartTime: (pid: number) => (pid === process.pid ? FAKE_STARTTIME : null), - }; -}); - async function expectLockRemovedOnlyAfterFinalRelease(params: { lockPath: string; firstLock: { release: () => Promise }; @@ -142,6 +132,12 @@ describe("acquireSessionWriteLock", () => { resetSessionWriteLockStateForTest(); vi.clearAllMocks(); }); + + function pinCurrentProcessStartTimeForTest(): void { + __testing.setProcessStartTimeResolverForTest((pid) => + pid === process.pid ? FAKE_STARTTIME : null, + ); + } it("reuses locks across symlinked session paths", async () => { await withSymlinkedSessionPaths( async ({ sessionReal, sessionLink, realLockPath, linkLockPath }) => { @@ -418,6 +414,7 @@ describe("acquireSessionWriteLock", () => { }); it("cleans untracked current-process .jsonl lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); const sessionsDir = path.join(root, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); @@ -490,6 +487,7 @@ describe("acquireSessionWriteLock", () => { return; } await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); // Write a lock with a live PID (current process) but a wrong starttime, // simulating PID recycling: the PID is alive but belongs to a different // process than the one that created the lock. @@ -511,6 +509,7 @@ describe("acquireSessionWriteLock", () => { it("reclaims untracked current-process lock files with matching starttime", async () => { await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); await writeCurrentProcessLock(lockPath, { starttime: FAKE_STARTTIME }); await expectCurrentPidOwnsLock({ sessionFile, timeoutMs: 500 }); @@ -526,6 +525,7 @@ describe("acquireSessionWriteLock", () => { }); it("does not reclaim active in-process lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); await expectActiveInProcessLockIsNotReclaimed({ legacyStarttime: FAKE_STARTTIME }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index aaf1888864c..ae20cde9268 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -61,6 +61,7 @@ type LockInspectionDetails = Pick< >; const SESSION_LOCKS = createFileLockManager("openclaw.session-write-lock"); +let resolveProcessStartTimeForLock = getProcessStartTime; function isFileLockError(error: unknown, code: string): boolean { return (error as { code?: unknown } | null)?.code === code; @@ -312,7 +313,7 @@ function inspectLockPayload( const pidRecycled = pidAlive && pid !== null && storedStarttime !== null ? (() => { - const currentStarttime = getProcessStartTime(pid); + const currentStarttime = resolveProcessStartTimeForLock(pid); return currentStarttime !== null && currentStarttime !== storedStarttime; })() : false; @@ -419,7 +420,7 @@ function shouldTreatAsOrphanSelfLock(params: { return params.reclaimLockWithoutStarttime; } - const currentStarttime = getProcessStartTime(process.pid); + const currentStarttime = resolveProcessStartTimeForLock(process.pid); return currentStarttime !== null && currentStarttime === storedStarttime; } @@ -543,7 +544,7 @@ export async function acquireSessionWriteLock(params: { metadata: { maxHoldMs }, payload: () => { const createdAt = new Date().toISOString(); - const starttime = getProcessStartTime(process.pid); + const starttime = resolveProcessStartTimeForLock(process.pid); const lockPayload: LockFilePayload = { pid: process.pid, createdAt }; if (starttime !== null) { lockPayload.starttime = starttime; @@ -591,6 +592,9 @@ export const __testing = { handleTerminationSignal, releaseAllLocksSync, runLockWatchdogCheck, + setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void { + resolveProcessStartTimeForLock = resolver ?? getProcessStartTime; + }, }; export async function drainSessionWriteLockStateForTest(): Promise { @@ -603,4 +607,5 @@ export function resetSessionWriteLockStateForTest(): void { releaseAllLocksSync(); stopWatchdogTimer(); unregisterCleanupHandlers(); + resolveProcessStartTimeForLock = getProcessStartTime; } diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index e78c0cd3401..bfcfbd121d3 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -69,7 +69,7 @@ function extractProviderFromModelRef(value: string): string | null { } function hasConfiguredEmbeddedHarnessRuntime(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return collectConfiguredAgentHarnessRuntimes(cfg, env).length > 0; + return collectConfiguredAgentHarnessRuntimes(cfg, env, { includeEnvRuntime: false }).length > 0; } function resolveAgentHarnessOwnerPluginIds( @@ -641,7 +641,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env)) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env, { + includeEnvRuntime: false, + })) { const pluginIds = resolveAgentHarnessOwnerPluginIds(params.registry, runtime); for (const pluginId of pluginIds) { changes.push({ diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 8beaf901f68..6f4421b3216 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -629,7 +629,10 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { rootConfig: activationSourceConfig, }; const requiredAgentHarnessRuntimes = new Set( - collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env), + collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env, { + includeEnvRuntime: false, + includeLegacyAgentRuntimes: false, + }), ); const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); const manifestLookup = createManifestRegistryLookup(params.manifestRegistry); From bcf094f443333f5c3c350f1f1a164f1b5ab6e5c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:52:44 +0100 Subject: [PATCH 375/806] test: tighten cron timer assertions --- src/cron/service.armtimer-tight-loop.test.ts | 6 +++--- src/cron/service.rearm-timer-when-running.test.ts | 4 ++-- src/cron/service.session-reaper-in-finally.test.ts | 2 +- src/cron/service/ops.test.ts | 2 +- src/cron/service/timer.regression.test.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts index 87e74b84387..a63af5cdd87 100644 --- a/src/cron/service.armtimer-tight-loop.test.ts +++ b/src/cron/service.armtimer-tight-loop.test.ts @@ -90,7 +90,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = extractTimeoutDelays(timeoutSpy); // Before the fix, delay would be 0 (tight loop). @@ -171,7 +171,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = extractTimeoutDelays(timeoutSpy); expect(delays).toContain(60_000); @@ -208,7 +208,7 @@ describe("CronService - armTimer tight loop prevention", () => { await onTimer(state); expect(state.running).toBe(false); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); // The re-armed timer must NOT use delay=0. It should use at least // MIN_REFIRE_GAP_MS to prevent the hot-loop. diff --git a/src/cron/service.rearm-timer-when-running.test.ts b/src/cron/service.rearm-timer-when-running.test.ts index be9be3d3704..126e994bfe7 100644 --- a/src/cron/service.rearm-timer-when-running.test.ts +++ b/src/cron/service.rearm-timer-when-running.test.ts @@ -78,7 +78,7 @@ describe("CronService - timer re-arm when running (#12025)", () => { // The timer must be re-armed so the scheduler continues ticking, // with a fixed 60s delay to avoid hot-looping. - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); expect(timeoutSpy).toHaveBeenCalled(); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) @@ -138,7 +138,7 @@ describe("CronService - timer re-arm when running (#12025)", () => { await Promise.resolve(); expect(settled).toBe(false); expect(state.running).toBe(true); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) diff --git a/src/cron/service.session-reaper-in-finally.test.ts b/src/cron/service.session-reaper-in-finally.test.ts index 8cec12738df..ef209fef14c 100644 --- a/src/cron/service.session-reaper-in-finally.test.ts +++ b/src/cron/service.session-reaper-in-finally.test.ts @@ -84,7 +84,7 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { expect(state.running).toBe(false); // The timer must be re-armed. - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); }); }); diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 23e0c1c9377..1ef577ab6e3 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -160,7 +160,7 @@ describe("cron service ops seam coverage", () => { ); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeat).not.toHaveBeenCalled(); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const persisted = (await loadCronStore(storePath)) as { jobs: CronJob[]; diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 67e82ae9e77..66c4f5d9536 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -97,7 +97,7 @@ describe("cron service timer regressions", () => { await onTimer(state); expect(timeoutSpy).toHaveBeenCalled(); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((d): d is number => typeof d === "number"); From 17444268a9c84e1c2198a83cfe8c5e18a24913df Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:52:10 +0100 Subject: [PATCH 376/806] test: tighten memory wiki schema assertion --- extensions/memory-wiki/src/tool.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index 5c0888862b0..308aad61d3a 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,6 +3,7 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { + expect(typeof value).toBe("object"); expect(value).toEqual(expect.any(Object)); expect(Array.isArray(value)).toBe(false); if (typeof value !== "object" || value === null || Array.isArray(value)) { From 5a91c7c2a7493be30606ffbb6bb90e428ebcd42d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:54:31 +0100 Subject: [PATCH 377/806] test: require gateway lock acquisitions --- src/infra/gateway-lock.test.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index 8a1c3cd54b6..2ac7c660815 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -10,6 +10,8 @@ import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { acquireGatewayLock, GatewayLockError, type GatewayLockOptions } from "./gateway-lock.js"; +type GatewayLock = NonNullable>>; + const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-lock-" }); let fixtureRoot = ""; const realNow = Date.now.bind(Date); @@ -47,6 +49,14 @@ async function acquireForTest( }); } +function expectGatewayLock(lock: Awaited>): GatewayLock { + expect(lock).toEqual(expect.objectContaining({ release: expect.any(Function) })); + if (lock === null) { + throw new Error("Expected gateway lock"); + } + return lock; +} + function resolveLockPath(env: NodeJS.ProcessEnv) { const stateDir = resolveStateDir(env); const configPath = resolveConfigPath(env, stateDir); @@ -175,7 +185,7 @@ describe("gateway lock", () => { vi.useRealTimers(); const env = await makeEnv(); const lock = await acquireForTest(env, { timeoutMs: 50 }); - expect(lock).not.toBeNull(); + const acquiredLock = expectGatewayLock(lock); const pending = acquireForTest(env, { timeoutMs: 15, @@ -183,9 +193,9 @@ describe("gateway lock", () => { }); await expect(pending).rejects.toBeInstanceOf(GatewayLockError); - await lock?.release(); + await acquiredLock.release(); const lock2 = await acquireForTest(env); - await lock2?.release(); + await expectGatewayLock(lock2).release(); }); it("treats recycled linux pid as stale when start time mismatches", async () => { @@ -204,9 +214,9 @@ describe("gateway lock", () => { pollIntervalMs: 5, platform: "linux", }); - expect(lock).not.toBeNull(); + const acquiredLock = expectGatewayLock(lock); - await lock?.release(); + await acquiredLock.release(); spy.mockRestore(); }); @@ -259,8 +269,7 @@ describe("gateway lock", () => { platform: "darwin", port: 18789, }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); @@ -329,8 +338,7 @@ describe("gateway lock", () => { port: 18789, readProcessCmdline: () => ["chrome.exe", "--no-sandbox"], }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); @@ -394,8 +402,7 @@ describe("gateway lock", () => { port: 18789, readProcessCmdline: () => ["/Applications/Safari.app/Contents/MacOS/Safari"], }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); From d040d6d639101b007954252075436538dddb8e67 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:55:49 +0100 Subject: [PATCH 378/806] test: tighten memory flush defaults assertion --- extensions/memory-core/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index 90363bd7f14..441ecc5347c 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -115,7 +115,6 @@ describe("buildMemoryFlushPlan", () => { it("defaults to safe prompts and gating values", () => { const plan = buildMemoryFlushPlan(); - expect(plan).not.toBeNull(); expect(plan?.softThresholdTokens).toBe(DEFAULT_MEMORY_FLUSH_SOFT_TOKENS); expect(plan?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES); expect(plan?.prompt).toContain("memory/"); From a07802e7f0c87493514adbf22479961be6df72c1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:56:54 +0100 Subject: [PATCH 379/806] test: tighten browser profile assertion --- extensions/browser/src/browser/config.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 59005ed7ef1..149c8a6c957 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -706,14 +706,15 @@ describe("browser config", () => { }, }); const profile = resolveProfile(resolved, "chrome-live"); - expect(profile).not.toBeNull(); - expect(profile?.driver).toBe("existing-session"); - expect(profile?.attachOnly).toBe(true); - expect(profile?.cdpPort).toBe(0); - expect(profile?.cdpUrl).toBe(""); - expect(profile?.cdpIsLoopback).toBe(true); + expect(profile).toMatchObject({ + driver: "existing-session", + attachOnly: true, + cdpPort: 0, + cdpUrl: "", + cdpIsLoopback: true, + color: "#00AA00", + }); expect(profile?.userDataDir).toBeUndefined(); - expect(profile?.color).toBe("#00AA00"); }); it("expands tilde-prefixed userDataDir for existing-session profiles", () => { From 2cf0c07f7cccfd0662cf127b6a817e67a0901393 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:57:17 +0100 Subject: [PATCH 380/806] test: require proxy lifecycle handles --- src/infra/net/proxy/proxy-lifecycle.test.ts | 79 ++++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index 91d46a45cce..337b94e697b 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -25,6 +25,7 @@ import { registerManagedProxyGatewayLoopbackNoProxy, startProxy, stopProxy, + type ProxyHandle, } from "./proxy-lifecycle.js"; const mockForceResetGlobalDispatcher = vi.mocked(forceResetGlobalDispatcher); @@ -32,6 +33,24 @@ const mockBootstrapGlobalAgent = vi.mocked(bootstrapGlobalAgent); const mockLogInfo = vi.mocked(logInfo); const mockLogWarn = vi.mocked(logWarn); +function expectProxyHandle(handle: Awaited>): ProxyHandle { + expect(handle).toEqual(expect.objectContaining({ proxyUrl: expect.any(String) })); + if (handle === null) { + throw new Error("Expected managed proxy handle"); + } + return handle; +} + +function expectNoProxyUnregister( + unregister: ReturnType, +): () => void { + expect(unregister).toBeTypeOf("function"); + if (typeof unregister !== "function") { + throw new Error("Expected Gateway NO_PROXY unregister callback"); + } + return unregister; +} + describe("startProxy", () => { const savedEnv: Record = {}; const envKeysToClean = [ @@ -135,9 +154,14 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(getActiveManagedProxyUrl()?.href).toBe("http://127.0.0.1:3128/"); + const activeProxyUrl = getActiveManagedProxyUrl(); + expect(activeProxyUrl).toEqual(expect.any(URL)); + if (activeProxyUrl === undefined) { + throw new Error("Expected active managed proxy URL"); + } + expect(activeProxyUrl.href).toBe("http://127.0.0.1:3128/"); - await stopProxy(handle); + await stopProxy(expectProxyHandle(handle)); expect(getActiveManagedProxyUrl()).toBeUndefined(); }); @@ -147,7 +171,7 @@ describe("startProxy", () => { const handle = await startProxy({ enabled: true }); - expect(handle?.proxyUrl).toBe("http://127.0.0.1:3128"); + expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); }); @@ -159,7 +183,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3129", }); - expect(handle?.proxyUrl).toBe("http://127.0.0.1:3129"); + expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3129"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); }); @@ -178,7 +202,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); + expectProxyHandle(handle); expect(process.env["http_proxy"]).toBe("http://127.0.0.1:3128"); expect(process.env["https_proxy"]).toBe("http://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); @@ -261,12 +285,12 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); + const proxyHandle = expectProxyHandle(handle); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); expect(process.env["NO_PROXY"]).toBe(""); mockForceResetGlobalDispatcher.mockClear(); - await stopProxy(handle); + await stopProxy(proxyHandle); expect(process.env["HTTP_PROXY"]).toBe("http://previous.example.com:8080"); expect(process.env["NO_PROXY"]).toBe("corp.example.com"); @@ -448,12 +472,12 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"), + ); expect(agent["NO_PROXY"]).toBe("127.0.0.1:18789"); - unregister?.(); + unregister(); expect(agent["NO_PROXY"]).toBeNull(); await stopProxy(handle); }); @@ -465,15 +489,17 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregisterIpv6 = registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"); - expect(unregisterIpv6).toBeTypeOf("function"); + const unregisterIpv6 = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"), + ); expect(agent["NO_PROXY"]).toBe("[::1]:18789"); - unregisterIpv6?.(); + unregisterIpv6(); - const unregisterLocalhost = registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"); - expect(unregisterLocalhost).toBeTypeOf("function"); + const unregisterLocalhost = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"), + ); expect(agent["NO_PROXY"]).toBe("localhost.:18789"); - unregisterLocalhost?.(); + unregisterLocalhost(); await stopProxy(handle); }); @@ -489,12 +515,12 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"), + ); expect(agent["NO_PROXY"]).toBe("127.0.0.1:3000"); - unregister?.(); + unregister(); await stopProxy(handle); }); @@ -539,12 +565,12 @@ describe("startProxy", () => { const agent = (global as Record)["GLOBAL_AGENT"] as Record; agent["NO_PROXY"] = "corp.example.com"; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"), + ); expect(agent["NO_PROXY"]).toBe("corp.example.com,127.0.0.1:18789"); - unregister?.(); + unregister(); expect(agent["NO_PROXY"]).toBe("corp.example.com"); await stopProxy(handle); }); @@ -556,8 +582,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); - handle?.kill("SIGTERM"); + expectProxyHandle(handle).kill("SIGTERM"); expect(process.env["HTTP_PROXY"]).toBeUndefined(); expect(process.env["NO_PROXY"]).toBe("corp.example.com"); From 40998a81524100f4e21183537d805b23729fabf4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:57:44 +0100 Subject: [PATCH 381/806] test: tighten command queue wait assertion --- src/process/command-queue.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 0e32478e8bb..56fa3df2182 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -165,8 +165,8 @@ describe("command queue", () => { releaseFirst(); await Promise.all([first, second]); - expect(waited).not.toBeNull(); - expect(waited as unknown as number).toBeGreaterThanOrEqual(5); + expect(typeof waited).toBe("number"); + expect(waited).toBeGreaterThanOrEqual(5); expect(queuedAhead).toBe(0); } finally { vi.useRealTimers(); From a8bbfdc7e600c6adf7618cf3ff06bf1494909a0c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:58:32 +0100 Subject: [PATCH 382/806] test: tighten whatsapp transport activity assertion --- extensions/whatsapp/src/auto-reply/monitor-state.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply/monitor-state.test.ts b/extensions/whatsapp/src/auto-reply/monitor-state.test.ts index a7ec43941e5..b33fe0c6b58 100644 --- a/extensions/whatsapp/src/auto-reply/monitor-state.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor-state.test.ts @@ -58,7 +58,6 @@ describe("createWebChannelStatusController", () => { // The gateway health policy checks `connected === true && lastTransportActivityAt != null` // to decide whether to run stale-socket detection. Both must be present. expect(last.connected).toBe(true); - expect(last.lastTransportActivityAt).not.toBeNull(); - expect(typeof last.lastTransportActivityAt).toBe("number"); + expect(last.lastTransportActivityAt).toBe(1000); }); }); From 99df40b49e69c3c29e3c5b53ad987f16306e926b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:59:12 +0100 Subject: [PATCH 383/806] test: require core helper results --- src/config/gateway-control-ui-origins.test.ts | 4 ++- .../heartbeat-runner.ghost-reminder.test.ts | 18 +++++++---- src/infra/push-web.test.ts | 15 ++++++++-- src/trajectory/runtime.test.ts | 30 ++++++++++++++----- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/config/gateway-control-ui-origins.test.ts b/src/config/gateway-control-ui-origins.test.ts index f8172c7a410..b7d65859314 100644 --- a/src/config/gateway-control-ui-origins.test.ts +++ b/src/config/gateway-control-ui-origins.test.ts @@ -27,7 +27,9 @@ describe("ensureControlUiAllowedOriginsForNonLoopbackBind", () => { ); expect(result.bind).toBe("lan"); - expect(result.seededOrigins).not.toBeNull(); + expect(result.seededOrigins).toEqual( + expect.arrayContaining(["http://localhost:18789", "http://127.0.0.1:18789"]), + ); }); it("uses runtime loopback before config non-loopback and avoids seeding", () => { diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 7351a2b7703..1f1e38019ad 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -103,12 +103,18 @@ describe("Ghost reminder bug (issue #13317)", () => { } | null, reminderText: string, ) => { - expect(calledCtx).not.toBeNull(); - expect(calledCtx?.Provider).toBe("cron-event"); - expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); - expect(calledCtx?.Body).toContain(reminderText); - expect(calledCtx?.Body).not.toContain("HEARTBEAT_OK"); - expect(calledCtx?.Body).not.toContain("heartbeat poll"); + expect(calledCtx).toEqual( + expect.objectContaining({ + Provider: "cron-event", + Body: expect.stringContaining("scheduled reminder has been triggered"), + }), + ); + if (calledCtx === null || typeof calledCtx.Body !== "string") { + throw new Error("Expected cron event prompt body"); + } + expect(calledCtx.Body).toContain(reminderText); + expect(calledCtx.Body).not.toContain("HEARTBEAT_OK"); + expect(calledCtx.Body).not.toContain("heartbeat poll"); }; const runCronReminderCase = async ( diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index 68056bf6b9b..2c32d1f8d24 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -14,6 +14,8 @@ import { sendWebPushNotification, } from "./push-web.js"; +type WebPushSubscription = NonNullable>>; + // Stub resolveStateDir so tests use a temp directory. let tmpDir: string; vi.mock("../config/paths.js", () => ({ @@ -32,6 +34,16 @@ vi.mock("web-push", () => ({ }, })); +function expectLoadedSubscription( + loaded: Awaited>, +): WebPushSubscription { + expect(loaded).toEqual(expect.objectContaining({ endpoint: expect.any(String) })); + if (loaded === null) { + throw new Error("Expected loaded web push subscription"); + } + return loaded; +} + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-")); vi.clearAllMocks(); @@ -118,8 +130,7 @@ describe("subscription CRUD", () => { baseDir: tmpDir, }); const loaded = await loadWebPushSubscription(sub.subscriptionId, tmpDir); - expect(loaded).not.toBeNull(); - expect(loaded!.endpoint).toBe(endpoint); + expect(expectLoadedSubscription(loaded).endpoint).toBe(endpoint); }); it("returns null for unknown subscription ID", async () => { diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index 63723c76d22..faebfd3b875 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -11,6 +11,8 @@ import { toTrajectoryToolDefinitions, } from "./runtime.js"; +type TrajectoryRuntimeRecorder = NonNullable>; + const tempDirs: string[] = []; function makeTempDir(): string { @@ -25,6 +27,16 @@ afterEach(() => { } }); +function expectTrajectoryRuntimeRecorder( + recorder: ReturnType, +): TrajectoryRuntimeRecorder { + expect(recorder).toEqual(expect.objectContaining({ recordEvent: expect.any(Function) })); + if (recorder === null) { + throw new Error("Expected trajectory runtime recorder"); + } + return recorder; +} + describe("trajectory runtime", () => { it("resolves a session-adjacent trajectory file by default", () => { expect( @@ -63,8 +75,8 @@ describe("trajectory runtime", () => { }, }); - expect(recorder).not.toBeNull(); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { systemPrompt: "system prompt", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", @@ -102,7 +114,8 @@ describe("trajectory runtime", () => { }, }); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1), }); @@ -132,18 +145,19 @@ describe("trajectory runtime", () => { }, }); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(180), }); - recorder?.recordEvent("prompt.submitted", { + runtimeRecorder.recordEvent("prompt.submitted", { prompt: "y".repeat(180), }); - recorder?.recordEvent("model.completed", { + runtimeRecorder.recordEvent("model.completed", { get prompt() { throw new Error("stopped recorder should not read dropped payloads"); }, }); - await recorder?.flush(); + await runtimeRecorder.flush(); const parsed = writes.map((line) => JSON.parse(line)); expect(parsed.map((event) => event.type)).toContain("trace.truncated"); @@ -170,7 +184,7 @@ describe("trajectory runtime", () => { }, }); - expect(recorder).not.toBeNull(); + expectTrajectoryRuntimeRecorder(recorder); const pointer = JSON.parse( fs.readFileSync(resolveTrajectoryPointerFilePath(sessionFile), "utf8"), ) as { runtimeFile?: string }; From 504000ff6150391c8be8c92d2b72bc28bcf6780b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 16:59:28 +0100 Subject: [PATCH 384/806] test: tighten mantle provider assertions --- extensions/amazon-bedrock-mantle/discovery.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/amazon-bedrock-mantle/discovery.test.ts b/extensions/amazon-bedrock-mantle/discovery.test.ts index 484028b2e30..dbb1371ccdd 100644 --- a/extensions/amazon-bedrock-mantle/discovery.test.ts +++ b/extensions/amazon-bedrock-mantle/discovery.test.ts @@ -398,11 +398,12 @@ describe("bedrock mantle discovery", () => { fetchFn: mockFetch as unknown as typeof fetch, }); - expect(provider).not.toBeNull(); - expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1"); - expect(provider?.api).toBe("openai-completions"); - expect(provider?.auth).toBe("api-key"); - expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK"); + expect(provider).toMatchObject({ + baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "env:AWS_BEARER_TOKEN_BEDROCK", + }); expect(provider?.models).toHaveLength(2); expect( provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"), @@ -447,8 +448,7 @@ describe("bedrock mantle discovery", () => { tokenProviderFactory, }); - expect(provider).not.toBeNull(); - expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER); + expect(provider).toMatchObject({ apiKey: MANTLE_IAM_TOKEN_MARKER }); expect(tokenProvider).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith( "https://bedrock-mantle.us-east-1.api.aws/v1/models", From 0d5ddc719ab844c2a630b2c09f6171fe65dbe4d8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:00:22 +0100 Subject: [PATCH 385/806] test: tighten discord threading utility assertions --- .../monitor/monitor.threading-utils.test.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/extensions/discord/src/monitor/monitor.threading-utils.test.ts b/extensions/discord/src/monitor/monitor.threading-utils.test.ts index fdd73967e8a..869998e4e09 100644 --- a/extensions/discord/src/monitor/monitor.threading-utils.test.ts +++ b/extensions/discord/src/monitor/monitor.threading-utils.test.ts @@ -245,20 +245,23 @@ describe("resolveDiscordPresenceUpdate", () => { it("returns status-only presence when activity is omitted", () => { const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("dnd"); - expect(presence?.activities).toEqual([]); + expect(presence).toMatchObject({ + status: "dnd", + activities: [], + }); }); it("defaults to custom activity type when activity is set without type", () => { const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("online"); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 4, - name: "Custom Status", - state: "Focus time", + expect(presence).toMatchObject({ + status: "online", + activities: [ + expect.objectContaining({ + type: 4, + name: "Custom Status", + state: "Focus time", + }), + ], }); }); @@ -268,12 +271,14 @@ describe("resolveDiscordPresenceUpdate", () => { activityType: 1, activityUrl: "https://twitch.tv/openclaw", }); - expect(presence).not.toBeNull(); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 1, - name: "Live", - url: "https://twitch.tv/openclaw", + expect(presence).toMatchObject({ + activities: [ + expect.objectContaining({ + type: 1, + name: "Live", + url: "https://twitch.tv/openclaw", + }), + ], }); }); }); @@ -331,17 +336,16 @@ describe("resolveDiscordAutoThreadContext", () => { continue; } - expect(context, testCase.name).not.toBeNull(); - expect(context?.To, testCase.name).toBe("channel:thread"); - expect(context?.From, testCase.name).toBe("discord:channel:thread"); - expect(context?.OriginatingTo, testCase.name).toBe("channel:thread"); - expect(context?.SessionKey, testCase.name).toBe( - buildAgentSessionKey({ + expect(context, testCase.name).toMatchObject({ + To: "channel:thread", + From: "discord:channel:thread", + OriginatingTo: "channel:thread", + SessionKey: buildAgentSessionKey({ agentId: "agent", channel: "discord", peer: { kind: "channel", id: "thread" }, }), - ); + }); expect(context?.ParentSessionKey, testCase.name).toBe(testCase.expectedParentSessionKey); expect(context?.ModelParentSessionKey, testCase.name).toBe( testCase.expectedModelParentSessionKey, From e4622823e4285f7a5ceafcb775adcf822aa2d654 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:00:39 +0100 Subject: [PATCH 386/806] test: require approval handler runtime --- src/infra/approval-handler-runtime.test.ts | 42 ++++++++++++++-------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/infra/approval-handler-runtime.test.ts b/src/infra/approval-handler-runtime.test.ts index a9e0e22128b..f38b5f1682e 100644 --- a/src/infra/approval-handler-runtime.test.ts +++ b/src/infra/approval-handler-runtime.test.ts @@ -84,6 +84,18 @@ function createTestApprovalHandler(capability: ApprovalCapability) { }); } +type ApprovalHandlerRuntime = NonNullable>>; + +function expectApprovalRuntime( + runtime: Awaited>, +): ApprovalHandlerRuntime { + expect(runtime).toEqual(expect.objectContaining({ handleRequested: expect.any(Function) })); + if (runtime === null) { + throw new Error("Expected approval handler runtime"); + } + return runtime; +} + describe("createChannelApprovalHandlerFromCapability", () => { it("returns null when the capability does not expose a native runtime", async () => { await expect( @@ -116,7 +128,7 @@ describe("createChannelApprovalHandlerFromCapability", () => { ...TEST_HANDLER_PARAMS, }); - expect(runtime).not.toBeNull(); + expectApprovalRuntime(runtime); }); it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => { @@ -128,7 +140,7 @@ describe("createChannelApprovalHandlerFromCapability", () => { }), ); - expect(runtime).not.toBeNull(); + const approvalRuntime = expectApprovalRuntime(runtime); const request = { id: "custom:1", expiresAtMs: Date.now() + 60_000, @@ -138,8 +150,8 @@ describe("createChannelApprovalHandlerFromCapability", () => { }, } as never; - await runtime?.handleRequested(request); - await runtime?.stop(); + await approvalRuntime.handleRequested(request); + await approvalRuntime.stop(); expect(unbindPending).toHaveBeenCalledWith( expect.objectContaining({ @@ -161,12 +173,12 @@ describe("createChannelApprovalHandlerFromCapability", () => { }), ); - expect(runtime).not.toBeNull(); + const approvalRuntime = expectApprovalRuntime(runtime); const request = makeExecApprovalRequest("exec:1"); - await runtime?.handleRequested(request); - await runtime?.handleRequested(request); - await runtime?.handleResolved({ + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleResolved({ id: "exec:1", decision: "approved", resolvedBy: "operator", @@ -207,9 +219,10 @@ describe("createChannelApprovalHandlerFromCapability", () => { const request = makeExecApprovalRequest("exec:2"); - await runtime?.handleRequested(request); + const approvalRuntime = expectApprovalRuntime(runtime); + await approvalRuntime.handleRequested(request); await expect( - runtime?.handleResolved({ + approvalRuntime.handleResolved({ id: "exec:2", decision: "approved", resolvedBy: "operator", @@ -240,15 +253,16 @@ describe("createChannelApprovalHandlerFromCapability", () => { const request = makeExecApprovalRequest("exec:stop-1"); - await runtime?.handleRequested(request); - await runtime?.handleRequested({ + const approvalRuntime = expectApprovalRuntime(runtime); + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleRequested({ ...request, id: "exec:stop-2", }); - await expect(runtime?.stop()).resolves.toBeUndefined(); + await expect(approvalRuntime.stop()).resolves.toBeUndefined(); expect(unbindPending).toHaveBeenCalledTimes(2); - await expect(runtime?.stop()).resolves.toBeUndefined(); + await expect(approvalRuntime.stop()).resolves.toBeUndefined(); expect(unbindPending).toHaveBeenCalledTimes(2); }); }); From 415958ec080675e511890e96c73741f9ad7cd5f9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:01:17 +0100 Subject: [PATCH 387/806] test: tighten discord permission bitfield assertion --- extensions/discord/src/send.permissions.authz.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/discord/src/send.permissions.authz.test.ts b/extensions/discord/src/send.permissions.authz.test.ts index 62b20433f95..74e9c05c389 100644 --- a/extensions/discord/src/send.permissions.authz.test.ts +++ b/extensions/discord/src/send.permissions.authz.test.ts @@ -82,11 +82,13 @@ describe("discord guild permission authorization", () => { "user-1", EMPTY_DISCORD_TEST_OPTS, ); - expect(result).not.toBeNull(); - expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe( + if (result === null) { + throw new Error("Expected guild permissions bitfield"); + } + expect((result & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe( true, ); - expect((result! & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe( + expect((result & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe( true, ); }); From f193efbcd31e91ea7cad6b6e069e583cc4ce1c0f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:02:36 +0100 Subject: [PATCH 388/806] test: tighten discord owner allowlist assertion --- extensions/discord/src/monitor.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 87080e5b14e..e81ac2c6815 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -269,7 +269,11 @@ describe("discord allowlist helpers", () => { allowFrom: ["*", "user:123"], sender: { id: "123" }, }); - expect(explicitOwner.ownerAllowList).not.toBeNull(); + if (explicitOwner.ownerAllowList === null) { + throw new Error("Expected explicit owner allowlist"); + } + expect(explicitOwner.ownerAllowList.allowAll).toBe(false); + expect(explicitOwner.ownerAllowList.ids).toEqual(new Set(["123"])); expect(explicitOwner.ownerAllowed).toBe(true); }); }); From ad818ed99dcf99a47718a98462af401727682f16 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:03:29 +0100 Subject: [PATCH 389/806] test: require matrix test targets --- .../browser/src/browser/pw-session.test.ts | 7 +++- .../matrix/src/matrix/credentials.test.ts | 20 ++++++++-- .../matrix/src/migration-config.test.ts | 38 ++++++++++++------- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index 0568d911e74..9ee78332825 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -169,7 +169,12 @@ describe("pw-session ensurePageState", () => { expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA); expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB); for (const call of [saveAsA.mock.calls[0], saveAsB.mock.calls[0]]) { - const savedParentName = path.basename(path.dirname(String(call?.[0]))); + const savedPath = call?.[0]; + expect(savedPath).toEqual(expect.any(String)); + if (typeof savedPath !== "string") { + throw new Error("Expected saved download path"); + } + const savedParentName = path.basename(path.dirname(savedPath)); expect( savedParentName.includes("fs-safe-output") || savedParentName === path.basename(DEFAULT_DOWNLOAD_DIR), diff --git a/extensions/matrix/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts index cff999c4282..cfba0d0a7ed 100644 --- a/extensions/matrix/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -23,6 +23,18 @@ const DEFAULT_LEGACY_CREDENTIALS = { const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32"; +type MatrixCredentials = NonNullable>; + +function expectMatrixCredentials( + credentials: ReturnType, +): MatrixCredentials { + expect(credentials).toEqual(expect.objectContaining({ createdAt: expect.any(String) })); + if (credentials === null) { + throw new Error("Expected Matrix credentials"); + } + return credentials; +} + describe("matrix credentials storage", () => { const tempDirs: string[] = []; @@ -96,15 +108,15 @@ describe("matrix credentials storage", () => { "default", ); const initial = loadMatrixCredentials({}, "default"); - expect(initial).not.toBeNull(); + const initialCredentials = expectMatrixCredentials(initial); vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z")); await touchMatrixCredentials({}, "default"); const touched = loadMatrixCredentials({}, "default"); - expect(touched).not.toBeNull(); + const touchedCredentials = expectMatrixCredentials(touched); - expect(touched?.createdAt).toBe(initial?.createdAt); - expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z"); + expect(touchedCredentials.createdAt).toBe(initialCredentials.createdAt); + expect(touchedCredentials.lastUsedAt).toBe("2026-03-01T10:05:00.000Z"); } finally { vi.useRealTimers(); } diff --git a/extensions/matrix/src/migration-config.test.ts b/extensions/matrix/src/migration-config.test.ts index ffdcf62cf02..5fa411466bd 100644 --- a/extensions/matrix/src/migration-config.test.ts +++ b/extensions/matrix/src/migration-config.test.ts @@ -19,6 +19,16 @@ function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) { }); } +type MatrixMigrationTarget = NonNullable>; + +function expectMigrationTarget(target: ReturnType): MatrixMigrationTarget { + expect(target).toEqual(expect.objectContaining({ homeserver: expect.any(String) })); + if (target === null) { + throw new Error("Expected Matrix migration account target"); + } + return target; +} + describe("resolveMatrixMigrationAccountTarget", () => { it("reuses stored user identity for token-only configs when the access token matches", async () => { await withTempHome(async (home) => { @@ -44,9 +54,9 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe(MATRIX_OPS_USER_ID); - expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID); + expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS"); }); }); @@ -76,10 +86,10 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe("@new-bot:example.org"); - expect(target?.accessToken).toBe("tok-new"); - expect(target?.storedDeviceId).toBeNull(); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe("@new-bot:example.org"); + expect(migrationTarget.accessToken).toBe("tok-new"); + expect(migrationTarget.storedDeviceId).toBeNull(); }); }); @@ -138,9 +148,9 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe(MATRIX_OPS_USER_ID); - expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID); + expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS"); }); }); @@ -219,10 +229,10 @@ describe("resolveMatrixMigrationAccountTarget", () => { accountId: "ops-prod", }); - expect(target).not.toBeNull(); - expect(target?.homeserver).toBe("https://matrix.example.org"); - expect(target?.userId).toBe("@ops-prod:example.org"); - expect(target?.accessToken).toBe("tok-ops-prod"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.homeserver).toBe("https://matrix.example.org"); + expect(migrationTarget.userId).toBe("@ops-prod:example.org"); + expect(migrationTarget.accessToken).toBe("tok-ops-prod"); }); }); }); From 111863a7b16e71fd5954c9c9fa4e8ba0716ac6e6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:04:25 +0100 Subject: [PATCH 390/806] test: tighten slack media result assertions --- extensions/slack/src/monitor/media.test.ts | 51 +++++++++++++--------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 5487b679d02..2afc8917da5 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -13,6 +13,16 @@ import * as mediaRuntime from "./media.runtime.js"; import { logVerbose } from "./thread.runtime.js"; type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type SlackMediaResult = NonNullable>>; + +function expectSlackMediaResult( + result: Awaited>, +): SlackMediaResult { + if (result === null) { + throw new Error("Expected Slack media result"); + } + return result; +} const fetchRemoteMediaMock = vi.hoisted(() => vi.fn( @@ -134,7 +144,7 @@ async function expectPrivateDownloadRedirect(params: { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://files.slack.com/download.jpg"); expect(mockFetch.mock.calls[1]?.[0]).toBe(params.redirectedUrl); @@ -376,7 +386,7 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); const fetchOptions = fetchRemoteMediaMock.mock.calls[0]?.[0]; expect(fetchOptions?.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS); expect(fetchOptions?.requestInit?.signal).toBeInstanceOf(AbortSignal); @@ -481,8 +491,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result?.[0]?.path).toBe("/tmp/page.html"); + const media = expectSlackMediaResult(result); + expect(media[0]?.path).toBe("/tmp/page.html"); }); it("overrides video/* MIME to audio/* for slack_audio voice messages", async () => { @@ -512,8 +522,8 @@ describe("resolveSlackMedia", () => { maxBytes: 16 * 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); // saveMediaBuffer should receive the overridden audio/mp4 expect(saveMediaBufferMock).toHaveBeenCalledWith( expect.any(Buffer), @@ -523,7 +533,7 @@ describe("resolveSlackMedia", () => { ); // Returned contentType must be the overridden value, not the // re-detected video/mp4 from saveMediaBuffer - expect(result![0]?.contentType).toBe("audio/mp4"); + expect(media[0]?.contentType).toBe("audio/mp4"); }); it("preserves original MIME for non-voice Slack files", async () => { @@ -549,15 +559,15 @@ describe("resolveSlackMedia", () => { maxBytes: 16 * 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); expect(saveMediaBufferMock).toHaveBeenCalledWith( expect.any(Buffer), "video/mp4", "inbound", 16 * 1024 * 1024, ); - expect(result![0]?.contentType).toBe("video/mp4"); + expect(media[0]?.contentType).toBe("video/mp4"); }); it("falls through to next file when first file returns error", async () => { @@ -584,8 +594,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -628,11 +638,12 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).toHaveLength(2); - expect(result![0].path).toBe("/tmp/a.jpg"); - expect(result![0].placeholder).toBe("[Slack file: a.jpg (fileId: FA)]"); - expect(result![1].path).toBe("/tmp/b.png"); - expect(result![1].placeholder).toBe("[Slack file: b.png (fileId: FB)]"); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(2); + expect(media[0].path).toBe("/tmp/a.jpg"); + expect(media[0].placeholder).toBe("[Slack file: a.jpg (fileId: FA)]"); + expect(media[1].path).toBe("/tmp/b.png"); + expect(media[1].placeholder).toBe("[Slack file: b.png (fileId: FB)]"); }); it("caps downloads to 8 files for large multi-attachment messages", async () => { @@ -659,8 +670,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(8); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(8); expect(saveMediaBufferMock).toHaveBeenCalledTimes(8); expect(mockFetch).toHaveBeenCalledTimes(8); }); @@ -687,7 +698,7 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); expect(runtimeFetchSpy).toHaveBeenCalled(); expect(runtimeFetchSpy.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); expect( From 534fef283663af72eaf00f8470d94975d4a7c8d7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:05:05 +0100 Subject: [PATCH 391/806] test: tighten slack command payload assertion --- extensions/slack/src/monitor/message-handler/prepare.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 9567edd1a12..e42b0c80ddc 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -2103,8 +2103,9 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> /new", "1700000000.0002"); - expect(result).not.toBeNull(); - expect(result?.ctxPayload.CommandAuthorized).toBe(true); + expect(result).toMatchObject({ + ctxPayload: { CommandAuthorized: true }, + }); }); }); From 4d448e4ccef8f45c7397f60823462edc1890a178 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:05:55 +0100 Subject: [PATCH 392/806] test: tighten missing plugin command assertion --- src/cli/run-main.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index c1e32ecb360..5165acd0722 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -363,7 +363,6 @@ describe("resolveMissingPluginCommandMessage", () => { }, { registry: memoryWikiCommandAliasRegistry }, ); - expect(message).not.toBeNull(); expect(message).toContain('"memory-wiki"'); expect(message).toContain("plugins.allow"); }); From 172158bfcb00f0d83abbd551bc0ccecd8d87f780 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:06:49 +0100 Subject: [PATCH 393/806] test: require plugin test handles --- src/plugins/hook-runner-global.test.ts | 22 ++++++++++++++++--- src/plugins/lazy-service-module.test.ts | 17 ++++++++++++-- src/plugins/loader.test.ts | 21 +++++++++++++----- .../stage-bundled-plugin-runtime.test.ts | 10 +++++---- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts index 826fd6f4a0f..ebc8b87ed5b 100644 --- a/src/plugins/hook-runner-global.test.ts +++ b/src/plugins/hook-runner-global.test.ts @@ -5,6 +5,19 @@ async function importHookRunnerGlobalModule() { return import("./hook-runner-global.js"); } +type HookRunnerGlobalModule = Awaited>; +type HookRunner = NonNullable>; + +function expectGlobalHookRunner( + runner: ReturnType, +): HookRunner { + expect(runner).toEqual(expect.objectContaining({ hasHooks: expect.any(Function) })); + if (runner === null) { + throw new Error("Expected global hook runner"); + } + return runner; +} + async function expectGlobalRunnerState(expected: { hasRunner: boolean; registry?: unknown }) { const mod = await importHookRunnerGlobalModule(); expect(mod.getGlobalHookRunner() === null).toBe(!expected.hasRunner); @@ -29,13 +42,16 @@ describe("hook-runner-global", () => { it("preserves the initialized runner across module reloads", async () => { const { modA, registry } = await createInitializedModule(); - expect(modA.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(expectGlobalHookRunner(modA.getGlobalHookRunner()).hasHooks("message_received")).toBe( + true, + ); vi.resetModules(); const modB = await expectGlobalRunnerState({ hasRunner: true, registry }); - expect(modB.getGlobalHookRunner()).not.toBeNull(); - expect(modB.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(expectGlobalHookRunner(modB.getGlobalHookRunner()).hasHooks("message_received")).toBe( + true, + ); }); it("clears the shared state across module reloads", async () => { diff --git a/src/plugins/lazy-service-module.test.ts b/src/plugins/lazy-service-module.test.ts index f7419a8c06f..0d1cd9c2c9c 100644 --- a/src/plugins/lazy-service-module.test.ts +++ b/src/plugins/lazy-service-module.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultLoadOverrideModule, startLazyPluginServiceModule } from "./lazy-service-module.js"; +type LazyPluginServiceHandle = NonNullable< + Awaited> +>; + function createAsyncHookMock() { return vi.fn(async () => {}); } @@ -38,6 +42,16 @@ async function expectLifecycleStarted(params: { }); } +function expectLazyServiceHandle( + handle: Awaited>, +): LazyPluginServiceHandle { + expect(handle).toEqual(expect.objectContaining({ stop: expect.any(Function) })); + if (handle === null) { + throw new Error("Expected lazy plugin service handle"); + } + return handle; +} + describe("startLazyPluginServiceModule", () => { afterEach(() => { delete process.env.OPENCLAW_LAZY_SERVICE_SKIP; @@ -54,8 +68,7 @@ describe("startLazyPluginServiceModule", () => { }); expect(lifecycle.start).toHaveBeenCalledTimes(1); - expect(handle).not.toBeNull(); - await handle?.stop(); + await expectLazyServiceHandle(handle).stop(); expect(lifecycle.stop).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index b59e8be4066..a37dc2e6a4f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -94,6 +94,16 @@ import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; +type GlobalHookRunner = NonNullable>; + +function expectGlobalHookRunner(runner: ReturnType): GlobalHookRunner { + expect(runner).toEqual(expect.objectContaining({ hasHooks: expect.any(Function) })); + if (runner === null) { + throw new Error("Expected global hook runner"); + } + return runner; +} + function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime { const fail = (name: string): never => { throw new Error(`detached runtime ${id} should not execute ${name} in this test`); @@ -3274,14 +3284,14 @@ module.exports = { id: "throws-after-import", register() {} };`, }; const first = loadOpenClawPlugins(options); - expect(getGlobalHookRunner()).not.toBeNull(); + expectGlobalHookRunner(getGlobalHookRunner()); resetGlobalHookRunner(); expect(getGlobalHookRunner()).toBeNull(); const second = loadOpenClawPlugins(options); expect(second).toBe(first); - expect(getGlobalHookRunner()).not.toBeNull(); + expectGlobalHookRunner(getGlobalHookRunner()); resetGlobalHookRunner(); }); @@ -3322,7 +3332,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }); expect(getGlobalPluginRegistry()).toBe(gatewayRegistry); - expect(getGlobalHookRunner()?.hasHooks("subagent_ended")).toBe(true); + expect(expectGlobalHookRunner(getGlobalHookRunner()).hasHooks("subagent_ended")).toBe(true); const defaultRegistry = loadOpenClawPlugins({ workspaceDir: defaultPlugin.dir, @@ -3342,8 +3352,9 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(getActivePluginRegistry()).toBe(defaultRegistry); expect(getGlobalPluginRegistry()).toBe(gatewayRegistry); - expect(getGlobalHookRunner()?.hasHooks("subagent_ended")).toBe(true); - expect(getGlobalHookRunner()?.hasHooks("message_sent")).toBe(false); + const globalHookRunner = expectGlobalHookRunner(getGlobalHookRunner()); + expect(globalHookRunner.hasHooks("subagent_ended")).toBe(true); + expect(globalHookRunner.hasHooks("message_sent")).toBe(false); }); it.each([ diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index c9b8dd27d06..a92c189b1df 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -331,12 +331,14 @@ describe("stageBundledPluginRuntime", () => { ]); const match = commandsModule.matchPluginCommand("/pair now"); - expect(match).not.toBeNull(); - expect(match?.args).toBe("now"); + expect(match).toEqual(expect.objectContaining({ args: "now" })); + if (match === null) { + throw new Error("Expected plugin command match"); + } await expect( commandsModule.executePluginCommand({ - command: match!.command, - args: match?.args, + command: match.command, + args: match.args, }), ).resolves.toEqual({ text: "paired:now" }); }); From 0bd7995ddb809d5d15222cd4153b479e9e7eb26f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:07:15 +0100 Subject: [PATCH 394/806] test: tighten post compaction context assertions --- .../reply/post-compaction-context.test.ts | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index ba2e86b2942..860814ebf45 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -29,7 +29,6 @@ describe("readPostCompactionContext", () => { }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Do startup things"); expect(result).toContain("Be safe"); if (expectDefaultProse) { @@ -63,7 +62,6 @@ Not relevant. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Session Startup"); expect(result).toContain("WORKFLOW_AUTO.md"); expect(result).toContain("Post-compaction context refresh"); @@ -84,7 +82,6 @@ Stuff. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Red Lines"); expect(result).toContain("Never do X"); }); @@ -106,7 +103,6 @@ Ignore this. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Session Startup"); expect(result).toContain("Red Lines"); expect(result).not.toContain("Other"); @@ -116,9 +112,8 @@ Ignore this. const longContent = "## Session Startup\n\n" + "A".repeat(4000) + "\n\n## Other\n\nStuff."; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), longContent); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("[truncated]"); - expect(result!.length).toBeLessThan(2600); + expect(result?.length).toBeLessThan(2600); }); it("honors per-agent post-compaction context limit overrides", async () => { @@ -144,9 +139,8 @@ Ignore this. } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg, agentId: "writer" }); - expect(result).not.toBeNull(); expect(result).toContain("[truncated]"); - expect(result!.length).toBeLessThan(1_200); + expect(result?.length).toBeLessThan(1_200); }); it("matches section names case-insensitively", async () => { @@ -160,7 +154,6 @@ Read WORKFLOW_AUTO.md `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("WORKFLOW_AUTO.md"); }); @@ -175,7 +168,6 @@ Read these files. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Read these files"); }); @@ -195,7 +187,6 @@ Real red lines here. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Real red lines here"); expect(result).not.toContain("inside a code block"); }); @@ -213,7 +204,6 @@ Never do Y. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Rule 1"); expect(result).toContain("Rule 2"); expect(result).not.toContain("Other Section"); @@ -259,7 +249,6 @@ Never modify memory/YYYY-MM-DD.md destructively. // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const result = await readPostCompactionContext(tmpDir, { cfg, nowMs }); - expect(result).not.toBeNull(); expect(result).toContain("memory/2026-03-03.md"); expect(result).not.toContain("memory/YYYY-MM-DD.md"); expect(result).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); @@ -274,7 +263,6 @@ Read WORKFLOW.md on startup. fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const result = await readPostCompactionContext(tmpDir, { nowMs }); - expect(result).not.toBeNull(); expect(result).toContain("Current time:"); }); @@ -302,7 +290,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Critical Rules"); expect(result).toContain("My custom rules"); // Default sections must not be included when overridden @@ -321,7 +308,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Onboard things"); expect(result).toContain("Safe things"); expect(result).not.toContain("Ignore"); @@ -370,7 +356,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); // Must not reference the hardcoded default section name expect(result).not.toContain("Session Startup"); // Must reference the actual configured section names @@ -381,7 +366,6 @@ Read WORKFLOW.md on startup. const content = `## Session Startup\n\nDo startup.\n`; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Run your Session Startup sequence"); }); @@ -407,7 +391,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Init things"); }); }); From 7c4c4762ebc68facbbe768493ccf8ae17ce2bf6a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:08:00 +0100 Subject: [PATCH 395/806] test: tighten session fork assertions --- src/auto-reply/reply/session-fork.runtime.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index e67a38a5ca5..1d021ae5e1e 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -286,10 +286,12 @@ describe("forkSessionFromParentRuntime", () => { sessionsDir, }); - expect(fork).not.toBeNull(); - expect(fork?.sessionFile).toContain(sessionsDir); - expect(fork?.sessionId).not.toBe(parentSessionId); - const raw = await fs.readFile(fork?.sessionFile ?? "", "utf-8"); + if (fork === null) { + throw new Error("Expected forked session"); + } + expect(fork.sessionFile).toContain(sessionsDir); + expect(fork.sessionId).not.toBe(parentSessionId); + const raw = await fs.readFile(fork.sessionFile, "utf-8"); const forkedEntries = raw .trim() .split(/\r?\n/u) @@ -297,7 +299,7 @@ describe("forkSessionFromParentRuntime", () => { const resolvedParentSessionFile = await fs.realpath(parentSessionFile); expect(forkedEntries[0]).toMatchObject({ type: "session", - id: fork?.sessionId, + id: fork.sessionId, cwd, parentSession: resolvedParentSessionFile, }); From de9e5b44deb4492aef09cc7466e2c41c4fca548d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:09:08 +0100 Subject: [PATCH 396/806] test: tighten bundled plugin schema assertion --- src/plugins/bundled-plugin-metadata.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 8860299ad11..330bf146413 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -454,8 +454,7 @@ describe("bundled plugin metadata", () => { it("keeps config schemas on all bundled plugin manifests", () => { for (const entry of listRepoBundledPluginMetadata()) { - expect(typeof entry.manifest.configSchema).toBe("object"); - expect(entry.manifest.configSchema).not.toBeNull(); + expect(entry.manifest.configSchema).toEqual(expect.any(Object)); expect(Array.isArray(entry.manifest.configSchema)).toBe(false); } }); From 65757882312572cb7002dfc3c2017972b9dc21c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:09:29 +0100 Subject: [PATCH 397/806] test: require CLI routes --- src/cli/program/routes.test.ts | 86 ++++++++++++++++------------------ 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 4054ac072ed..1ae2265e4a4 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -59,46 +59,51 @@ describe("program routes", () => { vi.clearAllMocks(); }); - function expectRoute(path: string[]) { - const route = findRoutedCommand(path); - expect(route).not.toBeNull(); + type ProgramRoute = NonNullable>; + + function expectRoute(path: string[], argv?: string[]): ProgramRoute { + const route = findRoutedCommand(path, argv); + expect(route).toEqual(expect.objectContaining({ run: expect.any(Function) })); + if (route === null) { + throw new Error(`Expected routed command for ${path.join(" ")}`); + } return route; } async function expectRunFalse(path: string[], argv: string[]) { const route = expectRoute(path); - await expect(route?.run(argv)).resolves.toBe(false); + await expect(route.run(argv)).resolves.toBe(false); } it("matches status route without plugin preload", () => { const route = expectRoute(["status"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("matches health route without plugin preload", () => { const route = expectRoute(["health"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("matches channel read-only routes without plugin preload", () => { - expect(expectRoute(["channels", "list"])?.loadPlugins).toBeUndefined(); - expect(expectRoute(["channels", "status"])?.loadPlugins).toBeUndefined(); + expect(expectRoute(["channels", "list"]).loadPlugins).toBeUndefined(); + expect(expectRoute(["channels", "status"]).loadPlugins).toBeUndefined(); }); it("matches agents read-only routes without plugin preload", () => { - expect(expectRoute(["agents"])?.loadPlugins).toBeUndefined(); - expect(expectRoute(["agents", "list"])?.loadPlugins).toBeUndefined(); + expect(expectRoute(["agents"]).loadPlugins).toBeUndefined(); + expect(expectRoute(["agents", "list"]).loadPlugins).toBeUndefined(); }); it("passes parsed agents list flags through", async () => { - await expect(expectRoute(["agents"])?.run(["node", "openclaw", "agents"])).resolves.toBe(true); + await expect(expectRoute(["agents"]).run(["node", "openclaw", "agents"])).resolves.toBe(true); expect(agentsListCommandMock).toHaveBeenCalledWith( { json: false, bindings: false }, expect.any(Object), ); await expect( - expectRoute(["agents", "list"])?.run([ + expectRoute(["agents", "list"]).run([ "node", "openclaw", "agents", @@ -115,7 +120,7 @@ describe("program routes", () => { it("passes parsed channel read-only route flags through", async () => { const listRoute = expectRoute(["channels", "list"]); - await expect(listRoute?.run(["node", "openclaw", "channels", "list", "--json"])).resolves.toBe( + await expect(listRoute.run(["node", "openclaw", "channels", "list", "--json"])).resolves.toBe( true, ); expect(channelsListCommandMock).toHaveBeenCalledWith( @@ -125,7 +130,7 @@ describe("program routes", () => { const statusRoute = expectRoute(["channels", "status"]); await expect( - statusRoute?.run([ + statusRoute.run([ "node", "openclaw", "channels", @@ -144,7 +149,7 @@ describe("program routes", () => { it("matches gateway status route without plugin preload", () => { const route = expectRoute(["gateway", "status"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("returns false for gateway status route when option values are missing", async () => { @@ -181,7 +186,7 @@ describe("program routes", () => { it("passes parsed gateway status flags through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "--profile", @@ -217,7 +222,7 @@ describe("program routes", () => { it("passes --no-probe through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); - await expect(route?.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( + await expect(route.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( true, ); @@ -242,16 +247,7 @@ describe("program routes", () => { it("routes status --json through the lean JSON command", async () => { const route = expectRoute(["status"]); await expect( - route?.run([ - "node", - "openclaw", - "status", - "--json", - "--deep", - "--usage", - "--timeout", - "5000", - ]), + route.run(["node", "openclaw", "status", "--json", "--deep", "--usage", "--timeout", "5000"]), ).resolves.toBe(true); expect(statusJsonCommandMock).toHaveBeenCalledWith( { deep: true, all: false, usage: true, timeoutMs: 5000 }, @@ -290,7 +286,7 @@ describe("program routes", () => { it("passes config get path correctly when root option values precede command", async () => { const route = expectRoute(["config", "get"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "--log-level", @@ -307,7 +303,7 @@ describe("program routes", () => { it("passes config unset path correctly when root option values precede command", async () => { const route = expectRoute(["config", "unset"]); await expect( - route?.run(["node", "openclaw", "--profile", "work", "config", "unset", "update.channel"]), + route.run(["node", "openclaw", "--profile", "work", "config", "unset", "update.channel"]), ).resolves.toBe(true); expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" }); }); @@ -315,7 +311,7 @@ describe("program routes", () => { it("passes config get path when root value options appear after subcommand", async () => { const route = expectRoute(["config", "get"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "config", @@ -332,7 +328,7 @@ describe("program routes", () => { it("passes config unset path when root value options appear after subcommand", async () => { const route = expectRoute(["config", "unset"]); await expect( - route?.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]), + route.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]), ).resolves.toBe(true); expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" }); }); @@ -381,7 +377,7 @@ describe("program routes", () => { it("accepts negative-number probe profile values", async () => { const route = expectRoute(["models", "status"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "models", @@ -415,10 +411,10 @@ describe("program routes", () => { it("routes tasks list JSON through the lean task JSON command", async () => { const rootRoute = expectRoute(["tasks"]); - expect(rootRoute?.loadPlugins).toBeUndefined(); - expect(rootRoute?.canRun?.(["node", "openclaw", "tasks"])).toBe(false); + expect(rootRoute.loadPlugins).toBeUndefined(); + expect(rootRoute.canRun?.(["node", "openclaw", "tasks"])).toBe(false); await expect( - rootRoute?.run([ + rootRoute.run([ "node", "openclaw", "tasks", @@ -434,9 +430,9 @@ describe("program routes", () => { ); const listRoute = expectRoute(["tasks", "list"]); - expect(listRoute?.loadPlugins).toBeUndefined(); + expect(listRoute.loadPlugins).toBeUndefined(); await expect( - listRoute?.run(["node", "openclaw", "tasks", "list", "--json", "--runtime=cron"]), + listRoute.run(["node", "openclaw", "tasks", "list", "--json", "--runtime=cron"]), ).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenLastCalledWith( { json: true, runtime: "cron", status: undefined }, @@ -455,9 +451,8 @@ describe("program routes", () => { "--status", "running", ]; - const separateValueRoute = findRoutedCommand(["tasks", "cli"], separateValueArgv); - expect(separateValueRoute).not.toBeNull(); - await expect(separateValueRoute?.run(separateValueArgv)).resolves.toBe(true); + const separateValueRoute = expectRoute(["tasks", "cli"], separateValueArgv); + await expect(separateValueRoute.run(separateValueArgv)).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenCalledWith( { json: true, runtime: "cli", status: "running" }, expect.any(Object), @@ -472,13 +467,12 @@ describe("program routes", () => { "list", "--json", ]; - const parentOptionBeforeSubcommandRoute = findRoutedCommand( + const parentOptionBeforeSubcommandRoute = expectRoute( ["tasks", "cli"], parentOptionBeforeSubcommandArgv, ); - expect(parentOptionBeforeSubcommandRoute).not.toBeNull(); await expect( - parentOptionBeforeSubcommandRoute?.run(parentOptionBeforeSubcommandArgv), + parentOptionBeforeSubcommandRoute.run(parentOptionBeforeSubcommandArgv), ).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenLastCalledWith( { json: true, runtime: "cli", status: undefined }, @@ -488,10 +482,10 @@ describe("program routes", () => { it("routes tasks audit JSON through the lean task JSON command", async () => { const route = expectRoute(["tasks", "audit"]); - expect(route?.loadPlugins).toBeUndefined(); - expect(route?.canRun?.(["node", "openclaw", "tasks", "audit"])).toBe(false); + expect(route.loadPlugins).toBeUndefined(); + expect(route.canRun?.(["node", "openclaw", "tasks", "audit"])).toBe(false); await expect( - route?.run([ + route.run([ "node", "openclaw", "tasks", From a09e68e2495054dfb0f68e25f644eaec26b74113 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:09:59 +0100 Subject: [PATCH 398/806] test: tighten matrix logger assertion --- extensions/matrix/src/matrix/sdk.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 8026f7ffc82..01e088972a4 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1623,9 +1623,12 @@ describe("MatrixClient crypto bootstrapping", () => { debug?: (...args: unknown[]) => void; getChild?: (namespace: string) => unknown; } | null; - expect(logger).not.toBeNull(); - expect(logger?.debug).toBeTypeOf("function"); - expect(logger?.getChild).toBeTypeOf("function"); + expect(logger).toEqual( + expect.objectContaining({ + debug: expect.any(Function), + getChild: expect.any(Function), + }), + ); }); it("passes a custom sync filter to matrix-js-sdk startup", async () => { From 07b972ca077131820d2c2c8e81bccc35bb09d33a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:11:00 +0100 Subject: [PATCH 399/806] test: tighten backup manifest callback assertions --- src/commands/backup.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index fef97a24821..17069b8a796 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -210,12 +210,11 @@ describe("backup commands", () => { expect(result.archivePath).toBe( path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); - expect(capturedManifest).not.toBeNull(); - expect(capturedOnWriteEntry).not.toBeNull(); - const manifest = capturedManifest as unknown as { - assets: Array<{ kind: string; archivePath: string }>; - }; - const onWriteEntry = capturedOnWriteEntry as unknown as (entry: { path: string }) => void; + if (capturedManifest === null || capturedOnWriteEntry === null) { + throw new Error("Expected backup manifest and archive entry callback"); + } + const manifest = capturedManifest; + const onWriteEntry = capturedOnWriteEntry; expect(manifest.assets).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "state" }), From 1b16944eb4328a7ddb1d94b25cbdc5608b4aa654 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:11:33 +0100 Subject: [PATCH 400/806] test: tighten gateway auth snapshot assertion --- src/secrets/runtime.gateway-auth.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/secrets/runtime.gateway-auth.integration.test.ts b/src/secrets/runtime.gateway-auth.integration.test.ts index ec222597bc8..751009d403e 100644 --- a/src/secrets/runtime.gateway-auth.integration.test.ts +++ b/src/secrets/runtime.gateway-auth.integration.test.ts @@ -112,9 +112,11 @@ describe("secrets runtime snapshot gateway-auth integration", () => { ).rejects.toThrow(/runtime snapshot refresh failed: .*MISSING_GATEWAY_AUTH_TOKEN/i); const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); + if (activeAfterFailure === null) { + throw new Error("Expected active secrets runtime snapshot"); + } expect(getRuntimeConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); - expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); + expect(activeAfterFailure.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); const persistedConfig = JSON.parse( await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), From cb2f2e013a898e45a30672f5df1c415dfce0d761 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:12:25 +0100 Subject: [PATCH 401/806] test: tighten clawhub docs schema assertion --- src/docs/clawhub-plugin-docs.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 3fd19fa4640..13cd6e73a5c 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -42,8 +42,7 @@ describe("ClawHub plugin docs", () => { expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]); expect(typeof pluginManifest.id).toBe("string"); - expect(typeof pluginManifest.configSchema).toBe("object"); - expect(pluginManifest.configSchema).not.toBeNull(); + expect(pluginManifest.configSchema).toEqual(expect.any(Object)); expect(Array.isArray(pluginManifest.configSchema)).toBe(false); }); From 069aa10c180a9b2154a045e5d2248ac5347b118b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:13:12 +0100 Subject: [PATCH 402/806] test: tighten service audit drift assertion --- src/daemon/service-audit.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 98e434719bd..80eaf846fe0 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -473,9 +473,10 @@ describe("checkTokenDrift", () => { it("detects drift when config has token but service has different token", () => { const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); - expect(result).not.toBeNull(); - expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift); - expect(result?.message).toContain("differs from service token"); + expect(result).toMatchObject({ + code: SERVICE_AUDIT_CODES.gatewayTokenDrift, + message: expect.stringContaining("differs from service token"), + }); }); it("returns null when config has token but service has no token", () => { From d470d893273c52cbe5a3aa2484e9e01bb29906fd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:14:12 +0100 Subject: [PATCH 403/806] test: tighten config footprint record assertion --- src/plugins/contracts/config-footprint-guardrails.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 5bda4273b13..7736267adb6 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -53,8 +53,7 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { } function asRecord(value: unknown): Record { - expect(value).not.toBeNull(); - expect(typeof value).toBe("object"); + expect(value).toEqual(expect.any(Object)); expect(Array.isArray(value)).toBe(false); return value as Record; } From 2d5a5ee666b24d7c2f723d7a6413801698fda14d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:15:31 +0100 Subject: [PATCH 404/806] test: tighten windows acl command assertions --- src/security/windows-acl.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index a0e5d496bd7..045c7f365e7 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -758,10 +758,10 @@ Successfully processed 1 files`; isDir: false, env, }); - expect(result).not.toBeNull(); - expect(result?.command).toBe(DEFAULT_ICACLS); - expect(result?.args).toContain("C:\\test\\file.txt"); - expect(result?.args).toContain("/inheritance:r"); + expect(result).toMatchObject({ + command: DEFAULT_ICACLS, + args: expect.arrayContaining(["C:\\test\\file.txt", "/inheritance:r"]), + }); }); it("uses a validated SystemRoot for the structured command executable", () => { @@ -781,9 +781,10 @@ Successfully processed 1 files`; userInfo: mockUserInfo, }); // Should return a valid command using the system username - expect(result).not.toBeNull(); - expect(result?.command).toBe(DEFAULT_ICACLS); - expect(result?.args).toContain(`${MOCK_USERNAME}:F`); + expect(result).toMatchObject({ + command: DEFAULT_ICACLS, + args: expect.arrayContaining([`${MOCK_USERNAME}:F`]), + }); }); it("includes display string matching formatIcaclsResetCommand", () => { From de850f44f5aa4a62c330616416bf3d6e50e705fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:12:21 +0100 Subject: [PATCH 405/806] test: require command helper results --- src/commands/agents.delete.test.ts | 3 +-- src/commands/backup.test.ts | 2 ++ src/commands/doctor-state-integrity.linux-storage.test.ts | 7 +++++-- src/commands/doctor/shared/legacy-config-migrate.test.ts | 7 +++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 6827c317e8a..57a9c2fe2e3 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -276,8 +276,7 @@ describe("agents delete command", () => { await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); // Workspace should still exist — it was shared - const stat = await fs.stat(sharedWorkspace).catch(() => null); - expect(stat).not.toBeNull(); + await expect(fs.stat(sharedWorkspace)).resolves.toEqual(expect.any(Object)); // The JSON output should report why the workspace was retained. const jsonOutput = readJsonLogs(); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 17069b8a796..ed0c43c48b5 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -210,6 +210,8 @@ describe("backup commands", () => { expect(result.archivePath).toBe( path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); + expect(capturedManifest).toEqual(expect.objectContaining({ assets: expect.any(Array) })); + expect(capturedOnWriteEntry).toEqual(expect.any(Function)); if (capturedManifest === null || capturedOnWriteEntry === null) { throw new Error("Expected backup manifest and archive entry callback"); } diff --git a/src/commands/doctor-state-integrity.linux-storage.test.ts b/src/commands/doctor-state-integrity.linux-storage.test.ts index 9d1ea696ce8..6c3789b3b8e 100644 --- a/src/commands/doctor-state-integrity.linux-storage.test.ts +++ b/src/commands/doctor-state-integrity.linux-storage.test.ts @@ -115,8 +115,11 @@ describe("detectLinuxSdBackedStateDir", () => { }, }); - expect(result).not.toBeNull(); - const warning = formatLinuxSdBackedStateDirWarning(stateDir, result!); + expect(result).toEqual(expect.any(Object)); + if (result === null) { + throw new Error("Expected Linux state storage warning details"); + } + const warning = formatLinuxSdBackedStateDirWarning(stateDir, result); expect(warning).toContain("device /dev/disk/by-uuid/mmc\\nsource"); expect(warning).toContain("mount /home/pi/mnt\\nspoofed"); expect(warning).not.toContain("device /dev/disk/by-uuid/mmc\nsource"); diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index ba9d3acbee6..985e7558e23 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -656,8 +656,11 @@ describe("legacy migrate heartbeat config", () => { }); expect(res.changes).toContain("Removed empty top-level heartbeat."); - expect(res.config).not.toBeNull(); - expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined(); + expect(res.config).toEqual(expect.any(Object)); + if (res.config === null) { + throw new Error("Expected migrated config"); + } + expect((res.config as { heartbeat?: unknown }).heartbeat).toBeUndefined(); }); }); From c223fa61cda41dcc8a50c92a6ee2a1e3dff92bde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:14:36 +0100 Subject: [PATCH 406/806] test: fix backup callback narrowing --- src/commands/backup.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index ed0c43c48b5..57908cf9a59 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -215,8 +215,10 @@ describe("backup commands", () => { if (capturedManifest === null || capturedOnWriteEntry === null) { throw new Error("Expected backup manifest and archive entry callback"); } - const manifest = capturedManifest; - const onWriteEntry = capturedOnWriteEntry; + const manifest = capturedManifest as unknown as { + assets: Array<{ kind: string; archivePath: string }>; + }; + const onWriteEntry = capturedOnWriteEntry as unknown as (entry: { path: string }) => void; expect(manifest.assets).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "state" }), From 686f595c47e31b3494f5b49ddda14ac7f9e1d653 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:16:29 +0100 Subject: [PATCH 407/806] test: tighten external content marker assertion --- src/security/external-content.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index b7239835b3b..8d60f0e5f51 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -496,8 +496,10 @@ describe("external-content security", () => { // The malicious tags are contained within the safe boundaries const startMatch = result.match(/<<>>/); - expect(startMatch).not.toBeNull(); - expect(result.indexOf(startMatch![0])).toBeLessThan(result.indexOf("")); + if (startMatch === null) { + throw new Error("Expected external content start marker"); + } + expect(result.indexOf(startMatch[0])).toBeLessThan(result.indexOf("")); }); }); }); From b0f481bdf1e3eefa4b8d96457efc4e10c1fb17a8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:17:55 +0100 Subject: [PATCH 408/806] test: tighten web provider fast path assertions --- ...ublic-artifacts.explicit-fast-path.test.ts | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 2baf806deaa..53b80e3e1b0 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -77,6 +77,15 @@ import { resolveBundledWebSearchProvidersFromPublicArtifacts, } from "./web-provider-public-artifacts.js"; +function expectSingleProvider(providers: T[] | undefined): T { + expect(providers).toHaveLength(1); + const provider = providers?.[0]; + if (provider === undefined) { + throw new Error("Expected one web provider"); + } + return provider; +} + describe("web provider public artifacts explicit fast path", () => { beforeEach(() => { loadPluginManifestRegistryMock.mockClear(); @@ -84,13 +93,15 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled web search providers by explicit plugin id without manifest scans", () => { - const provider = resolveBundledWebSearchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, - onlyPluginIds: ["brave"], - })?.[0]; + const provider = expectSingleProvider( + resolveBundledWebSearchProvidersFromPublicArtifacts({ + bundledAllowlistCompat: true, + onlyPluginIds: ["brave"], + }), + ); - expect(provider?.pluginId).toBe("brave"); - expect(provider?.createTool({ config: {} as never })).toBeNull(); + expect(provider.pluginId).toBe("brave"); + expect(provider.createTool({ config: {} as never })).toBeNull(); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "brave", artifactBasename: "web-search-contract-api.js", @@ -99,12 +110,14 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled runtime web search providers by explicit plugin id", () => { - const provider = resolveExplicitRuntimeWebSearchProviders({ - onlyPluginIds: ["google"], - })?.[0]; + const provider = expectSingleProvider( + resolveExplicitRuntimeWebSearchProviders({ + onlyPluginIds: ["google"], + }), + ); - expect(provider?.pluginId).toBe("google"); - expect(provider?.createTool({ config: {} as never })).not.toBeNull(); + expect(provider.pluginId).toBe("google"); + expect(provider.createTool({ config: {} as never })).toEqual(expect.any(Object)); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "google", artifactBasename: "web-search-provider.js", @@ -113,13 +126,15 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => { - const provider = resolveBundledWebFetchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, - onlyPluginIds: ["firecrawl"], - })?.[0]; + const provider = expectSingleProvider( + resolveBundledWebFetchProvidersFromPublicArtifacts({ + bundledAllowlistCompat: true, + onlyPluginIds: ["firecrawl"], + }), + ); - expect(provider?.pluginId).toBe("firecrawl"); - expect(provider?.createTool({ config: {} as never })).toBeNull(); + expect(provider.pluginId).toBe("firecrawl"); + expect(provider.createTool({ config: {} as never })).toBeNull(); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "firecrawl", artifactBasename: "web-fetch-contract-api.js", From f38e65fb89c87fa0429dea711da074679f1ba967 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:19:10 +0100 Subject: [PATCH 409/806] test: tighten discord acp bind route assertion --- .../src/monitor/acp-bind-here.integration.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts index b88d7609c61..3f666f67ab9 100644 --- a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts +++ b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts @@ -203,9 +203,12 @@ describe("Discord ACP bind here end-to-end flow", () => { allowFrom: ["*"], }); - expect(preflight).not.toBeNull(); - expect(preflight?.boundSessionKey).toBe(binding.targetSessionKey); - expect(preflight?.route.sessionKey).toBe(binding.targetSessionKey); - expect(preflight?.route.agentId).toBe("codex"); + expect(preflight).toMatchObject({ + boundSessionKey: binding.targetSessionKey, + route: { + sessionKey: binding.targetSessionKey, + agentId: "codex", + }, + }); }); }); From 1b91cdf459d8ca8d390da0c8aa9df1e09db17e5d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:19:54 +0100 Subject: [PATCH 410/806] test: tighten discord acp preflight assertions --- .../message-handler.preflight.acp-bindings.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 874e58f9d2a..00e817c00c4 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -278,10 +278,11 @@ describe("preflightDiscordMessage configured ACP bindings", () => { }), ); - expect(result).not.toBeNull(); expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); - expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + expect(result).toMatchObject({ + boundSessionKey: "agent:codex:acp:binding:discord:default:abc123", + }); }); it("accepts plain messages in configured ACP-bound channels without a mention", async () => { @@ -309,9 +310,10 @@ describe("preflightDiscordMessage configured ACP bindings", () => { }), ); - expect(result).not.toBeNull(); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); - expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + expect(result).toMatchObject({ + boundSessionKey: "agent:codex:acp:binding:discord:default:abc123", + }); }); it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => { From f4c51937e89ed6ea14c94c30a6761aedbd4d316f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:19:05 +0100 Subject: [PATCH 411/806] test: require oc-path resolver matches --- src/oc-path/tests/jsonl/resolve.test.ts | 96 ++++---- .../tests/scenarios/cross-cutting.test.ts | 90 ++++---- .../scenarios/jsonc-byte-fidelity.test.ts | 7 +- .../scenarios/oc-path-resolver-edges.test.ts | 217 +++++++++--------- 4 files changed, 211 insertions(+), 199 deletions(-) diff --git a/src/oc-path/tests/jsonl/resolve.test.ts b/src/oc-path/tests/jsonl/resolve.test.ts index 9fccd944bf6..c24d7511db2 100644 --- a/src/oc-path/tests/jsonl/resolve.test.ts +++ b/src/oc-path/tests/jsonl/resolve.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; -import { resolveOcPath } from '../../universal.js'; -import { findOcPaths } from '../../find.js'; +import { describe, expect, it } from "vitest"; +import { findOcPaths } from "../../find.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; +import { resolveOcPath } from "../../universal.js"; const log = `{"event":"start","ts":1} {"event":"step","n":1,"result":{"ok":true,"detail":"a"}} @@ -16,51 +16,51 @@ function rs(ocPath: string) { return resolveJsonlOcPath(ast, parseOcPath(ocPath)); } -describe('resolveJsonlOcPath', () => { - it('returns root when no segments are given', () => { - expect(rs('oc://session-events')?.kind).toBe('root'); +describe("resolveJsonlOcPath", () => { + it("returns root when no segments are given", () => { + expect(rs("oc://session-events")?.kind).toBe("root"); }); - it('addresses an entire line by line number', () => { - const m = rs('oc://session-events/L1'); - expect(m?.kind).toBe('line'); + it("addresses an entire line by line number", () => { + const m = rs("oc://session-events/L1"); + expect(m?.kind).toBe("line"); }); - it('addresses fields under a line via item segment', () => { - const m = rs('oc://session-events/L2/event'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'step' }); + it("addresses fields under a line via item segment", () => { + const m = rs("oc://session-events/L2/event"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "step" }); } }); - it('descends via dotted item paths', () => { - const m = rs('oc://session-events/L2/result.ok'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'boolean', value: true }); + it("descends via dotted item paths", () => { + const m = rs("oc://session-events/L2/result.ok"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "boolean", value: true }); } }); - it('resolves $last to the most recent value line', () => { - const m = rs('oc://session-events/$last/event'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'end' }); + it("resolves $last to the most recent value line", () => { + const m = rs("oc://session-events/$last/event"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "end" }); } }); - it('returns null for unknown line addresses', () => { - expect(rs('oc://session-events/L99')).toBeNull(); - expect(rs('oc://session-events/garbage')).toBeNull(); + it("returns null for unknown line addresses", () => { + expect(rs("oc://session-events/L99")).toBeNull(); + expect(rs("oc://session-events/garbage")).toBeNull(); }); - it('returns null when descending into a blank line', () => { - expect(rs('oc://session-events/L3/anything')).toBeNull(); + it("returns null when descending into a blank line", () => { + expect(rs("oc://session-events/L3/anything")).toBeNull(); }); }); -describe('resolveJsonlToUniversal — file-relative line metadata (regression)', () => { +describe("resolveJsonlToUniversal — file-relative line metadata (regression)", () => { // Regression: surfaced via the openclaw-path CLI scenario run on // a multi-line session.jsonl. Every match returned `line: 1` // because the inside-line jsonc parser numbers from 1 within each @@ -68,30 +68,28 @@ describe('resolveJsonlToUniversal — file-relative line metadata (regression)', // number over the JsonlLine's file-relative line. const log = [ - '{"event":"start"}', // line 1 - '{"event":"step","n":1}', // line 2 - '{"event":"step","n":2}', // line 3 - '{"event":"end"}', // line 4 - '', // line 5 (blank) - ].join('\n'); + '{"event":"start"}', // line 1 + '{"event":"step","n":1}', // line 2 + '{"event":"step","n":2}', // line 3 + '{"event":"end"}', // line 4 + "", // line 5 (blank) + ].join("\n"); - it('resolves L2/event with line=2 (not 1)', () => { + it("resolves L2/event with line=2 (not 1)", () => { const { ast } = parseJsonl(log); - const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L2/event')); - expect(m).not.toBeNull(); - if (m !== null) {expect(m.line).toBe(2);} + const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L2/event")); + expect(m).toEqual(expect.objectContaining({ line: 2 })); }); - it('resolves L4/event with line=4', () => { + it("resolves L4/event with line=4", () => { const { ast } = parseJsonl(log); - const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L4/event')); - expect(m).not.toBeNull(); - if (m !== null) {expect(m.line).toBe(4);} + const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L4/event")); + expect(m).toEqual(expect.objectContaining({ line: 4 })); }); - it('findOcPaths over wildcard surfaces correct file-relative lines', () => { + it("findOcPaths over wildcard surfaces correct file-relative lines", () => { const { ast } = parseJsonl(log); - const matches = findOcPaths(ast, parseOcPath('oc://session.jsonl/*/event')); + const matches = findOcPaths(ast, parseOcPath("oc://session.jsonl/*/event")); expect(matches).toHaveLength(4); const lines = matches.map((m) => m.match.line); expect(lines).toEqual([1, 2, 3, 4]); diff --git a/src/oc-path/tests/scenarios/cross-cutting.test.ts b/src/oc-path/tests/scenarios/cross-cutting.test.ts index ab8ab5a93c7..a53f489ed2b 100644 --- a/src/oc-path/tests/scenarios/cross-cutting.test.ts +++ b/src/oc-path/tests/scenarios/cross-cutting.test.ts @@ -5,11 +5,11 @@ * across re-parses. OcPath round-trip via the AST (slugs in OcPath * must round-trip back to the resolved node). */ -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { formatOcPath, parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { formatOcPath, parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const SAMPLE = `--- name: github @@ -29,60 +29,60 @@ Preamble. - curl: HTTP client `; -describe('wave-13 cross-cutting', () => { - it('CC-01 parse → resolve → emit pipeline (block)', () => { +describe("wave-13 cross-cutting", () => { + it("CC-01 parse → resolve → emit pipeline (block)", () => { const { ast } = parseMd(SAMPLE); - const m = resolveOcPath(ast, { file: 'AGENTS.md', section: 'boundaries' }); - expect(m?.kind).toBe('block'); + const m = resolveOcPath(ast, { file: "AGENTS.md", section: "boundaries" }); + expect(m?.kind).toBe("block"); expect(emitMd(ast)).toBe(SAMPLE); }); - it('CC-02 OcPath round-trip via AST: parse + resolve + format', () => { + it("CC-02 OcPath round-trip via AST: parse + resolve + format", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { const path = parseOcPath(`oc://AGENTS.md/${block.slug}`); const m = resolveOcPath(ast, path); - expect(m?.kind, `block ${block.slug} should resolve`).toBe('block'); + expect(m?.kind, `block ${block.slug} should resolve`).toBe("block"); // Format the same path back; slug → URI shape should be stable. expect(formatOcPath(path)).toBe(`oc://AGENTS.md/${block.slug}`); } }); - it('CC-03 every item in every block is OcPath-addressable', () => { + it("CC-03 every item in every block is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { for (const item of block.items) { const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}`); const m = resolveOcPath(ast, path); - expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe('item'); + expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe("item"); } } }); - it('CC-04 every kv item field is OcPath-addressable', () => { + it("CC-04 every kv item field is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { for (const item of block.items) { - if (!item.kv) {continue;} - const path = parseOcPath( - `oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`, - ); + if (!item.kv) { + continue; + } + const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`); const m = resolveOcPath(ast, path); - expect(m?.kind).toBe('item-field'); + expect(m?.kind).toBe("item-field"); } } }); - it('CC-05 every frontmatter entry is OcPath-addressable', () => { + it("CC-05 every frontmatter entry is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const fm of ast.frontmatter) { const path = parseOcPath(`oc://AGENTS.md/[frontmatter]/${fm.key}`); const m = resolveOcPath(ast, path); - expect(m?.kind).toBe('frontmatter'); + expect(m?.kind).toBe("frontmatter"); } }); - it('CC-06 slugs are stable across re-parses (deterministic)', () => { + it("CC-06 slugs are stable across re-parses (deterministic)", () => { const a1 = parseMd(SAMPLE).ast; const a2 = parseMd(SAMPLE).ast; expect(a1.blocks.map((b) => b.slug)).toEqual(a2.blocks.map((b) => b.slug)); @@ -91,49 +91,49 @@ describe('wave-13 cross-cutting', () => { ); }); - it('CC-07 modifying raw + re-parse produces consistent AST shape', () => { + it("CC-07 modifying raw + re-parse produces consistent AST shape", () => { const a1 = parseMd(SAMPLE).ast; - const modified = SAMPLE.replace('GitHub CLI', 'GitHub command-line interface'); + const modified = SAMPLE.replace("GitHub CLI", "GitHub command-line interface"); const a2 = parseMd(modified).ast; // Block + item count + slugs unchanged. expect(a2.blocks.length).toBe(a1.blocks.length); - const a1Tools = a1.blocks.find((b) => b.slug === 'tools'); - const a2Tools = a2.blocks.find((b) => b.slug === 'tools'); + const a1Tools = a1.blocks.find((b) => b.slug === "tools"); + const a2Tools = a2.blocks.find((b) => b.slug === "tools"); expect(a2Tools?.items.length).toBe(a1Tools?.items.length); // KV value reflects the change. - const ghItem = a2Tools?.items.find((i) => i.kv?.key === 'gh'); - expect(ghItem?.kv?.value).toBe('GitHub command-line interface'); + const ghItem = a2Tools?.items.find((i) => i.kv?.key === "gh"); + expect(ghItem?.kv?.value).toBe("GitHub command-line interface"); }); - it('CC-08 unknown OcPath returns null without affecting subsequent valid resolves', () => { + it("CC-08 unknown OcPath returns null without affecting subsequent valid resolves", () => { const { ast } = parseMd(SAMPLE); - expect(resolveOcPath(ast, { file: 'X.md', section: 'nonexistent' })).toBeNull(); - expect(resolveOcPath(ast, { file: 'X.md', section: 'tools' })?.kind).toBe('block'); + expect(resolveOcPath(ast, { file: "X.md", section: "nonexistent" })).toBeNull(); + expect(resolveOcPath(ast, { file: "X.md", section: "tools" })?.kind).toBe("block"); }); - it('CC-09 resolve does not depend on file segment matching', () => { + it("CC-09 resolve does not depend on file segment matching", () => { const { ast } = parseMd(SAMPLE); - const a = resolveOcPath(ast, { file: 'A.md', section: 'tools' }); - const b = resolveOcPath(ast, { file: 'B.md', section: 'tools' }); + const a = resolveOcPath(ast, { file: "A.md", section: "tools" }); + const b = resolveOcPath(ast, { file: "B.md", section: "tools" }); expect(a?.kind).toBe(b?.kind); }); - it('CC-10 round-trip across all 9 valid OcPath shapes', () => { + it("CC-10 round-trip across all 9 valid OcPath shapes", () => { const { ast } = parseMd(SAMPLE); const cases = [ - { file: 'X.md' }, - { file: 'X.md', section: 'tools' }, - { file: 'X.md', section: 'tools', item: 'gh' }, - { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }, - { file: 'X.md', section: '[frontmatter]', field: 'name' }, - { file: 'X.md', section: 'boundaries' }, - { file: 'X.md', section: 'boundaries', item: 'never-write-to-etc' }, - { file: 'X.md', section: 'boundaries', item: 'always-confirm' }, - { file: 'X.md', section: '[frontmatter]', field: 'description' }, + { file: "X.md" }, + { file: "X.md", section: "tools" }, + { file: "X.md", section: "tools", item: "gh" }, + { file: "X.md", section: "tools", item: "gh", field: "gh" }, + { file: "X.md", section: "[frontmatter]", field: "name" }, + { file: "X.md", section: "boundaries" }, + { file: "X.md", section: "boundaries", item: "never-write-to-etc" }, + { file: "X.md", section: "boundaries", item: "always-confirm" }, + { file: "X.md", section: "[frontmatter]", field: "description" }, ]; for (const path of cases) { const m = resolveOcPath(ast, path); - expect(m, `failed for ${JSON.stringify(path)}`).not.toBeNull(); + expect(m, `failed for ${JSON.stringify(path)}`).toEqual(expect.any(Object)); } }); }); diff --git a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts index dd47ebd80d9..f81b5691989 100644 --- a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts @@ -34,8 +34,11 @@ function rt(raw: string): string { */ function assertParseable(raw: string): JsoncValue { const result = parseJsonc(raw); - expect(result.ast.root).not.toBeNull(); - return result.ast.root as JsoncValue; + expect(result.ast.root).toEqual(expect.any(Object)); + if (result.ast.root === null) { + throw new Error("Expected parseable JSONC root"); + } + return result.ast.root; } /** diff --git a/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts index 1f0381a8e6c..6cf5b8e1f3e 100644 --- a/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts @@ -6,9 +6,9 @@ * item returns `null` (not a guess). Frontmatter via the `[frontmatter]` * sentinel section. */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const SAMPLE = `--- name: github @@ -34,37 +34,39 @@ Preamble prose. - item one `; -describe('wave-08 oc-path-resolver-edges', () => { +describe("wave-08 oc-path-resolver-edges", () => { const { ast } = parseMd(SAMPLE); - it('R-01 root resolves to AST', () => { - const m = resolveOcPath(ast, { file: 'X.md' }); - expect(m?.kind).toBe('root'); + it("R-01 root resolves to AST", () => { + const m = resolveOcPath(ast, { file: "X.md" }); + expect(m?.kind).toBe("root"); }); - it('R-02 block by exact slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'boundaries' }); - expect(m?.kind).toBe('block'); + it("R-02 block by exact slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "boundaries" }); + expect(m?.kind).toBe("block"); }); - it('R-03 block by case-mismatched slug (Boundaries → boundaries)', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'Boundaries' }); - expect(m?.kind).toBe('block'); + it("R-03 block by case-mismatched slug (Boundaries → boundaries)", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "Boundaries" }); + expect(m?.kind).toBe("block"); }); - it('R-04 block by uppercased slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'BOUNDARIES' }); - expect(m?.kind).toBe('block'); + it("R-04 block by uppercased slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "BOUNDARIES" }); + expect(m?.kind).toBe("block"); }); - it('R-05 multi-word section by slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'multi-word-section' }); - expect(m?.kind).toBe('block'); - if (m?.kind === 'block') {expect(m.node.heading).toBe('Multi-Word Section');} + it("R-05 multi-word section by slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "multi-word-section" }); + expect(m?.kind).toBe("block"); + if (m?.kind === "block") { + expect(m.node.heading).toBe("Multi-Word Section"); + } }); - it('R-06 multi-word section by exact heading text (case-folded)', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'Multi-Word Section' }); + it("R-06 multi-word section by exact heading text (case-folded)", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "Multi-Word Section" }); // The OcPath section is matched case-insensitively against block.slug. // Block.slug for "Multi-Word Section" is "multi-word-section", and // path.section.toLowerCase() = "multi-word section" which does NOT @@ -73,163 +75,172 @@ describe('wave-08 oc-path-resolver-edges', () => { expect(m).toBeNull(); }); - it('R-07 unknown section returns null', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'unknown' }); + it("R-07 unknown section returns null", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "unknown" }); expect(m).toBeNull(); }); - it('R-08 item by slug under known section', () => { + it("R-08 item by slug under known section", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', + file: "X.md", + section: "tools", + item: "gh", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); it('R-09 item slug for KV uses kv.key (gh, not "gh-github-cli")', () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', + file: "X.md", + section: "tools", + item: "gh", }); - expect(m).not.toBeNull(); - if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('GitHub CLI');} + expect(m).toEqual(expect.objectContaining({ kind: "item" })); + if (m?.kind !== "item") { + throw new Error("Expected item match for gh"); + } + expect(m.node.kv?.value).toBe("GitHub CLI"); }); - it('R-10 item slug for plain bullet uses text', () => { + it("R-10 item slug for plain bullet uses text", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'boundaries', - item: 'never-write-to-etc', + file: "X.md", + section: "boundaries", + item: "never-write-to-etc", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); - it('R-11 item slug case-insensitive', () => { + it("R-11 item slug case-insensitive", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'GH', + file: "X.md", + section: "tools", + item: "GH", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); - it('R-12 item with spaces in key (slugified)', () => { + it("R-12 item with spaces in key (slugified)", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'the-tool', + file: "X.md", + section: "tools", + item: "the-tool", }); - expect(m?.kind).toBe('item'); - if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('with caps and spaces');} + expect(m?.kind).toBe("item"); + if (m?.kind === "item") { + expect(m.node.kv?.value).toBe("with caps and spaces"); + } }); - it('R-13 unknown item returns null', () => { + it("R-13 unknown item returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'nonexistent', + file: "X.md", + section: "tools", + item: "nonexistent", }); expect(m).toBeNull(); }); - it('R-14 item-field matches kv.key (case-insensitive)', () => { + it("R-14 item-field matches kv.key (case-insensitive)", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', - field: 'gh', + file: "X.md", + section: "tools", + item: "gh", + field: "gh", }); - expect(m?.kind).toBe('item-field'); + expect(m?.kind).toBe("item-field"); }); - it('R-15 field on plain (non-kv) item returns null', () => { + it("R-15 field on plain (non-kv) item returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'boundaries', - item: 'never-write-to-etc', - field: 'risk', + file: "X.md", + section: "boundaries", + item: "never-write-to-etc", + field: "risk", }); expect(m).toBeNull(); }); - it('R-16 field that does not match kv.key returns null', () => { + it("R-16 field that does not match kv.key returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', - field: 'nonexistent', + file: "X.md", + section: "tools", + item: "gh", + field: "nonexistent", }); expect(m).toBeNull(); }); - it('R-17 frontmatter via [frontmatter] sentinel section', () => { + it("R-17 frontmatter via [frontmatter] sentinel section", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', - field: 'name', + file: "X.md", + section: "[frontmatter]", + field: "name", }); - expect(m?.kind).toBe('frontmatter'); - if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('github');} + expect(m?.kind).toBe("frontmatter"); + if (m?.kind === "frontmatter") { + expect(m.node.value).toBe("github"); + } }); - it('R-18 frontmatter unknown key returns null', () => { + it("R-18 frontmatter unknown key returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', - field: 'nonexistent', + file: "X.md", + section: "[frontmatter]", + field: "nonexistent", }); expect(m).toBeNull(); }); - it('R-19 frontmatter without field returns null', () => { + it("R-19 frontmatter without field returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', + file: "X.md", + section: "[frontmatter]", }); expect(m).toBeNull(); }); - it('R-20 multiple frontmatter keys with same name — first match wins', () => { + it("R-20 multiple frontmatter keys with same name — first match wins", () => { // Build an AST manually to test const dupeAst = { - kind: 'md' as const, - raw: '', + kind: "md" as const, + raw: "", frontmatter: [ - { key: 'k', value: 'first', line: 2 }, - { key: 'k', value: 'second', line: 3 }, + { key: "k", value: "first", line: 2 }, + { key: "k", value: "second", line: 3 }, ], - preamble: '', + preamble: "", blocks: [], }; const m = resolveOcPath(dupeAst, { - file: 'X.md', - section: '[frontmatter]', - field: 'k', + file: "X.md", + section: "[frontmatter]", + field: "k", }); - expect(m?.kind).toBe('frontmatter'); - if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('first');} + expect(m?.kind).toBe("frontmatter"); + if (m?.kind === "frontmatter") { + expect(m.node.value).toBe("first"); + } }); - it('R-21 empty AST resolves root only', () => { - const empty = { kind: 'md' as const, raw: '', frontmatter: [], preamble: '', blocks: [] }; - expect(resolveOcPath(empty, { file: 'X.md' })?.kind).toBe('root'); - expect(resolveOcPath(empty, { file: 'X.md', section: 'any' })).toBeNull(); + it("R-21 empty AST resolves root only", () => { + const empty = { kind: "md" as const, raw: "", frontmatter: [], preamble: "", blocks: [] }; + expect(resolveOcPath(empty, { file: "X.md" })?.kind).toBe("root"); + expect(resolveOcPath(empty, { file: "X.md", section: "any" })).toBeNull(); }); - it('R-22 resolver does not mutate the AST', () => { + it("R-22 resolver does not mutate the AST", () => { const before = JSON.stringify(ast); - resolveOcPath(ast, { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }); + resolveOcPath(ast, { file: "X.md", section: "tools", item: "gh", field: "gh" }); const after = JSON.stringify(ast); expect(after).toBe(before); }); - it('R-23 file segment is informational — resolver doesn\'t check it', () => { + it("R-23 file segment is informational — resolver doesn't check it", () => { // The file name in OcPath is metadata; resolver assumes the AST // matches. Callers verify file mapping before passing the AST. - const m1 = resolveOcPath(ast, { file: 'SOUL.md', section: 'tools' }); - const m2 = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); + const m1 = resolveOcPath(ast, { file: "SOUL.md", section: "tools" }); + const m2 = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" }); expect(m1?.kind).toBe(m2?.kind); }); }); From 7e0f2301c9ae24dcff46e03e4c516dabacced213 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:20:49 +0100 Subject: [PATCH 412/806] test: tighten discord thread binding shared state assertion --- .../discord/src/monitor/thread-bindings.shared-state.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts b/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts index f852908d819..1f2cab9e739 100644 --- a/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts @@ -30,7 +30,8 @@ describe("thread binding manager state", () => { enableSweeper: false, }); - expect(getThreadBindingManager("work")).not.toBeNull(); - expect(viaAlternateLoader.getThreadBindingManager("work")).not.toBeNull(); + const direct = getThreadBindingManager("work"); + expect(direct).toEqual(expect.any(Object)); + expect(viaAlternateLoader.getThreadBindingManager("work")).toBe(direct); }); }); From 26644d3e9d49363608a16b2e7fcc937c4194dba7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:21:50 +0100 Subject: [PATCH 413/806] test: require plugin inspect reports --- src/plugins/status.test.ts | 15 +++++++-------- ...er-public-artifacts.explicit-fast-path.test.ts | 7 +++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 348e1f973ce..90c9fbabd03 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -149,8 +149,9 @@ function createInstalledPluginIndexSnapshot( function expectInspectReport( pluginId: string, + options: Omit[0], "id"> = {}, ): NonNullable> { - const inspect = buildPluginInspectReport({ id: pluginId }); + const inspect = buildPluginInspectReport({ id: pluginId, ...options }); if (inspect === null) { throw new Error(`expected inspect report for ${pluginId}`); } @@ -568,10 +569,9 @@ describe("plugin status reports", () => { }), ); - const inspect = buildPluginInspectReport({ id: "demo", config: rawConfig }); + const inspect = expectInspectReport("demo", { config: rawConfig }); - expect(inspect).not.toBeNull(); - expectInspectPolicy(inspect!, { + expectInspectPolicy(inspect, { allowPromptInjection: undefined, allowConversationAccess: undefined, hookTimeoutMs: undefined, @@ -719,10 +719,9 @@ describe("plugin status reports", () => { typedHooks: [createTypedHook({ pluginId: "google", hookName: "before_agent_start" })], }); - const inspect = buildPluginInspectReport({ id: "google" }); + const inspect = expectInspectReport("google"); - expect(inspect).not.toBeNull(); - expectInspectShape(inspect!, { + expectInspectShape(inspect, { shape: "hybrid-capability", capabilityMode: "hybrid", capabilityKinds: ["text-inference", "media-understanding", "image-generation", "web-search"], @@ -731,7 +730,7 @@ describe("plugin status reports", () => { expect(inspect?.compatibility).toEqual([ createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }), ]); - expectInspectPolicy(inspect!, { + expectInspectPolicy(inspect, { allowPromptInjection: false, allowConversationAccess: true, hookTimeoutMs: undefined, diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 53b80e3e1b0..0e67659b9e2 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -77,7 +77,7 @@ import { resolveBundledWebSearchProvidersFromPublicArtifacts, } from "./web-provider-public-artifacts.js"; -function expectSingleProvider(providers: T[] | undefined): T { +function expectSingleProvider(providers: T[] | null | undefined): T { expect(providers).toHaveLength(1); const provider = providers?.[0]; if (provider === undefined) { @@ -117,7 +117,10 @@ describe("web provider public artifacts explicit fast path", () => { ); expect(provider.pluginId).toBe("google"); - expect(provider.createTool({ config: {} as never })).toEqual(expect.any(Object)); + expect(provider.createTool({ config: {} as never })).toEqual({ + description: "fixture", + parameters: {}, + }); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "google", artifactBasename: "web-search-provider.js", From f785d96b0f00359552b10501ccd379dcc7357c2f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:21:57 +0100 Subject: [PATCH 414/806] test: tighten qa gateway rpc callback assertions --- extensions/qa-lab/src/gateway-rpc-client.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/qa-lab/src/gateway-rpc-client.test.ts b/extensions/qa-lab/src/gateway-rpc-client.test.ts index 1ac7bb4b8e2..3c74f15bad8 100644 --- a/extensions/qa-lab/src/gateway-rpc-client.test.ts +++ b/extensions/qa-lab/src/gateway-rpc-client.test.ts @@ -145,8 +145,10 @@ describe("startQaGatewayRpcClient", () => { }, ); - expect(resolveFirst).not.toBeNull(); - resolveFirst!({ ok: true }); + if (resolveFirst === null) { + throw new Error("Expected first gateway request resolver"); + } + resolveFirst({ ok: true }); await expect(firstRequest).resolves.toEqual({ ok: true }); }); @@ -174,8 +176,10 @@ describe("startQaGatewayRpcClient", () => { expect(gatewayRpcMock.callGatewayFromCli).toHaveBeenCalledTimes(1); - expect(releaseFirst).not.toBeNull(); - releaseFirst!(); + if (releaseFirst === null) { + throw new Error("Expected first gateway request release callback"); + } + releaseFirst(); await expect(firstRequest).resolves.toEqual({ ok: true }); await expect(secondRequest).resolves.toEqual({ ok: true }); From a68f58a4365fba020b4a9eeb4cf0a5de07b91c67 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:23:15 +0100 Subject: [PATCH 415/806] test: tighten voice media upgrade callback assertion --- extensions/voice-call/src/media-stream.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index ca97f288614..18eaf5113c4 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -557,7 +557,6 @@ describe("MediaStreamHandler security hardening", () => { expect(secondSocket.write).toHaveBeenCalledOnce(); expect(secondSocket.destroy).toHaveBeenCalledOnce(); - expect(upgradeCallback).not.toBeNull(); const completeUpgrade = upgradeCallback as ((ws: WebSocket) => void) | null; if (!completeUpgrade) { throw new Error("Expected upgrade callback to be registered"); From 0c6200cd1495e033164d23c397d71cf9523a87d6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:24:23 +0100 Subject: [PATCH 416/806] test: tighten plugin status inspect assertions --- src/plugins/status.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 90c9fbabd03..028834e0425 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -726,8 +726,8 @@ describe("plugin status reports", () => { capabilityMode: "hybrid", capabilityKinds: ["text-inference", "media-understanding", "image-generation", "web-search"], }); - expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); - expect(inspect?.compatibility).toEqual([ + expect(inspect.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect.compatibility).toEqual([ createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }), ]); expectInspectPolicy(inspect, { @@ -739,7 +739,7 @@ describe("plugin status reports", () => { allowedModels: ["openai/gpt-5.5"], hasAllowedModelsConfig: true, }); - expect(inspect?.diagnostics).toEqual([ + expect(inspect.diagnostics).toEqual([ { level: "warn", pluginId: "google", message: "watch this surface" }, ]); }); From b204b5dd253140c73f07c7f5a277c774b7d313a4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:25:22 +0100 Subject: [PATCH 417/806] test: tighten qmd update callback assertion --- extensions/memory-core/src/memory/qmd-manager.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index d319e81b009..8548f467f84 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -484,8 +484,10 @@ describe("QmdMemoryManager", () => { }); const { manager } = await createManager({ mode: "full" }); - expect(releaseUpdate).not.toBeNull(); - (releaseUpdate as (() => void) | null)?.(); + if (releaseUpdate === null) { + throw new Error("Expected qmd update release callback"); + } + releaseUpdate(); await manager?.close(); }); From 596cbd2da8531afefacea4a6f1cc1ed4a5c280ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:24:37 +0100 Subject: [PATCH 418/806] test: require qa lab rpc callbacks --- .../qa-lab/src/gateway-rpc-client.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/extensions/qa-lab/src/gateway-rpc-client.test.ts b/extensions/qa-lab/src/gateway-rpc-client.test.ts index 3c74f15bad8..027140d995b 100644 --- a/extensions/qa-lab/src/gateway-rpc-client.test.ts +++ b/extensions/qa-lab/src/gateway-rpc-client.test.ts @@ -16,6 +16,22 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({ import { startQaGatewayRpcClient } from "./gateway-rpc-client.js"; +function expectRequestResolver( + callback: ((value: { ok: boolean }) => void) | null, +): (value: { ok: boolean }) => void { + if (callback === null) { + throw new Error("Expected first request resolver callback to be captured"); + } + return callback; +} + +function expectReleaseCallback(callback: (() => void) | null): () => void { + if (callback === null) { + throw new Error("Expected first request release callback to be captured"); + } + return callback; +} + describe("startQaGatewayRpcClient", () => { beforeEach(() => { gatewayRpcMock.reset(); @@ -145,10 +161,7 @@ describe("startQaGatewayRpcClient", () => { }, ); - if (resolveFirst === null) { - throw new Error("Expected first gateway request resolver"); - } - resolveFirst({ ok: true }); + expectRequestResolver(resolveFirst)({ ok: true }); await expect(firstRequest).resolves.toEqual({ ok: true }); }); @@ -176,10 +189,7 @@ describe("startQaGatewayRpcClient", () => { expect(gatewayRpcMock.callGatewayFromCli).toHaveBeenCalledTimes(1); - if (releaseFirst === null) { - throw new Error("Expected first gateway request release callback"); - } - releaseFirst(); + expectReleaseCallback(releaseFirst)(); await expect(firstRequest).resolves.toEqual({ ok: true }); await expect(secondRequest).resolves.toEqual({ ok: true }); From 3da1c712736d39159f942f62f4a44528d392ea43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:27:19 +0100 Subject: [PATCH 419/806] test: require core catalog results --- src/config/future-version-guard.test.ts | 17 +++++-- src/gateway/models-http.test.ts | 27 +++++++----- .../official-external-plugin-catalog.test.ts | 44 +++++++++++-------- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/src/config/future-version-guard.test.ts b/src/config/future-version-guard.test.ts index 95216649d70..d232955a58a 100644 --- a/src/config/future-version-guard.test.ts +++ b/src/config/future-version-guard.test.ts @@ -4,6 +4,7 @@ import { formatFutureConfigActionBlock, resolveFutureConfigActionBlock, } from "./future-version-guard.js"; +import type { FutureConfigActionBlock } from "./future-version-guard.js"; import type { ConfigFileSnapshot } from "./types.js"; function snapshotWithTouchedVersion( @@ -15,6 +16,13 @@ function snapshotWithTouchedVersion( }; } +function expectFutureActionBlock(block: FutureConfigActionBlock | null): FutureConfigActionBlock { + if (block === null) { + throw new Error("Expected destructive action to be blocked by future config version"); + } + return block; +} + describe("resolveFutureConfigActionBlock", () => { it("blocks destructive actions from older binaries", () => { const block = resolveFutureConfigActionBlock({ @@ -24,10 +32,11 @@ describe("resolveFutureConfigActionBlock", () => { env: {}, }); - expect(block?.message).toContain("Refusing to restart the gateway service"); - expect(block?.message).toContain("2026.4.5"); - expect(block?.message).toContain("2026.4.23"); - expect(formatFutureConfigActionBlock(block!)).toContain( + const actionBlock = expectFutureActionBlock(block); + expect(actionBlock.message).toContain("Refusing to restart the gateway service"); + expect(actionBlock.message).toContain("2026.4.5"); + expect(actionBlock.message).toContain("2026.4.23"); + expect(formatFutureConfigActionBlock(actionBlock)).toContain( ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV, ); }); diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts index c79ba3a425b..d30a6b6d9ed 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -46,6 +46,17 @@ async function getModels(pathname: string, headers?: Record) { }); } +async function expectFirstModelId(): Promise { + const list = (await (await getModels("/v1/models")).json()) as { + data?: Array<{ id?: string }>; + }; + const firstId = list.data?.[0]?.id; + if (typeof firstId !== "string") { + throw new Error("Expected /v1/models to return at least one string model id"); + } + return firstId; +} + describe("OpenAI-compatible models HTTP API (e2e)", () => { it("serves /v1/models when compatibility endpoints are enabled", async () => { const res = await getModels("/v1/models"); @@ -62,12 +73,8 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { }); it("serves /v1/models/{id}", async () => { - const list = (await (await getModels("/v1/models")).json()) as { - data?: Array<{ id?: string }>; - }; - const firstId = list.data?.[0]?.id; - expect(typeof firstId).toBe("string"); - const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`); + const firstId = await expectFirstModelId(); + const res = await getModels(`/v1/models/${encodeURIComponent(firstId)}`); expect(res.status).toBe(200); const json = (await res.json()) as { id?: string; object?: string }; expect(json.object).toBe("model"); @@ -99,12 +106,8 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { }); it("rejects /v1/models/{id} without read access", async () => { - const list = (await (await getModels("/v1/models")).json()) as { - data?: Array<{ id?: string }>; - }; - const firstId = list.data?.[0]?.id; - expect(typeof firstId).toBe("string"); - const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`, { + const firstId = await expectFirstModelId(); + const res = await getModels(`/v1/models/${encodeURIComponent(firstId)}`, { "x-openclaw-scopes": "operator.approvals", }); expect(res.status).toBe(403); diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 9089883172a..43541be6fa9 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -1,39 +1,47 @@ import { describe, expect, it } from "vitest"; import { + type OfficialExternalPluginCatalogEntry, getOfficialExternalPluginCatalogEntry, listOfficialExternalPluginCatalogEntries, resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; +function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry { + const entry = getOfficialExternalPluginCatalogEntry(id); + if (entry === undefined) { + throw new Error(`Expected external plugin catalog entry for ${id}`); + } + return entry; +} + describe("official external plugin catalog", () => { it("resolves third-party channel lookup aliases to published plugin ids", () => { - const wecomByChannel = getOfficialExternalPluginCatalogEntry("wecom"); - const wecomByPlugin = getOfficialExternalPluginCatalogEntry("wecom-openclaw-plugin"); - const yuanbaoByChannel = getOfficialExternalPluginCatalogEntry("yuanbao"); + const wecomByChannel = expectCatalogEntry("wecom"); + const wecomByPlugin = expectCatalogEntry("wecom-openclaw-plugin"); + const yuanbaoByChannel = expectCatalogEntry("yuanbao"); - expect(resolveOfficialExternalPluginId(wecomByChannel!)).toBe("wecom-openclaw-plugin"); - expect(resolveOfficialExternalPluginId(wecomByPlugin!)).toBe("wecom-openclaw-plugin"); - expect(resolveOfficialExternalPluginInstall(wecomByChannel!)?.npmSpec).toBe( + expect(resolveOfficialExternalPluginId(wecomByChannel)).toBe("wecom-openclaw-plugin"); + expect(resolveOfficialExternalPluginId(wecomByPlugin)).toBe("wecom-openclaw-plugin"); + expect(resolveOfficialExternalPluginInstall(wecomByChannel)?.npmSpec).toBe( "@wecom/wecom-openclaw-plugin@2026.4.23", ); - expect(resolveOfficialExternalPluginId(yuanbaoByChannel!)).toBe("openclaw-plugin-yuanbao"); - expect(resolveOfficialExternalPluginInstall(yuanbaoByChannel!)?.npmSpec).toBe( + expect(resolveOfficialExternalPluginId(yuanbaoByChannel)).toBe("openclaw-plugin-yuanbao"); + expect(resolveOfficialExternalPluginInstall(yuanbaoByChannel)?.npmSpec).toBe( "openclaw-plugin-yuanbao@2.11.0", ); }); it("keeps official launch package specs on the production package names", () => { - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("acpx")!)?.npmSpec, - ).toBe("@openclaw/acpx"); - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("googlechat")!) - ?.npmSpec, - ).toBe("@openclaw/googlechat"); - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("line")!)?.npmSpec, - ).toBe("@openclaw/line"); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("acpx"))?.npmSpec).toBe( + "@openclaw/acpx", + ); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("googlechat"))?.npmSpec).toBe( + "@openclaw/googlechat", + ); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("line"))?.npmSpec).toBe( + "@openclaw/line", + ); }); it("keeps Matrix and Mattermost out of the external catalog until cutover", () => { From 4cfe562fa43ebc1608c5cd4c686d3d07c0ae54bb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:27:35 +0100 Subject: [PATCH 420/806] test: tighten google oauth cache assertion --- extensions/google/oauth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 4fc8e83406d..03c99d3a547 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -520,7 +520,7 @@ describe("extractGeminiCliCredentials", () => { // First call const result1 = extractGeminiCliCredentials(); - expect(result1).not.toBeNull(); + expectFakeCliCredentials(result1); // Second call should use cache (readFileSync not called again) const readCount = mockReadFileSync.mock.calls.length; From 631c655db77361dfa1fae3426a8693e7a26d4d11 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:28:27 +0100 Subject: [PATCH 421/806] test: tighten memory watcher manager assertions --- .../memory-core/src/memory/manager.watcher-config.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 8c60dadc1ef..480f15cb70e 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -146,7 +146,6 @@ describe("memory watcher config", () => { async function expectWatcherManager(cfg: OpenClawConfig) { const result = await getMemorySearchManager({ cfg, agentId: "main" }); - expect(result.manager).not.toBeNull(); if (!result.manager) { throw new Error("manager missing"); } @@ -200,8 +199,10 @@ describe("memory watcher config", () => { const cfg = createWatcherConfig(); const result = await getMemorySearchManager({ cfg, agentId: "main", purpose: "cli" }); - expect(result.manager).not.toBeNull(); - manager = result.manager as unknown as MemoryIndexManager; + if (!result.manager) { + throw new Error("manager missing"); + } + manager = result.manager; expect(watchMock).not.toHaveBeenCalled(); }); From e0e8354536f0ccf2fb78b24896b21c390ee91fe1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:29:19 +0100 Subject: [PATCH 422/806] test: tighten memory index manager assertion --- extensions/memory-core/src/memory/index.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 1c17c22fd3e..c9b27f9f008 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -275,11 +275,10 @@ describe("memory index", () => { result: Awaited>, missingMessage = "manager missing", ): MemoryIndexManager { - expect(result.manager).not.toBeNull(); if (!result.manager) { throw new Error(missingMessage); } - return result.manager as MemoryIndexManager; + return result.manager; } async function getPersistentManager(cfg: TestCfg): Promise { From c109e29c150a301b5f901a0d96957e151cbbbf19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:29:15 +0100 Subject: [PATCH 423/806] test: require infra helper results --- .../current-conversation-bindings.test.ts | 10 +- src/infra/outbound/outbound-policy.test.ts | 14 ++- src/infra/restart-handoff.test.ts | 95 ++++++++++--------- 3 files changed, 69 insertions(+), 50 deletions(-) diff --git a/src/infra/outbound/current-conversation-bindings.test.ts b/src/infra/outbound/current-conversation-bindings.test.ts index 7a4f2d5e230..50c396b5859 100644 --- a/src/infra/outbound/current-conversation-bindings.test.ts +++ b/src/infra/outbound/current-conversation-bindings.test.ts @@ -13,6 +13,14 @@ import { touchGenericCurrentConversationBinding, unbindGenericCurrentConversationBindings, } from "./current-conversation-bindings.js"; +import type { SessionBindingRecord } from "./session-binding.types.js"; + +function expectSessionBinding(bound: SessionBindingRecord | null): SessionBindingRecord { + if (bound === null) { + throw new Error("Expected current-conversation binding"); + } + return bound; +} function setMinimalCurrentConversationRegistry(): void { setActivePluginRegistry( @@ -315,7 +323,7 @@ describe("generic current-conversation bindings", () => { }, }); - expect(bound).not.toBeNull(); + expectSessionBinding(bound); touchGenericCurrentConversationBinding( "generic:workspace\u241fdefault\u241f\u241fuser:U123", diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 2449d968458..687217ad70c 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -2,12 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; import type { ChannelMessageActionName } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { CrossContextDecoration } from "./outbound-policy.js"; let applyCrossContextDecoration: typeof import("./outbound-policy.js").applyCrossContextDecoration; let buildCrossContextDecoration: typeof import("./outbound-policy.js").buildCrossContextDecoration; let enforceCrossContextPolicy: typeof import("./outbound-policy.js").enforceCrossContextPolicy; let shouldApplyCrossContextMarker: typeof import("./outbound-policy.js").shouldApplyCrossContextMarker; +function expectCrossContextDecoration( + decoration: CrossContextDecoration | null, +): CrossContextDecoration { + if (decoration === null) { + throw new Error("Expected cross-context decoration"); + } + return decoration; +} + const mocks = vi.hoisted(() => ({ getChannelPlugin: vi.fn((channel: string) => channel === "richchat" @@ -183,10 +193,10 @@ describe("outbound policy helpers", () => { toolContext: { currentChannelId: "C12345678", currentChannelProvider: "richchat" }, }); - expect(decoration).not.toBeNull(); + const requiredDecoration = expectCrossContextDecoration(decoration); const applied = applyCrossContextDecoration({ message: "hello", - decoration: decoration!, + decoration: requiredDecoration, preferPresentation: true, }); diff --git a/src/infra/restart-handoff.test.ts b/src/infra/restart-handoff.test.ts index ad138720ef9..854176bac4d 100644 --- a/src/infra/restart-handoff.test.ts +++ b/src/infra/restart-handoff.test.ts @@ -10,6 +10,7 @@ import { readGatewayRestartHandoffSync, writeGatewayRestartHandoffSync, } from "./restart-handoff.js"; +import type { GatewayRestartHandoff } from "./restart-handoff.js"; const tempDirs: string[] = []; @@ -26,6 +27,16 @@ function handoffPath(env: NodeJS.ProcessEnv): string { return path.join(env.OPENCLAW_STATE_DIR ?? "", GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME); } +function expectWrittenHandoff( + opts: Parameters[0], +): GatewayRestartHandoff { + const handoff = writeGatewayRestartHandoffSync(opts); + if (handoff === null) { + throw new Error("Expected gateway restart handoff to be written"); + } + return handoff; +} + describe("gateway restart handoff", () => { afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -68,16 +79,14 @@ describe("gateway restart handoff", () => { it("consumes a fresh handoff by exited pid instead of current process pid", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: process.pid + 1, - reason: "update.run", - restartKind: "update-process", - supervisorMode: "systemd", - createdAt: 2_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: process.pid + 1, + reason: "update.run", + restartKind: "update-process", + supervisorMode: "systemd", + createdAt: 2_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -97,15 +106,13 @@ describe("gateway restart handoff", () => { it("rejects handoffs for a different exited pid and clears them", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -120,16 +127,14 @@ describe("gateway restart handoff", () => { it("rejects a handoff when the supplied process instance does not match", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - processInstanceId: "gateway-instance-1", - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + processInstanceId: "gateway-instance-1", + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -168,16 +173,14 @@ describe("gateway restart handoff", () => { it("rejects expired and oversized handoff files", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - ttlMs: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + ttlMs: 1_000, + }); expect(readGatewayRestartHandoffSync(env, 2_001)).toBeNull(); fs.writeFileSync(handoffPath(env), "x".repeat(8192), { encoding: "utf8", mode: 0o600 }); @@ -231,14 +234,12 @@ describe("gateway restart handoff", () => { return; } - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 12_345, - restartKind: "full-process", - supervisorMode: "external", - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 12_345, + restartKind: "full-process", + supervisorMode: "external", + }); expect(fs.readFileSync(targetPath, "utf8")).toBe("keep"); expect(fs.lstatSync(handoffPath(env)).isSymbolicLink()).toBe(false); From ca4d6da0aa9fa76b27141d53e1da520ee24422fe Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:30:40 +0100 Subject: [PATCH 424/806] test: tighten reply normalization assertions --- src/auto-reply/reply/reply-utils.test.ts | 95 ++++++++++++------------ 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index b09d354ced9..24bf5ca78e2 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -18,6 +18,17 @@ import { createMockTypingController } from "./test-helpers.js"; import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; import { createTypingController } from "./typing.js"; +type NormalizedReplyPayload = NonNullable>; + +function expectNormalizedReply( + result: ReturnType, +): NormalizedReplyPayload { + if (result === null) { + throw new Error("Expected normalized reply payload"); + } + return result; +} + describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bopenclaw\b/i]; @@ -92,9 +103,9 @@ describe("normalizeReplyPayload", () => { const normalized = normalizeReplyPayload(payload); - expect(normalized).not.toBeNull(); - expect(normalized?.text).toBeUndefined(); - expect(normalized?.channelData).toEqual(payload.channelData); + const reply = expectNormalizedReply(normalized); + expect(reply.text).toBeUndefined(); + expect(reply.channelData).toEqual(payload.channelData); }); it("records skip reasons for silent/empty payloads", () => { @@ -114,50 +125,45 @@ describe("normalizeReplyPayload", () => { it("strips NO_REPLY from mixed emoji message (#30916)", () => { const result = normalizeReplyPayload({ text: "😄 NO_REPLY" }); - expect(result).not.toBeNull(); - expect(result!.text).toContain("😄"); - expect(result!.text).not.toContain("NO_REPLY"); + const reply = expectNormalizedReply(result); + expect(reply.text).toContain("😄"); + expect(reply.text).not.toContain("NO_REPLY"); }); it("strips NO_REPLY appended after substantive text (#30916)", () => { const result = normalizeReplyPayload({ text: "File's there. Not urgent.\n\nNO_REPLY", }); - expect(result).not.toBeNull(); - expect(result!.text).toContain("File's there"); - expect(result!.text).not.toContain("NO_REPLY"); + const reply = expectNormalizedReply(result); + expect(reply.text).toContain("File's there"); + expect(reply.text).not.toContain("NO_REPLY"); }); it("strips glued leading NO_REPLY text without leaking the token", () => { const result = normalizeReplyPayload({ text: "NO_REPLYThe user is saying hello", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("The user is saying hello"); + expect(expectNormalizedReply(result).text).toBe("The user is saying hello"); }); it("strips glued leading NO_REPLY text case-insensitively", () => { const result = normalizeReplyPayload({ text: "no_replyThe user is saying hello", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("The user is saying hello"); + expect(expectNormalizedReply(result).text).toBe("The user is saying hello"); }); it("keeps NO_REPLY when used as leading substantive text", () => { const result = normalizeReplyPayload({ text: "NO_REPLY -- nope" }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("NO_REPLY -- nope"); + expect(expectNormalizedReply(result).text).toBe("NO_REPLY -- nope"); }); it("keeps punctuation-start content after a leading NO_REPLY token", () => { const colonResult = normalizeReplyPayload({ text: "NO_REPLY: explanation" }); - expect(colonResult).not.toBeNull(); - expect(colonResult!.text).toBe("NO_REPLY: explanation"); + expect(expectNormalizedReply(colonResult).text).toBe("NO_REPLY: explanation"); const dashResult = normalizeReplyPayload({ text: "NO_REPLY—note" }); - expect(dashResult).not.toBeNull(); - expect(dashResult!.text).toBe("NO_REPLY—note"); + expect(expectNormalizedReply(dashResult).text).toBe("NO_REPLY—note"); }); it("suppresses message when stripping NO_REPLY leaves nothing", () => { @@ -184,8 +190,7 @@ describe("normalizeReplyPayload", () => { const result = normalizeReplyPayload({ text: '{"action":"NO_REPLY","note":"example"}', }); - expect(result).not.toBeNull(); - expect(result!.text).toBe('{"action":"NO_REPLY","note":"example"}'); + expect(expectNormalizedReply(result).text).toBe('{"action":"NO_REPLY","note":"example"}'); }); it("strips NO_REPLY but keeps media payload", () => { @@ -193,9 +198,9 @@ describe("normalizeReplyPayload", () => { text: "NO_REPLY", mediaUrl: "https://example.com/img.png", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe(""); - expect(result!.mediaUrl).toBe("https://example.com/img.png"); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe(""); + expect(reply.mediaUrl).toBe("https://example.com/img.png"); }); it("strips JSON NO_REPLY action text but keeps media payload", () => { @@ -203,9 +208,9 @@ describe("normalizeReplyPayload", () => { text: '{"action":"NO_REPLY"}', mediaUrl: "https://example.com/img.png", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe(""); - expect(result!.mediaUrl).toBe("https://example.com/img.png"); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe(""); + expect(reply.mediaUrl).toBe("https://example.com/img.png"); }); it("strips legacy uppercase TOOL_CALL blocks from normalized replies", () => { @@ -217,8 +222,7 @@ describe("normalizeReplyPayload", () => { ].join("\n"), }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Before\n\nAfter"); + expect(expectNormalizedReply(result).text).toBe("Before\n\nAfter"); }); it("strips legacy uppercase TOOL_RESULT blocks from normalized replies", () => { @@ -226,8 +230,7 @@ describe("normalizeReplyPayload", () => { text: ["Before", '[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]', "After"].join("\n"), }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Before\n\nAfter"); + expect(expectNormalizedReply(result).text).toBe("Before\n\nAfter"); }); it("does not compile Slack directives unless interactive replies are enabled", () => { @@ -235,9 +238,9 @@ describe("normalizeReplyPayload", () => { text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(reply.interactive).toBeUndefined(); }); it("applies responsePrefix before channel-owned transforms run", () => { @@ -248,9 +251,9 @@ describe("normalizeReplyPayload", () => { { responsePrefix: "[bot]" }, ); - expect(result).not.toBeNull(); - expect(result!.text).toBe("[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(reply.interactive).toBeUndefined(); }); it("leaves trailing Options lines for channel-owned transforms", () => { @@ -258,9 +261,9 @@ describe("normalizeReplyPayload", () => { text: "Current verbose level: off.\nOptions: on, full, off.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Current verbose level: off.\nOptions: on, full, off."); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("Current verbose level: off.\nOptions: on, full, off."); + expect(reply.interactive).toBeUndefined(); }); it("leaves larger Options lists for channel-owned transforms", () => { @@ -268,11 +271,11 @@ describe("normalizeReplyPayload", () => { text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe( + const reply = expectNormalizedReply(result); + expect(reply.text).toBe( "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", ); - expect(result!.interactive).toBeUndefined(); + expect(reply.interactive).toBeUndefined(); }); it("leaves complex Options lines as plain text", () => { @@ -280,11 +283,11 @@ describe("normalizeReplyPayload", () => { text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe( + const reply = expectNormalizedReply(result); + expect(reply.text).toBe( "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", ); - expect(result!.interactive).toBeUndefined(); + expect(reply.interactive).toBeUndefined(); }); }); From db215046327d2da43b2942def5863aaab11427d5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:31:51 +0100 Subject: [PATCH 425/806] test: tighten doctor auth migration assertion --- ...or.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index 50d8b4331a6..e84009650d0 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -175,7 +175,7 @@ describe("doctor command", () => { } const profiles = (written.auth as { profiles: Record }).profiles; expect(profiles).toHaveProperty("anthropic:me@example.com"); - expect(profiles["anthropic:me@example.com"]).not.toBeNull(); + expect(profiles["anthropic:me@example.com"]).toEqual(expect.any(Object)); expect(profiles["anthropic:default"]).toBeUndefined(); }, 30_000); }); From f42f6dde9ae32b4ea86399ef60a91729d58c8800 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:34:39 +0100 Subject: [PATCH 426/806] test: require generation tool handles --- src/agents/tools/music-generate-tool.test.ts | 18 ++++++++++++++---- src/agents/tools/video-generate-tool.test.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index 80a839e079e..a4a8b8c0e79 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -111,6 +111,16 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function expectMusicGenerateTool( + tool: ReturnType, +): NonNullable> { + if (tool === null) { + throw new Error("expected music_generate tool"); + } + expect(typeof tool.execute).toBe("function"); + return tool; +} + function resetMusicGenerateMocks() { vi.restoreAllMocks(); vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([]); @@ -137,7 +147,7 @@ describe("createMusicGenerateTool", () => { }); it("registers when music-generation config is present", () => { - expect( + expectMusicGenerateTool( createMusicGenerateTool({ config: asConfig({ agents: { @@ -147,7 +157,7 @@ describe("createMusicGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); }); it("does not load runtime providers while registering an explicitly configured tool", () => { @@ -157,7 +167,7 @@ describe("createMusicGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + expectMusicGenerateTool( createMusicGenerateTool({ config: asConfig({ agents: { @@ -167,7 +177,7 @@ describe("createMusicGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index 9f0f1059f7f..d1de9cd7635 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -90,6 +90,16 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function expectVideoGenerateTool( + tool: ReturnType, +): NonNullable> { + if (tool === null) { + throw new Error("expected video_generate tool"); + } + expect(typeof tool.execute).toBe("function"); + return tool; +} + function mockVideoPluginProvider(capabilities: Record = {}) { vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([ { @@ -170,7 +180,7 @@ describe("createVideoGenerateTool", () => { }); it("registers when video-generation config is present", () => { - expect( + expectVideoGenerateTool( createVideoGenerateTool({ config: asConfig({ agents: { @@ -180,7 +190,7 @@ describe("createVideoGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); }); it("does not load runtime providers while registering an explicitly configured tool", () => { @@ -190,7 +200,7 @@ describe("createVideoGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + expectVideoGenerateTool( createVideoGenerateTool({ config: asConfig({ agents: { @@ -200,7 +210,7 @@ describe("createVideoGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); From b542daab1f532beb2ed340f791c91b14b3db94a3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:35:00 +0100 Subject: [PATCH 427/806] test: tighten discord thread lifecycle assertions --- .../monitor/thread-bindings.lifecycle.test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index ee2d777cc43..a7fa3e7cbbb 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -288,7 +288,7 @@ describe("thread binding lifecycle", () => { webhookToken: "tok-1", introText: "intro", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); hoisted.sendMessageDiscord.mockClear(); hoisted.sendWebhookMessageDiscord.mockClear(); @@ -327,7 +327,7 @@ describe("thread binding lifecycle", () => { webhookId: "wh-1", webhookToken: "tok-1", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); @@ -656,7 +656,7 @@ describe("thread binding lifecycle", () => { vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z")); const touched = manager.touchThread({ threadId: "thread-1", persist: false }); - expect(touched).not.toBeNull(); + expect(touched).toEqual(expect.any(Object)); const record = requireBinding(manager, "thread-1"); expect(record.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime()); @@ -746,7 +746,7 @@ describe("thread binding lifecycle", () => { targetSessionKey: "agent:main:subagent:child-1", agentId: "main", }); - expect(first).not.toBeNull(); + expect(first).toEqual(expect.any(Object)); expect(hoisted.restPost).toHaveBeenCalledTimes(1); manager.unbindThread({ @@ -761,9 +761,10 @@ describe("thread binding lifecycle", () => { targetSessionKey: "agent:main:subagent:child-2", agentId: "main", }); - expect(second).not.toBeNull(); - expect(second?.webhookId).toBe("wh-created"); - expect(second?.webhookToken).toBe("tok-created"); + expect(second).toMatchObject({ + webhookId: "wh-created", + webhookToken: "tok-created", + }); expect(hoisted.restPost).toHaveBeenCalledTimes(1); }); @@ -796,7 +797,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); + expect(childBinding).toEqual(expect.any(Object)); expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-1", @@ -836,8 +837,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); - expect(childBinding?.channelId).toBe("parent-1"); + expect(childBinding).toMatchObject({ channelId: "parent-1" }); expect(hoisted.restGet).toHaveBeenCalledTimes(1); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-1", @@ -879,7 +879,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); + expect(childBinding).toEqual(expect.any(Object)); const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as | { accountId?: string; token?: string } | undefined; @@ -929,7 +929,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(bound).not.toBeNull(); + expect(bound).toEqual(expect.any(Object)); const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { if (call?.[1] === refreshedCfg) { return true; @@ -986,7 +986,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(bound).not.toBeNull(); + expect(bound).toEqual(expect.any(Object)); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-runtime", expect.objectContaining({ autoArchiveMinutes: 60 }), From 6c015e83a17699835e605a481b1453cb0d534b73 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:36:05 +0100 Subject: [PATCH 428/806] test: tighten discord preflight result assertions --- .../monitor/message-handler.preflight.test.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 38254f59827..976e35eef16 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -87,6 +87,17 @@ function createPreflightArgs(params: { return createDiscordPreflightArgs(params); } +type DiscordPreflightResult = NonNullable>>; + +function expectPreflightResult( + result: Awaited>, +): DiscordPreflightResult { + if (result === null) { + throw new Error("Expected Discord preflight result"); + } + return result; +} + function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { return { fetchChannel: async (channelId: string) => { @@ -386,8 +397,8 @@ describe("preflightDiscordMessage", () => { } as DiscordConfig, }); - expect(result).not.toBeNull(); - expect(result?.threadBinding).toMatchObject({ + const preflight = expectPreflightResult(result); + expect(preflight.threadBinding).toMatchObject({ conversation: { channel: "discord", accountId: "default", @@ -468,11 +479,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.route.agentId).toBe("newagent"); - expect(result?.route.sessionKey).toBe(`agent:newagent:discord:channel:${channelId}`); - expect(result?.boundSessionKey).toBeUndefined(); - expect(result?.threadBinding).toBeUndefined(); + const preflight = expectPreflightResult(result); + expect(preflight.route.agentId).toBe("newagent"); + expect(preflight.route.sessionKey).toBe(`agent:newagent:discord:channel:${channelId}`); + expect(preflight.boundSessionKey).toBeUndefined(); + expect(preflight.threadBinding).toBeUndefined(); }); it("preflights direct-message voice notes without mention gating", async () => { @@ -512,9 +523,9 @@ describe("preflightDiscordMessage", () => { }), }), ); - expect(result).not.toBeNull(); - expect(result?.isDirectMessage).toBe(true); - expect(result?.preflightAudioTranscript).toBe("hello openclaw from dm audio"); + const preflight = expectPreflightResult(result); + expect(preflight.isDirectMessage).toBe(true); + expect(preflight.preflightAudioTranscript).toBe("hello openclaw from dm audio"); }); it("keeps no-guild messages direct when channel lookup is unavailable", async () => { @@ -542,11 +553,11 @@ describe("preflightDiscordMessage", () => { } as DiscordConfig, }); - expect(result).not.toBeNull(); - expect(result?.channelInfo).toBeNull(); - expect(result?.isDirectMessage).toBe(true); - expect(result?.isGroupDm).toBe(false); - expect(result?.route.sessionKey).toBe("agent:main:discord:direct:user-1"); + const preflight = expectPreflightResult(result); + expect(preflight.channelInfo).toBeNull(); + expect(preflight.isDirectMessage).toBe(true); + expect(preflight.isGroupDm).toBe(false); + expect(preflight.route.sessionKey).toBe("agent:main:discord:direct:user-1"); }); it("falls back to the default discord account for omitted-account dm authorization", async () => { From ebe6ef321c6253ea95c6c045e8d7418677195593 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:36:02 +0100 Subject: [PATCH 429/806] test: require modal shadow labels --- ui/src/ui/components/modal-dialog.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/ui/components/modal-dialog.test.ts index 53a5d35f035..2c2a686f4b7 100644 --- a/ui/src/ui/components/modal-dialog.test.ts +++ b/ui/src/ui/components/modal-dialog.test.ts @@ -74,6 +74,14 @@ async function renderModal() { return { modal, dialog }; } +function expectShadowElement(modal: OpenClawModalDialog, id: string): HTMLElement { + const element = modal.shadowRoot?.getElementById(id); + if (!(element instanceof HTMLElement)) { + throw new Error(`Expected shadow element #${id}`); + } + return element; +} + describe("openclaw-modal-dialog", () => { beforeEach(() => { installDialogPolyfill(); @@ -101,8 +109,10 @@ describe("openclaw-modal-dialog", () => { expect(descriptionId).toBe("openclaw-modal-dialog-description"); expect(dialog.getRootNode()).toBe(modal.shadowRoot); expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull(); - expect(modal.shadowRoot?.getElementById(labelId!)?.textContent).toBe("Confirm action"); - expect(modal.shadowRoot?.getElementById(descriptionId!)?.textContent).toBe( + expect(expectShadowElement(modal, "openclaw-modal-dialog-label").textContent).toBe( + "Confirm action", + ); + expect(expectShadowElement(modal, "openclaw-modal-dialog-description").textContent).toBe( "Review the operation before continuing.", ); }); From b927d50cc76eb7e223e511af37450469067cecfa Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:37:06 +0100 Subject: [PATCH 430/806] test: tighten discord bound preflight assertions --- .../src/monitor/message-handler.preflight.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 976e35eef16..f932818e6d6 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -639,8 +639,7 @@ describe("preflightDiscordMessage", () => { registerBindingAdapter: true, }); - expect(result).not.toBeNull(); - expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); + expect(expectPreflightResult(result).boundSessionKey).toBe(threadBinding.targetSessionKey); }); it("drops hydrated bound-thread webhook copies after fetching an empty payload", async () => { @@ -770,9 +769,9 @@ describe("preflightDiscordMessage", () => { config: expect.objectContaining({ enabled: true }), }), ); - expect(result).not.toBeNull(); - expect(result?.sender.isPluralKit).toBe(true); - expect(result?.canonicalMessageId).toBe("orig-123"); + const preflight = expectPreflightResult(result); + expect(preflight.sender.isPluralKit).toBe(true); + expect(preflight.canonicalMessageId).toBe("orig-123"); }); it("skips PluralKit lookup for bound-thread webhook echoes", async () => { @@ -848,9 +847,9 @@ describe("preflightDiscordMessage", () => { }), ); - expect(result).not.toBeNull(); - expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.boundSessionKey).toBe(threadBinding.targetSessionKey); + expect(preflight.shouldRequireMention).toBe(false); }); it("drops bot messages without mention when allowBots=mentions", async () => { From e09ff2bc15496c00d8876c9b821d336c6647f8fc Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:37:57 +0100 Subject: [PATCH 431/806] test: tighten discord bot preflight assertions --- .../monitor/message-handler.preflight.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index f932818e6d6..3421e9a4f06 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -888,7 +888,7 @@ describe("preflightDiscordMessage", () => { const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("hydrates mention metadata from REST when bot mention syntax is present but mentions are missing", async () => { @@ -934,7 +934,7 @@ describe("preflightDiscordMessage", () => { botUserId: botId, }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("still drops bot control commands without a real mention when allowBots=mentions", async () => { @@ -973,7 +973,7 @@ describe("preflightDiscordMessage", () => { const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("routes ordinary guild text control commands through authorization instead of dropping them", async () => { @@ -1015,11 +1015,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.baseText).toBe("/steer keep digging"); - expect(result?.commandAuthorized).toBe(true); - expect(result?.shouldRequireMention).toBe(true); - expect(result?.shouldBypassMention).toBe(true); + const preflight = expectPreflightResult(result); + expect(preflight.baseText).toBe("/steer keep digging"); + expect(preflight.commandAuthorized).toBe(true); + expect(preflight.shouldRequireMention).toBe(true); + expect(preflight.shouldBypassMention).toBe(true); }); it("still drops Discord native command echo messages", async () => { From 2ccc85e986b91eecf63a8b6ed879ade4700aa750 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:37:55 +0100 Subject: [PATCH 432/806] test: require task parent flow ids --- src/tasks/task-executor.test.ts | 37 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index feaee1de3bb..4ac9b1f745d 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -101,6 +101,16 @@ async function withTaskExecutorStateDir(run: (stateDir: string) => Promise }); } +function expectParentFlowId(task: { parentFlowId?: string }): string { + expect(task.parentFlowId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + if (task.parentFlowId === undefined) { + throw new Error("Expected task parent flow id"); + } + return task.parentFlowId; +} + function createRunningAcpChildTaskRun( overrides: Partial[0]> = {}, ) { @@ -289,11 +299,9 @@ describe("task-executor", () => { deliveryStatus: "pending", }); - expect(created.parentFlowId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, - ); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + const parentFlowId = expectParentFlowId(created); + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, ownerKey: "agent:main:main", status: "running", goal: "Write summary", @@ -307,8 +315,8 @@ describe("task-executor", () => { terminalSummary: "Done.", }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "succeeded", endedAt: 40, goal: "Write summary", @@ -366,8 +374,9 @@ describe("task-executor", () => { terminalOutcome: "blocked", terminalSummary: "Writable session required.", }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + const parentFlowId = expectParentFlowId(created); + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "blocked", blockedTaskId: created.taskId, blockedSummary: "Writable session required.", @@ -375,7 +384,7 @@ describe("task-executor", () => { }); const retried = retryBlockedFlowAsQueuedTaskRun({ - flowId: created.parentFlowId!, + flowId: parentFlowId, runId: "run-executor-retry", childSessionKey: "agent:codex:acp:retry-child", }); @@ -387,17 +396,17 @@ describe("task-executor", () => { taskId: created.taskId, }), task: expect.objectContaining({ - parentFlowId: created.parentFlowId, + parentFlowId, parentTaskId: created.taskId, status: "queued", runId: "run-executor-retry", }), }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "queued", }); - expect(findLatestTaskForFlowId(created.parentFlowId!)).toMatchObject({ + expect(findLatestTaskForFlowId(parentFlowId)).toMatchObject({ runId: "run-executor-retry", }); expect(findTaskByRunId("run-executor-blocked")).toMatchObject({ From dfcafcaf4117d1df9650304ad0e9d2c1d92474eb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:38:57 +0100 Subject: [PATCH 433/806] test: tighten discord mention preflight assertions --- .../monitor/message-handler.preflight.test.ts | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 3421e9a4f06..a43ee665749 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -1148,9 +1148,9 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.shouldRequireMention).toBe(true); - expect(result?.wasMentioned).toBe(true); + const preflight = expectPreflightResult(result); + expect(preflight.shouldRequireMention).toBe(true); + expect(preflight.wasMentioned).toBe(true); }); it("accepts allowlisted guild messages when guild object is missing", async () => { @@ -1183,10 +1183,10 @@ describe("preflightDiscordMessage", () => { includeGuildObject: false, }); - expect(result).not.toBeNull(); - expect(result?.guildInfo?.id).toBe("guild-1"); - expect(result?.channelConfig?.allowed).toBe(true); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.guildInfo?.id).toBe("guild-1"); + expect(preflight.channelConfig?.allowed).toBe(true); + expect(preflight.shouldRequireMention).toBe(false); }); it("inherits parent thread allowlist when guild object is missing", async () => { @@ -1231,11 +1231,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.guildInfo?.id).toBe("guild-1"); - expect(result?.threadParentId).toBe(parentId); - expect(result?.channelConfig?.allowed).toBe(true); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.guildInfo?.id).toBe("guild-1"); + expect(preflight.threadParentId).toBe(parentId); + expect(preflight.channelConfig?.allowed).toBe(true); + expect(preflight.shouldRequireMention).toBe(false); }); it("handles partial thread channel owner getters during mention preflight", async () => { @@ -1294,9 +1294,9 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.threadParentId).toBe(parentId); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.threadParentId).toBe(parentId); + expect(preflight.shouldRequireMention).toBe(false); }); it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { @@ -1336,8 +1336,7 @@ describe("preflightDiscordMessage", () => { const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); - expect(result?.hasAnyMention).toBe(true); + expect(expectPreflightResult(result).hasAnyMention).toBe(true); }); it("ignores bot-sent @everyone mentions for detection", async () => { @@ -1377,8 +1376,7 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.hasAnyMention).toBe(false); + expect(expectPreflightResult(result).hasAnyMention).toBe(false); }); it("does not treat bot-sent @everyone as wasMentioned", async () => { @@ -1418,8 +1416,7 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.wasMentioned).toBe(false); + expect(expectPreflightResult(result).wasMentioned).toBe(false); }); it("uses attachment content_type for guild audio preflight mention detection", async () => { From bbfd6a2e59369d8e36418e679f9b40d1d0eb9d54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:39:31 +0100 Subject: [PATCH 434/806] test: require frontmatter install base --- src/shared/frontmatter.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts index 776b9b58194..be98fb5a7ab 100644 --- a/src/shared/frontmatter.test.ts +++ b/src/shared/frontmatter.test.ts @@ -11,6 +11,15 @@ import { resolveOpenClawManifestRequires, } from "./frontmatter.js"; +function expectInstallBase( + parsed: ReturnType, +): NonNullable> { + if (parsed === undefined) { + throw new Error("Expected manifest install base"); + } + return parsed; +} + describe("shared/frontmatter", () => { test("normalizeStringList handles strings, arrays, and non-list values", () => { expect(normalizeStringList("a, b,,c")).toEqual(["a", "b", "c"]); @@ -135,7 +144,7 @@ describe("shared/frontmatter", () => { id?: string; label?: string; bins?: string[]; - }>({ extra: true }, parsed!), + }>({ extra: true }, expectInstallBase(parsed)), ).toEqual({ extra: true, id: "brew.git", From de21569e05f9fddd8c74ac98839b59bc6d4a31cf Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:39:52 +0100 Subject: [PATCH 435/806] test: tighten discord audio preflight assertions --- .../src/monitor/message-handler.preflight.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index a43ee665749..f4fa9d56f93 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -1484,9 +1484,9 @@ describe("preflightDiscordMessage", () => { }), }), ); - expect(result).not.toBeNull(); - expect(result?.wasMentioned).toBe(true); - expect(result?.preflightAudioTranscript).toBe("hey openclaw"); + const preflight = expectPreflightResult(result); + expect(preflight.wasMentioned).toBe(true); + expect(preflight.preflightAudioTranscript).toBe("hey openclaw"); }); it("does not transcribe guild audio from unauthorized members", async () => { @@ -1629,7 +1629,7 @@ describe("preflightDiscordMessage", () => { "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } }, }, }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); } finally { routeSpy.mockRestore(); ensureSpy.mockRestore(); @@ -1701,7 +1701,7 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => { webhookId: "wh-1", webhookToken: "tok-1", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); manager.unbindThread({ threadId: "thread-1", From 28893ce89cd3c4c740aaad158b3806e0ea99a654 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:40:46 +0100 Subject: [PATCH 436/806] test: require cron cadence timestamps --- src/cron/service.issue-22895-every-next-run.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cron/service.issue-22895-every-next-run.test.ts b/src/cron/service.issue-22895-every-next-run.test.ts index 0104d53e040..d11d111b9bd 100644 --- a/src/cron/service.issue-22895-every-next-run.test.ts +++ b/src/cron/service.issue-22895-every-next-run.test.ts @@ -21,6 +21,13 @@ function createEveryJob(state: CronJob["state"]): CronJob { }; } +function expectTimestamp(value: number | undefined | null, label: string): number { + if (typeof value !== "number") { + throw new Error(`Expected ${label} timestamp`); + } + return value; +} + describe("Cron issue #22895 interval scheduling", () => { it("uses lastRunAtMs cadence when the next interval is still in the future", () => { const nowMs = Date.parse("2026-02-22T10:10:00.000Z"); @@ -34,9 +41,9 @@ describe("Cron issue #22895 interval scheduling", () => { nowMs, ); - expect(nextFromLast).toBe(job.state.lastRunAtMs! + EVERY_30_MIN_MS); + expect(nextFromLast).toBe(expectTimestamp(job.state.lastRunAtMs, "last run") + EVERY_30_MIN_MS); expect(nextFromAnchor).toBe(Date.parse("2026-02-22T10:14:00.000Z")); - expect(nextFromLast).toBeGreaterThan(nextFromAnchor!); + expect(nextFromLast).toBeGreaterThan(expectTimestamp(nextFromAnchor, "next anchor run")); }); it("falls back to anchor scheduling when lastRunAtMs cadence is already in the past", () => { From 1ecc1e899e2b28222cc1236a204e6599558e0015 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:42:21 +0100 Subject: [PATCH 437/806] test: require active secrets snapshot --- src/secrets/runtime-auth-refresh-failure.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/secrets/runtime-auth-refresh-failure.test.ts b/src/secrets/runtime-auth-refresh-failure.test.ts index 3e119233c27..793e6a2a063 100644 --- a/src/secrets/runtime-auth-refresh-failure.test.ts +++ b/src/secrets/runtime-auth-refresh-failure.test.ts @@ -20,6 +20,16 @@ import { vi.unmock("../version.js"); +function expectActiveSecretsRuntimeSnapshot(): NonNullable< + ReturnType +> { + const snapshot = getActiveSecretsRuntimeSnapshot(); + if (snapshot === null) { + throw new Error("Expected active secrets runtime snapshot"); + } + return snapshot; +} + describe("secrets runtime snapshot auth refresh failure", () => { let envSnapshot: SecretsRuntimeEnvSnapshot; @@ -75,10 +85,9 @@ describe("secrets runtime snapshot auth refresh failure", () => { }), ).rejects.toThrow(/simulated secrets runtime refresh failure/i); - const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); + const activeAfterFailure = expectActiveSecretsRuntimeSnapshot(); expectResolvedOpenAIRuntime(agentDir); - expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual( + expect(activeAfterFailure.sourceConfig.models?.providers?.openai?.apiKey).toEqual( OPENAI_FILE_KEY_REF, ); }); From 127fdb61207e7a43f3f5ec27f1931227aaeb6e72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:43:52 +0100 Subject: [PATCH 438/806] test: require status gateway warning --- src/commands/status.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 2637f544b97..044d50f31df 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1241,7 +1241,7 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); - expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + expect(payload.gateway.error ?? payload.gateway.authWarning).toEqual(expect.any(String)); if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { expect( payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), From 6f2ada17234b384f73829ddd2a732041fbe073b2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:44:25 +0100 Subject: [PATCH 439/806] test: tighten tts command result assertions --- src/auto-reply/reply/commands-tts.test.ts | 88 +++++++++++++---------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index b6d6b6946ac..f576b5aa557 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -41,6 +41,7 @@ vi.mock("../../tts/tts.js", () => ttsMocks); const { handleTtsCommands } = await import("./commands-tts.js"); const PRIMARY_TTS_PROVIDER = "acme-speech"; const FALLBACK_TTS_PROVIDER = "backup-speech"; +type TtsCommandResult = Awaited>; function buildTtsParams( commandBodyNormalized: string, @@ -62,6 +63,22 @@ function buildTtsParams( } as unknown as Parameters[0]; } +function expectHandled(result: TtsCommandResult): NonNullable { + if (!result) { + throw new Error("Expected TTS command to be handled"); + } + expect(result.shouldContinue).toBe(false); + return result; +} + +function expectReply(result: TtsCommandResult): NonNullable["reply"] { + const handled = expectHandled(result); + if (!handled.reply) { + throw new Error("Expected TTS command to return a reply"); + } + return handled.reply; +} + describe("handleTtsCommands status fallback reporting", () => { beforeEach(() => { vi.clearAllMocks(); @@ -104,14 +121,10 @@ describe("handleTtsCommands status fallback reporting", () => { }); const result = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain( - `Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( - `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( + const reply = expectReply(result); + expect(reply.text).toContain(`Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain(`Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain( `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 73ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 420ms`, ); }); @@ -136,14 +149,10 @@ describe("handleTtsCommands status fallback reporting", () => { }); const result = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain("Error: TTS conversion failed"); - expect(result?.reply?.text).toContain( - `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( - `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(timeout) 999ms`, - ); + const reply = expectReply(result); + expect(reply.text).toContain("Error: TTS conversion failed"); + expect(reply.text).toContain(`Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain(`Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(timeout) 999ms`); }); it("persists fallback metadata from /tts audio and renders it in /tts status", async () => { @@ -177,19 +186,19 @@ describe("handleTtsCommands status fallback reporting", () => { }); const audioResult = await handleTtsCommands(buildTtsParams("/tts audio hello world"), true); - expect(audioResult?.shouldContinue).toBe(false); - expect(audioResult?.reply?.mediaUrl).toBe("/tmp/fallback.ogg"); + const audioReply = expectReply(audioResult); + expect(audioReply.mediaUrl).toBe("/tmp/fallback.ogg"); const statusResult = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(statusResult?.shouldContinue).toBe(false); - expect(statusResult?.reply?.text).toContain(`Provider: ${FALLBACK_TTS_PROVIDER}`); - expect(statusResult?.reply?.text).toContain( + const statusReply = expectReply(statusResult); + expect(statusReply.text).toContain(`Provider: ${FALLBACK_TTS_PROVIDER}`); + expect(statusReply.text).toContain( `Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, ); - expect(statusResult?.reply?.text).toContain( + expect(statusReply.text).toContain( `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, ); - expect(statusResult?.reply?.text).toContain( + expect(statusReply.text).toContain( `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 65ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 175ms`, ); }); @@ -201,8 +210,8 @@ describe("handleTtsCommands status fallback reporting", () => { } as OpenClawConfig), true, ); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain("TTS status"); + const reply = expectReply(result); + expect(reply.text).toContain("TTS status"); }); it("resolves status config for the active agent", async () => { @@ -212,7 +221,7 @@ describe("handleTtsCommands status fallback reporting", () => { const result = await handleTtsCommands(buildTtsParams("/tts status", cfg, "reader"), true); - expect(result?.shouldContinue).toBe(false); + expectHandled(result); expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith( cfg, expect.objectContaining({ agentId: "reader", channelId: "forum" }), @@ -237,7 +246,7 @@ describe("handleTtsCommands status fallback reporting", () => { true, ); - expect(result?.shouldContinue).toBe(false); + expectHandled(result); expect(ttsMocks.textToSpeech).toHaveBeenCalledWith( expect.objectContaining({ text: "hello", @@ -258,11 +267,11 @@ describe("handleTtsCommands status fallback reporting", () => { ]); const listResult = await handleTtsCommands(buildTtsParams("/tts persona"), true); - expect(listResult?.shouldContinue).toBe(false); - expect(listResult?.reply?.text).toContain("alfred (Alfred) provider=google"); + const listReply = expectReply(listResult); + expect(listReply.text).toContain("alfred (Alfred) provider=google"); const setResult = await handleTtsCommands(buildTtsParams("/tts persona alfred"), true); - expect(setResult?.shouldContinue).toBe(false); + expectHandled(setResult); expect(ttsMocks.setTtsPersona).toHaveBeenCalledWith("/tmp/tts-prefs.json", "alfred"); }); @@ -321,8 +330,8 @@ describe("handleTtsCommands status fallback reporting", () => { true, ); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply).toMatchObject({ + const reply = expectReply(result); + expect(reply).toMatchObject({ mediaUrl: "/tmp/latest.ogg", audioAsVoice: true, spokenText: "latest visible reply", @@ -359,12 +368,14 @@ describe("handleTtsCommands status fallback reporting", () => { const params = buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }); const first = await handleTtsCommands(params, true); - expect(first?.reply?.mediaUrl).toBe("/tmp/latest.ogg"); + const firstReply = expectReply(first); + expect(firstReply.mediaUrl).toBe("/tmp/latest.ogg"); ttsMocks.textToSpeech.mockClear(); const second = await handleTtsCommands(params, true); - expect(second?.reply?.text).toContain("already sent"); + const secondReply = expectReply(second); + expect(secondReply.text).toContain("already sent"); expect(ttsMocks.textToSpeech).not.toHaveBeenCalled(); }); @@ -376,21 +387,24 @@ describe("handleTtsCommands status fallback reporting", () => { buildTtsParams("/tts chat on", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(onResult?.reply?.text).toContain("enabled for this chat"); + const onReply = expectReply(onResult); + expect(onReply.text).toContain("enabled for this chat"); expect(sessionEntry.ttsAuto).toBe("always"); const offResult = await handleTtsCommands( buildTtsParams("/tts chat off", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(offResult?.reply?.text).toContain("disabled for this chat"); + const offReply = expectReply(offResult); + expect(offReply.text).toContain("disabled for this chat"); expect(sessionEntry.ttsAuto).toBe("off"); const clearResult = await handleTtsCommands( buildTtsParams("/tts chat default", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(clearResult?.reply?.text).toContain("override cleared"); + const clearReply = expectReply(clearResult); + expect(clearReply.text).toContain("override cleared"); expect(sessionEntry.ttsAuto).toBeUndefined(); }); }); From ce6fca41d87ee5710d88ab1f928a17aaf8c6cf60 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:48:08 +0100 Subject: [PATCH 440/806] test: require codex block reply text --- .../src/app-server/user-input-bridge.test.ts | 16 +++++++++++++--- extensions/memory-core/src/memory/index.test.ts | 2 +- .../src/memory/manager.watcher-config.test.ts | 2 +- .../memory-core/src/memory/qmd-manager.test.ts | 10 ++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index 7ef88c1cc93..1b2ca9e39ed 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -10,6 +10,18 @@ function createParams(): EmbeddedRunAttemptParams { } as unknown as EmbeddedRunAttemptParams; } +function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string { + const onBlockReply = params.onBlockReply; + if (onBlockReply === undefined) { + throw new Error("Expected onBlockReply callback"); + } + const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0]; + if (typeof payload?.text !== "string") { + throw new Error("Expected first block reply text"); + } + return payload.text; +} + describe("Codex app-server user input bridge", () => { it("prompts the originating chat and resolves request_user_input from the next queued message", async () => { const params = createParams(); @@ -161,9 +173,7 @@ describe("Codex app-server user input bridge", () => { }); await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - const payload = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0]; - expect(payload).toEqual(expect.objectContaining({ text: expect.any(String) })); - const text = payload?.text ?? ""; + const text = expectFirstBlockReplyText(params); expect(text).toContain("Mode <\uff20U123>"); expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here"); expect(text).toContain( diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index c9b27f9f008..b6425311ff6 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -278,7 +278,7 @@ describe("memory index", () => { if (!result.manager) { throw new Error(missingMessage); } - return result.manager; + return result.manager as unknown as MemoryIndexManager; } async function getPersistentManager(cfg: TestCfg): Promise { diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 480f15cb70e..9fc0f45fd64 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -202,7 +202,7 @@ describe("memory watcher config", () => { if (!result.manager) { throw new Error("manager missing"); } - manager = result.manager; + manager = result.manager as unknown as MemoryIndexManager; expect(watchMock).not.toHaveBeenCalled(); }); diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 8548f467f84..76dd3e0edcf 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -484,10 +484,12 @@ describe("QmdMemoryManager", () => { }); const { manager } = await createManager({ mode: "full" }); - if (releaseUpdate === null) { - throw new Error("Expected qmd update release callback"); - } - releaseUpdate(); + ( + releaseUpdate ?? + (() => { + throw new Error("Expected qmd update release callback"); + }) + )(); await manager?.close(); }); From 6e6c0cfbbee3fd707b6e02bce514a0b5fb6b36e3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:48:30 +0100 Subject: [PATCH 441/806] test: tighten discord queue timeout assertions --- extensions/discord/src/monitor/message-handler.queue.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index 33f90079d00..86ecc74447b 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -440,7 +440,7 @@ describe("createDiscordMessageHandler queue behavior", () => { await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); - expect(capturedAbortSignals[0]?.aborted).not.toBe(true); + expect(capturedAbortSignals).toEqual([undefined]); expect(params.runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("timed out")); firstRun.resolve(); @@ -448,7 +448,7 @@ describe("createDiscordMessageHandler queue behavior", () => { await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(2); - expect(capturedAbortSignals[1]?.aborted).not.toBe(true); + expect(capturedAbortSignals).toEqual([undefined, undefined]); secondRun.resolve(); await secondRun.promise; From 07a577630a89aa345aa2c53c11ac472ae8a0b47c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:50:30 +0100 Subject: [PATCH 442/806] test: tighten subagent target assertions --- src/auto-reply/reply/subagents-utils.test.ts | 31 +++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts index c457c1e4a03..b2c5b556c7c 100644 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ b/src/auto-reply/reply/subagents-utils.test.ts @@ -40,6 +40,18 @@ function resolveTarget(runs: SubagentRunRecord[], token: string | undefined) { }); } +function expectResolvedRunId( + runs: SubagentRunRecord[], + token: string | undefined, + expectedRunId: string, +): void { + const resolved = resolveTarget(runs, token); + if (!resolved.entry) { + throw new Error(`Expected ${String(token)} to resolve, got ${resolved.error ?? "no target"}`); + } + expect(resolved.entry.runId).toBe(expectedRunId); +} + describe("subagents utils", () => { afterEach(() => { vi.restoreAllMocks(); @@ -65,8 +77,7 @@ describe("subagents utils", () => { makeRun({ runId: "old", createdAt: NOW_MS - 2_000 }), makeRun({ runId: "new", createdAt: NOW_MS - 500 }), ]; - const resolved = resolveTarget(runs, " last "); - expect(resolved.entry?.runId).toBe("new"); + expectResolvedRunId(runs, " last ", "new"); }); it("resolves numeric index from running then recent finished order", () => { @@ -91,14 +102,14 @@ describe("subagents utils", () => { }), ]; - expect(resolveTarget(runs, "1").entry?.runId).toBe("running"); - expect(resolveTarget(runs, "2").entry?.runId).toBe("recent-finished"); + expectResolvedRunId(runs, "1", "running"); + expectResolvedRunId(runs, "2", "recent-finished"); expect(resolveTarget(runs, "3").error).toBe("invalid:3"); }); it("resolves session key target and unknown session errors", () => { const run = makeRun({ runId: "abc123", childSessionKey: "agent:beta:subagent:xyz" }); - expect(resolveTarget([run], "agent:beta:subagent:xyz").entry?.runId).toBe("abc123"); + expectResolvedRunId([run], "agent:beta:subagent:xyz", "abc123"); expect(resolveTarget([run], "agent:beta:subagent:missing").error).toBe( "unknown-session:agent:beta:subagent:missing", ); @@ -111,11 +122,11 @@ describe("subagents utils", () => { makeRun({ runId: "run-beta-1", label: "Beta Worker" }), ]; - expect(resolveTarget(runs, "beta worker").entry?.runId).toBe("run-beta-1"); - expect(resolveTarget(runs, "beta").entry?.runId).toBe("run-beta-1"); - expect(resolveTarget(runs, "run-beta").entry?.runId).toBe("run-beta-1"); + expectResolvedRunId(runs, "beta worker", "run-beta-1"); + expectResolvedRunId(runs, "beta", "run-beta-1"); + expectResolvedRunId(runs, "run-beta", "run-beta-1"); - expect(resolveTarget(runs, "alpha core").entry?.runId).toBe("run-alpha-1"); + expectResolvedRunId(runs, "alpha core", "run-alpha-1"); expect(resolveTarget(runs, "alpha").error).toBe("ambiguous-prefix:alpha"); expect(resolveTarget(runs, "run-alpha").error).toBe("ambiguous-run:run-alpha"); expect(resolveTarget(runs, "missing").error).toBe("unknown:missing"); @@ -149,6 +160,6 @@ describe("subagents utils", () => { }), ]; - expect(resolveTarget(runs, "same worker").entry?.runId).toBe("run-new"); + expectResolvedRunId(runs, "same worker", "run-new"); }); }); From 827354b7b2453a2492e4f1f4f887f2bdcc994583 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:51:21 +0100 Subject: [PATCH 443/806] test: require active video task status --- src/agents/video-generation-task-status.test.ts | 16 +++++++++++++--- src/auto-reply/reply/commands-tts.test.ts | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/agents/video-generation-task-status.test.ts b/src/agents/video-generation-task-status.test.ts index d2bfe3ac37b..95e9265b8ac 100644 --- a/src/agents/video-generation-task-status.test.ts +++ b/src/agents/video-generation-task-status.test.ts @@ -15,6 +15,15 @@ const taskRuntimeInternalMocks = vi.hoisted(() => ({ vi.mock("../tasks/runtime-internal.js", () => taskRuntimeInternalMocks); +function expectActiveVideoGenerationTask( + task: ReturnType, +): NonNullable> { + if (task == null) { + throw new Error("Expected active video generation task"); + } + return task; +} + describe("video generation task status", () => { beforeEach(() => { taskRuntimeInternalMocks.listTasksForOwnerKey.mockReset(); @@ -92,11 +101,12 @@ describe("video generation task status", () => { const task = findActiveVideoGenerationTaskForSession("agent:main"); expect(task?.taskId).toBe("task-running"); - expect(getVideoGenerationTaskProviderId(task!)).toBe("openai"); - expect(buildVideoGenerationTaskStatusText(task!, { duplicateGuard: true })).toContain( + const activeTask = expectActiveVideoGenerationTask(task); + expect(getVideoGenerationTaskProviderId(activeTask)).toBe("openai"); + expect(buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true })).toContain( "Do not call video_generate again for this request.", ); - expect(buildVideoGenerationTaskStatusDetails(task!)).toMatchObject({ + expect(buildVideoGenerationTaskStatusDetails(activeTask)).toMatchObject({ active: true, existingTask: true, status: "running", diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index f576b5aa557..2a2f4103fd1 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -71,7 +71,9 @@ function expectHandled(result: TtsCommandResult): NonNullable return result; } -function expectReply(result: TtsCommandResult): NonNullable["reply"] { +function expectReply( + result: TtsCommandResult, +): NonNullable["reply"]> { const handled = expectHandled(result); if (!handled.reply) { throw new Error("Expected TTS command to return a reply"); From 9b3c3686bcf5346b3a1fa6da49918e78b60d8fa3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:52:08 +0100 Subject: [PATCH 444/806] test: tighten block reply enqueue assertion --- src/auto-reply/reply/reply-delivery.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/reply-delivery.test.ts b/src/auto-reply/reply/reply-delivery.test.ts index 01b3ff9cd3f..f5b6aa838ac 100644 --- a/src/auto-reply/reply/reply-delivery.test.ts +++ b/src/auto-reply/reply/reply-delivery.test.ts @@ -309,7 +309,15 @@ describe("createBlockReplyDeliveryHandler", () => { await handler(payload); - const enqueuedPayload = enqueue.mock.calls[0]?.[0]; + expect(enqueue).toHaveBeenCalledTimes(1); + const [firstCall] = enqueue.mock.calls; + if (!firstCall) { + throw new Error("Expected block reply pipeline enqueue call"); + } + const [enqueuedPayload] = firstCall; + if (enqueuedPayload === undefined) { + throw new Error("Expected block reply pipeline payload"); + } expect(enqueuedPayload).toEqual({ text: "Alpha", mediaUrl: undefined, From 6f26a477be36f256dc25e20f9dfcce226b9de096 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:52:59 +0100 Subject: [PATCH 445/806] test: require core weak guard lookups --- src/agents/openclaw-tools.update-plan.test.ts | 19 +++++++++++++------ src/crestodian/operations.test.ts | 10 +++++++++- src/security/audit-probe-failure.test.ts | 5 +++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index 31dd3b8c9c3..be583d2ba72 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -15,6 +15,17 @@ function toolNames(tools: ReturnType): string[] { return tools.map((tool) => tool.name); } +function expectToolNamed( + tools: ReturnType, + name: string, +): ReturnType[number] { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return tool; +} + function openAiGpt5Params( config: OpenClawConfig, overrides: Partial = {}, @@ -67,13 +78,9 @@ describe("openclaw-tools update_plan gating", () => { wrapBeforeToolCallHook: false, }); + expect(isToolWrappedWithBeforeToolCallHook(expectToolNamed(tools, "sessions_list"))).toBe(true); expect( - isToolWrappedWithBeforeToolCallHook(tools.find((tool) => tool.name === "sessions_list")!), - ).toBe(true); - expect( - isToolWrappedWithBeforeToolCallHook( - unwrappedTools.find((tool) => tool.name === "sessions_list")!, - ), + isToolWrappedWithBeforeToolCallHook(expectToolNamed(unwrappedTools, "sessions_list")), ).toBe(false); }); diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 653dc31a8c4..bb5d2fc4e72 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -12,6 +12,14 @@ import { type TestConfig = Record; +function parseLastJsonLine(raw: string): unknown { + const lastLine = raw.trim().split("\n").at(-1); + if (!lastLine) { + throw new Error("Expected audit log to contain at least one JSON line"); + } + return JSON.parse(lastLine) as unknown; +} + const mockConfig = vi.hoisted(() => { const initial = {}; const state = { @@ -550,7 +558,7 @@ describe("parseCrestodianOperation", () => { }); expect(lines.join("\n")).toContain("[crestodian] done: doctor.fix"); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); - const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim().split("\n").at(-1)!); + const audit = parseLastJsonLine(await fs.readFile(auditPath, "utf8")); expect(audit).toMatchObject({ operation: "doctor.fix", summary: "Ran doctor repairs", diff --git a/src/security/audit-probe-failure.test.ts b/src/security/audit-probe-failure.test.ts index 814df4a35c6..9eb6dba9e62 100644 --- a/src/security/audit-probe-failure.test.ts +++ b/src/security/audit-probe-failure.test.ts @@ -14,7 +14,7 @@ describe("security audit deep probe failure", () => { close?: { code: number; reason: string } | null; }; }; - expectedError?: string; + expectedError: string; }> = [ { name: "probe returns failed result", @@ -50,7 +50,8 @@ describe("security audit deep probe failure", () => { findings.some((finding) => finding.checkId === "gateway.probe_failed"), testCase.name, ).toBe(true); - expect(findings[0]?.detail).toContain(testCase.expectedError!); + const probeFailure = findings.find((finding) => finding.checkId === "gateway.probe_failed"); + expect(probeFailure?.detail, testCase.name).toContain(testCase.expectedError); } }); }); From b13e8b2ed752d0667e63b0cc45cddfbf6eb301ae Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:54:10 +0100 Subject: [PATCH 446/806] test: tighten memory fallback provider assertions --- .../memory/manager.mistral-provider.test.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts index 2f5e7733a0f..8957f45b4d5 100644 --- a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts +++ b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts @@ -57,6 +57,19 @@ function createSettings(params: { } as unknown as ResolvedMemorySearchConfig; } +type MemoryFallbackProviderRequest = NonNullable< + ReturnType +>; + +function expectMemoryFallbackRequest( + request: ReturnType, +): MemoryFallbackProviderRequest { + if (!request) { + throw new Error("Expected memory fallback provider request"); + } + return request; +} + describe("memory manager mistral provider wiring", () => { it("stores mistral client when mistral provider is selected", () => { const mistralRuntime: EmbeddingProviderRuntime = { @@ -72,7 +85,7 @@ describe("memory manager mistral provider wiring", () => { providerUnavailableReason: undefined, }); - expect(state.provider?.id).toBe("mistral"); + expect(state.provider).toEqual(expect.objectContaining({ id: "mistral" })); expect(state.providerRuntime).toBe(mistralRuntime); }); @@ -105,7 +118,7 @@ describe("memory manager mistral provider wiring", () => { expect(fallbackState.fallbackFrom).toBe("openai"); expect(fallbackState.fallbackReason).toBe("forced test"); - expect(fallbackState.provider?.id).toBe("mistral"); + expect(fallbackState.provider).toEqual(expect.objectContaining({ id: "mistral" })); expect(fallbackState.providerRuntime).toBe(mistralRuntime); }); @@ -116,9 +129,10 @@ describe("memory manager mistral provider wiring", () => { currentProviderId: "openai", }); - expect(request?.provider).toBe("ollama"); - expect(request?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); - expect(request?.fallback).toBe("none"); + const fallbackRequest = expectMemoryFallbackRequest(request); + expect(fallbackRequest.provider).toBe("ollama"); + expect(fallbackRequest.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); + expect(fallbackRequest.fallback).toBe("none"); }); it("includes outputDimensionality in the primary provider request", () => { @@ -158,8 +172,9 @@ describe("memory manager mistral provider wiring", () => { currentProviderId: "openai", }); - expect(request?.provider).toBe("lmstudio"); - expect(request?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); - expect(request?.fallback).toBe("none"); + const fallbackRequest = expectMemoryFallbackRequest(request); + expect(fallbackRequest.provider).toBe("lmstudio"); + expect(fallbackRequest.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); + expect(fallbackRequest.fallback).toBe("none"); }); }); From ceb0385d29dfa572efd23abf2265b7f8254267bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:55:06 +0100 Subject: [PATCH 447/806] test: require media process handles --- .../memory-host-sdk/src/host/internal.test.ts | 17 +++++++++++++---- src/media/audio-transcode.test.ts | 15 +++++++++------ test/scripts/managed-child-process.test.ts | 14 +++++++++++--- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index ad10444ec0f..926632ca2d7 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -16,6 +16,8 @@ import { type MemoryMultimodalSettings, } from "./multimodal.js"; +type FileEntry = NonNullable>>; + let sharedTempRoot = ""; let sharedTempId = 0; @@ -38,6 +40,13 @@ function setupTempDirLifecycle(prefix: string): () => string { return () => tmpDir; } +function expectFileEntry(entry: Awaited>): FileEntry { + if (!entry) { + throw new Error("Expected file entry to be built"); + } + return entry; +} + const multimodal: MemoryMultimodalSettings = { enabled: true, modalities: ["image", "audio"], @@ -108,15 +117,15 @@ describe("memory host SDK package internals", () => { const imagePath = path.join(tmpDir, "diagram.png"); fsSync.writeFileSync(imagePath, Buffer.from("png")); - const entry = await buildFileEntry(imagePath, tmpDir, multimodal); - const built = await buildMultimodalChunkForIndexing(entry!); + const entry = expectFileEntry(await buildFileEntry(imagePath, tmpDir, multimodal)); + const built = await buildMultimodalChunkForIndexing(entry); expect(built?.chunk.embeddingInput?.parts).toEqual([ { type: "text", text: "Image file: diagram.png" }, expect.objectContaining({ type: "inline-data", mimeType: "image/png" }), ]); - fsSync.writeFileSync(imagePath, Buffer.alloc(entry!.size + 32, 1)); - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); + fsSync.writeFileSync(imagePath, Buffer.alloc(entry.size + 32, 1)); + await expect(buildMultimodalChunkForIndexing(entry)).resolves.toBeNull(); }); it("chunks mixed text and preserves surrogate pairs", () => { diff --git a/src/media/audio-transcode.test.ts b/src/media/audio-transcode.test.ts index 1451d926efc..655ce7b809a 100644 --- a/src/media/audio-transcode.test.ts +++ b/src/media/audio-transcode.test.ts @@ -23,12 +23,14 @@ describe("transcodeAudioBufferToOpus", () => { runFfmpegMock.mockImplementationOnce(async (args: string[]) => { capturedInputPath = args[args.indexOf("-i") + 1]; capturedOutputPath = args.at(-1); - if (!capturedInputPath || !capturedOutputPath) { + const inputPath = capturedInputPath; + const outputPath = capturedOutputPath; + if (!inputPath || !outputPath) { throw new Error("missing ffmpeg paths"); } - await expect(readFile(capturedInputPath)).resolves.toEqual(Buffer.from("source-mp3")); + await expect(readFile(inputPath)).resolves.toEqual(Buffer.from("source-mp3")); await import("node:fs/promises").then((fs) => - fs.writeFile(capturedOutputPath!, Buffer.from("opus-output")), + fs.writeFile(outputPath, Buffer.from("opus-output")), ); }); @@ -76,14 +78,15 @@ describe("transcodeAudioBufferToOpus", () => { runFfmpegMock.mockImplementationOnce(async (args: string[]) => { capturedInputPath = args[args.indexOf("-i") + 1]; capturedOutputPath = args.at(-1); - if (!capturedOutputPath) { + const outputPath = capturedOutputPath; + if (!outputPath) { throw new Error("missing ffmpeg output path"); } - const outputBaseName = path.basename(capturedOutputPath); + const outputBaseName = path.basename(outputPath); expect(outputBaseName).toContain("escape.opus"); expect(outputBaseName).toMatch(/\.part$/); await import("node:fs/promises").then((fs) => - fs.writeFile(capturedOutputPath!, Buffer.from("opus-output")), + fs.writeFile(outputPath, Buffer.from("opus-output")), ); }); diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 237c17a1a95..c7c3ae0f832 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -9,6 +9,13 @@ import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDir } = createScriptTestHarness(); +function expectProcessPid(pid: number | undefined): number { + if (pid == null) { + throw new Error("Expected spawned process to expose a pid"); + } + return pid; +} + describe("managed-child-process", () => { it("maps forwarded signals to shell-compatible exit codes", () => { expect(signalExitCode("SIGHUP")).toBe(129); @@ -56,6 +63,7 @@ process.exitCode = await runManagedCommand({ const runner = spawn(process.execPath, [runnerPath], { stdio: "ignore", }); + const runnerPid = expectProcessPid(runner.pid); let childPid = 0; try { @@ -65,14 +73,14 @@ process.exitCode = await runManagedCommand({ expect(Number.isInteger(childPid)).toBe(true); expect(isProcessAlive(childPid)).toBe(true); - process.kill(runner.pid!, "SIGTERM"); + process.kill(runnerPid, "SIGTERM"); const result = await waitForClose(runner); expect(result).toEqual({ code: 143, signal: null }); await waitFor(() => !isProcessAlive(childPid), 10_000); } finally { - if (runner.pid && isProcessAlive(runner.pid)) { - process.kill(runner.pid, "SIGKILL"); + if (isProcessAlive(runnerPid)) { + process.kill(runnerPid, "SIGKILL"); } if (childPid && isProcessAlive(childPid)) { process.kill(childPid, "SIGKILL"); From 396179883c6387d4343959d6801763b454560f30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:57:22 +0100 Subject: [PATCH 448/806] test: require compact ui controls --- ui/src/ui/views/command-palette.test.ts | 35 +++++++++----- ui/src/ui/views/config-quick.test.ts | 48 +++++++++++--------- ui/src/ui/views/usage-render-details.test.ts | 39 ++++++++++------ 3 files changed, 77 insertions(+), 45 deletions(-) diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts index 7bb590c9ca7..b96317e49ed 100644 --- a/ui/src/ui/views/command-palette.test.ts +++ b/ui/src/ui/views/command-palette.test.ts @@ -39,6 +39,22 @@ function restoreShowModalDescriptor() { delete (HTMLDialogElement.prototype as Partial).showModal; } +function expectPaletteInput(): HTMLInputElement { + const input = container.querySelector("#cmd-palette-input"); + if (!(input instanceof HTMLInputElement)) { + throw new Error("Expected command palette input"); + } + return input; +} + +function expectPaletteDialog(): HTMLDialogElement { + const dialog = container.querySelector("dialog.cmd-palette-overlay"); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected command palette dialog"); + } + return dialog; +} + function createProps(overrides: Partial = {}): CommandPaletteProps { return { open: true, @@ -172,7 +188,7 @@ describe("command palette", () => { const onToggle = vi.fn(); await renderPalette({ onToggle }); - const input = container.querySelector("#cmd-palette-input"); + const input = expectPaletteInput(); expect(document.activeElement).toBe(input); const tab = new KeyboardEvent("keydown", { @@ -180,8 +196,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - expect(input).toBeInstanceOf(HTMLInputElement); - input!.dispatchEvent(tab); + input.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(input); @@ -190,7 +205,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input!.dispatchEvent(escape); + input.dispatchEvent(escape); expect(escape.defaultPrevented).toBe(true); expect(onToggle).toHaveBeenCalledTimes(1); @@ -202,20 +217,18 @@ describe("command palette", () => { it("does not toggle twice when Escape is followed by dialog cancel", async () => { const onToggle = vi.fn(); await renderPalette({ onToggle }); - const dialog = container.querySelector("dialog.cmd-palette-overlay"); - const input = container.querySelector("#cmd-palette-input"); - expect(dialog?.open).toBe(true); - expect(input).toBeInstanceOf(HTMLInputElement); + const dialog = expectPaletteDialog(); + const input = expectPaletteInput(); + expect(dialog.open).toBe(true); - input!.dispatchEvent( + input.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); - expect(dialog).toBeInstanceOf(HTMLDialogElement); - dialog!.dispatchEvent(new Event("cancel", { cancelable: true })); + dialog.dispatchEvent(new Event("cancel", { cancelable: true })); expect(onToggle).toHaveBeenCalledTimes(1); }); diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 4c10a1a6674..0317bb18360 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -4,6 +4,23 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { renderQuickSettings, type QuickSettingsProps } from "./config-quick.ts"; +function expectButtonByText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button labelled ${text}`); + } + return button; +} + +function expectFileInput(input: Element | null | undefined): HTMLInputElement { + if (!(input instanceof HTMLInputElement)) { + throw new Error("Expected file input"); + } + return input; +} + function createProps(overrides: Partial = {}): QuickSettingsProps { return { currentModel: "gpt-5.5", @@ -253,11 +270,7 @@ describe("renderQuickSettings", () => { expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain( "UI override", ); - const clear = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Clear override", - ); - expect(clear).toBeInstanceOf(HTMLButtonElement); - clear!.dispatchEvent(new Event("click")); + expectButtonByText(container, "Clear override").dispatchEvent(new Event("click")); expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); }); @@ -307,18 +320,19 @@ describe("renderQuickSettings", () => { const container = document.createElement("div"); render(renderQuickSettings(createProps({ onUserAvatarChange })), container); - const input = Array.from(container.querySelectorAll('input[type="file"]')).find( - (node) => !node.closest(".qs-identity-card--assistant"), - ) as HTMLInputElement | null; - expect(input).toBeInstanceOf(HTMLInputElement); + const input = expectFileInput( + Array.from(container.querySelectorAll('input[type="file"]')).find( + (node) => !node.closest(".qs-identity-card--assistant"), + ), + ); const file = new File([new Uint8Array(1_500_001)], "avatar.png", { type: "image/png" }); - Object.defineProperty(input!, "files", { + Object.defineProperty(input, "files", { configurable: true, value: [file], }); - input!.dispatchEvent(new Event("change")); + input.dispatchEvent(new Event("change")); expect(fileReader).not.toHaveBeenCalled(); expect(onUserAvatarChange).not.toHaveBeenCalled(); @@ -355,11 +369,7 @@ describe("renderQuickSettings", () => { container, ); - const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Import", - ); - expect(customButton).toBeInstanceOf(HTMLButtonElement); - customButton!.click(); + expectButtonByText(container, "Import").click(); expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1); expect(setTheme).not.toHaveBeenCalled(); @@ -383,11 +393,7 @@ describe("renderQuickSettings", () => { container, ); - const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Light Green", - ); - expect(customButton).toBeInstanceOf(HTMLButtonElement); - customButton!.click(); + expectButtonByText(container, "Light Green").click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); expect(onOpenCustomThemeImport).not.toHaveBeenCalled(); diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/ui/views/usage-render-details.test.ts index c17053f4de8..32bc0507cec 100644 --- a/ui/src/ui/views/usage-render-details.test.ts +++ b/ui/src/ui/views/usage-render-details.test.ts @@ -46,6 +46,15 @@ const baseUsage = { }, } satisfies NonNullable; +function expectFilteredUsage( + result: ReturnType, +): NonNullable> { + if (!result) { + throw new Error("Expected filtered usage result"); + } + return result; +} + describe("computeFilteredUsage", () => { it("returns undefined when no points match the range", () => { const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 2000 })]; @@ -81,18 +90,22 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 2000, input: 0, output: 20 }), makePoint({ timestamp: 3000, input: 5, output: 15 }), ]; - const result = computeFilteredUsage(baseUsage, points, 1000, 3000); - expect(result!.messageCounts!.user).toBe(2); // points with input > 0 - expect(result!.messageCounts!.assistant).toBe(2); // points with output > 0 - expect(result!.messageCounts!.total).toBe(3); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 3000)); + expect(result).toMatchObject({ + messageCounts: { + user: 2, // points with input > 0 + assistant: 2, // points with output > 0 + total: 3, + }, + }); }); it("computes duration from first to last filtered point", () => { const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 5000 })]; - const result = computeFilteredUsage(baseUsage, points, 1000, 5000); - expect(result!.durationMs).toBe(4000); - expect(result!.firstActivity).toBe(1000); - expect(result!.lastActivity).toBe(5000); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 5000)); + expect(result.durationMs).toBe(4000); + expect(result.firstActivity).toBe(1000); + expect(result.lastActivity).toBe(5000); }); it("aggregates token types (input, output, cacheRead, cacheWrite)", () => { @@ -100,11 +113,11 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 1000, input: 10, output: 20, cacheRead: 30, cacheWrite: 40 }), makePoint({ timestamp: 2000, input: 5, output: 15, cacheRead: 25, cacheWrite: 35 }), ]; - const result = computeFilteredUsage(baseUsage, points, 1000, 2000); - expect(result!.input).toBe(15); - expect(result!.output).toBe(35); - expect(result!.cacheRead).toBe(55); - expect(result!.cacheWrite).toBe(75); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 2000)); + expect(result.input).toBe(15); + expect(result.output).toBe(35); + expect(result.cacheRead).toBe(55); + expect(result.cacheWrite).toBe(75); }); }); From 79dd22bfb2f0e8603316040f11d8e2a189fc908d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 17:57:33 +0100 Subject: [PATCH 449/806] test: tighten heartbeat session store assertions --- .../reply/session.heartbeat-no-reset.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index fa1d515ccaa..583b59cdd3f 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -82,6 +82,14 @@ describe("initSessionState - heartbeat should not trigger session reset", () => }); }; + const expectPersistedSession = (sessionStore: Record): SessionEntry => { + const entry = sessionStore["main:user123"]; + if (!entry) { + throw new Error("Expected persisted session for main:user123"); + } + return entry; + }; + it("should NOT reset session when Provider is 'heartbeat'", async () => { // Setup: Create a session entry that is "stale" (older than idle timeout) const now = Date.now(); @@ -191,7 +199,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(heartbeatResult.sessionEntry.lastInteractionAt).toBe(staleTime); const persistedAfterHeartbeat = loadSessionStore(storePath); - expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBe(staleTime); + expect(expectPersistedSession(persistedAfterHeartbeat).lastInteractionAt).toBe(staleTime); const userResult = await initSessionState({ ctx: createBaseCtx({ @@ -278,7 +286,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(heartbeatResult.sessionId).toBe("legacy-idle-session"); const persistedAfterHeartbeat = loadSessionStore(storePath); - expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBeUndefined(); + expect(expectPersistedSession(persistedAfterHeartbeat).lastInteractionAt).toBeUndefined(); const userResult = await initSessionState({ ctx: createBaseCtx({ From de8270160371ad1eca6cc1432a0bc3c55694f777 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 17:59:10 +0100 Subject: [PATCH 450/806] test: require powershell command runner --- test/scripts/install-ps1.test.ts | 62 ++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index 5648d6182b5..a4b9e24aae2 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -61,6 +61,12 @@ describe("install.ps1 failure handling", () => { const source = readFileSync(SCRIPT_PATH, "utf8"); const powershell = findPowerShell(); const runIfPowerShell = powershell ? it : it.skip; + const runPowerShell = (args: string[]) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + return spawnSync(powershell, args, { encoding: "utf8" }); + }; it("does not exit directly from inside Main", () => { const mainBody = extractFunctionBody(source, "Main"); @@ -103,11 +109,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); @@ -119,11 +128,14 @@ describe("install.ps1 failure handling", () => { writeFileSync(scriptPath, createFailingNodeFixture(source)); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(1); }); @@ -142,9 +154,7 @@ describe("install.ps1 failure handling", () => { "}", 'Write-Output "alive-after-install"', ].join("\n"); - const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-Command", command], { - encoding: "utf8", - }); + const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", command]); expect(result.status).toBe(0); expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1."); @@ -177,11 +187,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); @@ -219,11 +232,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); From b42bce0c8c8b2b8ca107742fd067c6b116cb7897 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:01:30 +0100 Subject: [PATCH 451/806] test: require device pair media url --- extensions/device-pair/index.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 52fa36265d0..9cbbe0399d3 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -133,6 +133,13 @@ function requireText(result: { text?: unknown } | null | undefined): string { return result.text; } +function requireMediaUrl(opts: { mediaUrl?: string }): string { + if (!opts.mediaUrl) { + throw new Error("pair command did not send a media URL"); + } + return opts.mediaUrl; +} + function createChannelRuntime( runtimeKey: string, sendKey: string, @@ -479,11 +486,12 @@ describe("device-pair /pair qr", () => { expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately."); - expect(opts.mediaUrl).toMatch(/pair-qr\.png$/); - expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]); + const mediaUrl = requireMediaUrl(opts); + expect(mediaUrl).toMatch(/pair-qr\.png$/); + expect(opts.mediaLocalRoots).toEqual([path.dirname(mediaUrl)]); expect(opts).toMatchObject(testCase.expectedOpts); expect(sentPng).toBe("fakepng"); - await expect(fs.access(opts.mediaUrl!)).rejects.toThrow(); + await expect(fs.access(mediaUrl)).rejects.toThrow(); expect(text).toContain("QR code sent above."); expect(text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes."); }); From 809abda82b480ee18141d964fbc628f9e75893c3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:01:46 +0100 Subject: [PATCH 452/806] test: tighten realtime voice bridge assertions --- src/talk/session-runtime.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/talk/session-runtime.test.ts b/src/talk/session-runtime.test.ts index db5752e75c5..4123c74e366 100644 --- a/src/talk/session-runtime.test.ts +++ b/src/talk/session-runtime.test.ts @@ -20,6 +20,15 @@ function makeBridge(overrides: Partial = {}): RealtimeVoice }; } +function expectBridgeRequest( + request: Parameters[0] | undefined, +): Parameters[0] { + if (!request) { + throw new Error("Expected realtime voice provider bridge request"); + } + return request; +} + describe("realtime voice bridge session runtime", () => { it("routes provider output through an open audio sink", () => { let callbacks: Parameters[0] | undefined; @@ -76,7 +85,9 @@ describe("realtime voice bridge session runtime", () => { audioSink: { sendAudio: vi.fn() }, }); - expect(request?.audioFormat).toEqual(REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ); + expect(expectBridgeRequest(request).audioFormat).toEqual( + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + ); }); it("passes the audio auto-response preference to the provider bridge", () => { @@ -98,7 +109,7 @@ describe("realtime voice bridge session runtime", () => { audioSink: { sendAudio: vi.fn() }, }); - expect(request?.autoRespondToAudio).toBe(false); + expect(expectBridgeRequest(request).autoRespondToAudio).toBe(false); }); it("can acknowledge provider marks without transport mark support", () => { From 32fb032ba04cb8f817c2f3be090bd23dde17803d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:03:42 +0100 Subject: [PATCH 453/806] test: tighten talk diagnostic event assertion --- src/talk/diagnostics.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/talk/diagnostics.test.ts b/src/talk/diagnostics.test.ts index 9a175e99f1f..16edbfccab8 100644 --- a/src/talk/diagnostics.test.ts +++ b/src/talk/diagnostics.test.ts @@ -57,7 +57,11 @@ describe("talk diagnostics", () => { await new Promise((resolve) => setImmediate(resolve)); expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]).toMatchObject({ + const [diagnostic] = diagnostics; + if (!diagnostic) { + throw new Error("Expected talk diagnostic event"); + } + expect(diagnostic).toMatchObject({ trusted: true, event: { type: "talk.event", @@ -67,6 +71,6 @@ describe("talk diagnostics", () => { byteLength: 320, }, }); - expect(JSON.stringify(diagnostics[0]?.event)).not.toContain("private transcript"); + expect(JSON.stringify(diagnostic.event)).not.toContain("private transcript"); }); }); From 2fc4b4c38f9210ec5290aa41d07eaf15a6257152 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:04:34 +0100 Subject: [PATCH 454/806] test: require provider optional hooks --- extensions/deepseek/index.test.ts | 13 ++++- extensions/github-copilot/embeddings.test.ts | 26 +++++----- extensions/minimax/speech-provider.test.ts | 54 ++++++++++++++------ 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 75f0afa18f1..bd41e7eb650 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -16,6 +16,8 @@ type PayloadCapture = { payload?: Record; }; +type RegisteredProvider = Awaited>; + const emptyUsage = { input: 0, output: 0, @@ -25,6 +27,15 @@ const emptyUsage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; +function requireThinkingProfileResolver( + provider: RegisteredProvider, +): NonNullable { + if (!provider.resolveThinkingProfile) { + throw new Error("DeepSeek provider did not register a thinking profile resolver"); + } + return provider.resolveThinkingProfile; +} + const readToolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} }; const readToolResult = { role: "toolResult", @@ -190,7 +201,7 @@ describe("deepseek provider plugin", () => { it("advertises max thinking levels for DeepSeek V4 models only", async () => { const provider = await registerSingleProviderPlugin(deepseekPlugin); - const resolveThinkingProfile = provider.resolveThinkingProfile!; + const resolveThinkingProfile = requireThinkingProfileResolver(provider); const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; expect( diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index b8f3f7beb85..3cfa5b32cf8 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -34,6 +34,14 @@ afterAll(() => { const TEST_BASE_URL = "https://api.githubcopilot.test"; +function shouldContinueAutoSelection(error: Error): boolean { + const shouldContinue = githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection; + if (!shouldContinue) { + throw new Error("GitHub Copilot embedding adapter did not expose auto-selection fallback"); + } + return shouldContinue(error); +} + function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: unknown }>) { return { data: models }; } @@ -242,25 +250,19 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { }); it("treats token parsing and discovery failures as auto-fallback errors", () => { + expect(shouldContinueAutoSelection(new Error("Copilot token response missing token"))).toBe( + true, + ); expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( - new Error("Copilot token response missing token"), - ), - ).toBe(true); - expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( + shouldContinueAutoSelection( new Error("Unexpected response from GitHub Copilot token endpoint"), ), ).toBe(true); expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( + shouldContinueAutoSelection( new Error("GitHub Copilot model discovery returned invalid JSON"), ), ).toBe(true); - expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( - new Error("Network timeout"), - ), - ).toBe(false); + expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false); }); }); diff --git a/extensions/minimax/speech-provider.test.ts b/extensions/minimax/speech-provider.test.ts index 96087adf1ac..e8ea8969138 100644 --- a/extensions/minimax/speech-provider.test.ts +++ b/extensions/minimax/speech-provider.test.ts @@ -21,6 +21,26 @@ function clearMinimaxAuthEnv() { describe("buildMinimaxSpeechProvider", () => { const provider = buildMinimaxSpeechProvider(); + function resolveProviderConfig( + params: Parameters>[0], + ): ReturnType> { + const resolveConfig = provider.resolveConfig; + if (!resolveConfig) { + throw new Error("MiniMax speech provider did not expose config resolution"); + } + return resolveConfig(params); + } + + function parseDirectiveToken( + params: Parameters>[0], + ): ReturnType> { + const parseToken = provider.parseDirectiveToken; + if (!parseToken) { + throw new Error("MiniMax speech provider did not expose directive parsing"); + } + return parseToken(params); + } + describe("metadata", () => { it("has correct id and label", () => { expect(provider.id).toBe("minimax"); @@ -107,14 +127,14 @@ describe("buildMinimaxSpeechProvider", () => { delete process.env.MINIMAX_API_HOST; delete process.env.MINIMAX_TTS_MODEL; delete process.env.MINIMAX_TTS_VOICE_ID; - const config = provider.resolveConfig!({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); + const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-2.8-hd"); expect(config.voiceId).toBe("English_expressive_narrator"); }); it("reads from providers.minimax in rawConfig", () => { - const config = provider.resolveConfig!({ + const config = resolveProviderConfig({ rawConfig: { providers: { minimax: { @@ -142,7 +162,7 @@ describe("buildMinimaxSpeechProvider", () => { process.env.MINIMAX_API_HOST = "https://api.minimax.io/anthropic"; process.env.MINIMAX_TTS_MODEL = "speech-01-240228"; process.env.MINIMAX_TTS_VOICE_ID = "Chinese (Mandarin)_Gentle_Boy"; - const config = provider.resolveConfig!({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); + const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-01-240228"); expect(config.voiceId).toBe("Chinese (Mandarin)_Gentle_Boy"); @@ -150,7 +170,7 @@ describe("buildMinimaxSpeechProvider", () => { it("derives the TTS host from minimax-portal OAuth config", () => { delete process.env.MINIMAX_API_HOST; - const config = provider.resolveConfig!({ + const config = resolveProviderConfig({ rawConfig: {}, cfg: { models: { @@ -178,7 +198,7 @@ describe("buildMinimaxSpeechProvider", () => { }; it("handles voice key", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "voice", value: "Chinese (Mandarin)_Warm_Girl", policy, @@ -188,13 +208,13 @@ describe("buildMinimaxSpeechProvider", () => { }); it("handles voiceid key", () => { - const result = provider.parseDirectiveToken!({ key: "voiceid", value: "test_voice", policy }); + const result = parseDirectiveToken({ key: "voiceid", value: "test_voice", policy }); expect(result.handled).toBe(true); expect(result.overrides?.voiceId).toBe("test_voice"); }); it("handles model key", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "model", value: "speech-01-240228", policy, @@ -204,50 +224,50 @@ describe("buildMinimaxSpeechProvider", () => { }); it("handles speed key with valid value", () => { - const result = provider.parseDirectiveToken!({ key: "speed", value: "1.5", policy }); + const result = parseDirectiveToken({ key: "speed", value: "1.5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.speed).toBe(1.5); }); it("warns on invalid speed", () => { - const result = provider.parseDirectiveToken!({ key: "speed", value: "5.0", policy }); + const result = parseDirectiveToken({ key: "speed", value: "5.0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("handles vol key", () => { - const result = provider.parseDirectiveToken!({ key: "vol", value: "3", policy }); + const result = parseDirectiveToken({ key: "vol", value: "3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(3); }); it("warns on vol=0 (exclusive minimum)", () => { - const result = provider.parseDirectiveToken!({ key: "vol", value: "0", policy }); + const result = parseDirectiveToken({ key: "vol", value: "0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("handles volume alias", () => { - const result = provider.parseDirectiveToken!({ key: "volume", value: "5", policy }); + const result = parseDirectiveToken({ key: "volume", value: "5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(5); }); it("handles pitch key", () => { - const result = provider.parseDirectiveToken!({ key: "pitch", value: "-3", policy }); + const result = parseDirectiveToken({ key: "pitch", value: "-3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.pitch).toBe(-3); }); it("warns on out-of-range pitch", () => { - const result = provider.parseDirectiveToken!({ key: "pitch", value: "20", policy }); + const result = parseDirectiveToken({ key: "pitch", value: "20", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("returns handled=false for unknown keys", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "unknown_key", value: "whatever", policy, @@ -256,7 +276,7 @@ describe("buildMinimaxSpeechProvider", () => { }); it("suppresses voice when policy disallows it", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "voice", value: "test", policy: { ...policy, allowVoice: false }, @@ -266,7 +286,7 @@ describe("buildMinimaxSpeechProvider", () => { }); it("suppresses model when policy disallows it", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "model", value: "test", policy: { ...policy, allowModelId: false }, From 7f0cde8d16dcee36b588c103b3c9bcddbab9a535 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:05:15 +0100 Subject: [PATCH 455/806] test: tighten commitment batch privacy assertions --- src/commitments/runtime.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index d82f178f27b..0c5b66aa21a 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -145,12 +145,20 @@ describe("commitment extraction runtime", () => { const store = await loadCommitmentStore(); expect(extractBatch).toHaveBeenCalledTimes(1); - const batchItems = extractBatch.mock.calls[0]?.[0].items; + const [extractCall] = extractBatch.mock.calls; + if (!extractCall) { + throw new Error("Expected commitment extraction batch call"); + } + const batchItems = extractCall[0].items; expect(batchItems).toHaveLength(2); - expect(batchItems?.[0]?.itemId).not.toContain("main"); - expect(batchItems?.[0]?.itemId).not.toContain("telegram"); - expect(batchItems?.[0]?.itemId).not.toContain("15551234567"); - expect(batchItems?.[0]?.itemId).not.toContain("m1"); + const [firstBatchItem] = batchItems; + if (!firstBatchItem) { + throw new Error("Expected first commitment extraction batch item"); + } + expect(firstBatchItem.itemId).not.toContain("main"); + expect(firstBatchItem.itemId).not.toContain("telegram"); + expect(firstBatchItem.itemId).not.toContain("15551234567"); + expect(firstBatchItem.itemId).not.toContain("m1"); expect(store.commitments.map((commitment) => commitment.dedupeKey)).toEqual([ "event:1", "event:2", From 668f7417e26e4e74b56e5944b8249448195577d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:05:56 +0100 Subject: [PATCH 456/806] test: require dreaming repair archive dir --- extensions/memory-core/src/dreaming-repair.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/src/dreaming-repair.test.ts b/extensions/memory-core/src/dreaming-repair.test.ts index 8db7135f59e..0c7d27ac782 100644 --- a/extensions/memory-core/src/dreaming-repair.test.ts +++ b/extensions/memory-core/src/dreaming-repair.test.ts @@ -13,6 +13,13 @@ async function createWorkspace(): Promise { return workspaceDir; } +function requireArchiveDir(archiveDir: string | undefined): string { + if (!archiveDir) { + throw new Error("Expected dreaming repair to create an archive directory"); + } + return archiveDir; +} + afterEach(async () => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -113,7 +120,8 @@ describe("dreaming artifact repair", () => { expect(repair.archivedSessionCorpus).toBe(true); expect(repair.archivedSessionIngestion).toBe(true); expect(repair.archivedDreamsDiary).toBe(false); - expect(repair.archiveDir).toBe( + const archiveDir = requireArchiveDir(repair.archiveDir); + expect(archiveDir).toBe( path.join(workspaceDir, ".openclaw-repair", "dreaming", "2026-04-11T21-30-00-000Z"), ); await expect(fs.access(sessionCorpusDir)).rejects.toMatchObject({ code: "ENOENT" }); @@ -121,7 +129,7 @@ describe("dreaming artifact repair", () => { fs.access(path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary"); - const archivedEntries = await fs.readdir(repair.archiveDir!); + const archivedEntries = await fs.readdir(archiveDir); expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-corpus\./)); expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-ingestion\.json\./)); }); From 9df3f3be1acd4b814654de4449b6a1e8e619b9e7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:06:29 +0100 Subject: [PATCH 457/806] test: tighten commitment due window assertions --- src/commitments/extraction.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commitments/extraction.test.ts b/src/commitments/extraction.test.ts index 660b96a7d6b..9ff2e9a7753 100644 --- a/src/commitments/extraction.test.ts +++ b/src/commitments/extraction.test.ts @@ -68,6 +68,17 @@ describe("commitment extraction", () => { }; } + function expectSingleValidCandidate( + valid: ReturnType, + ): ReturnType[number] { + expect(valid).toHaveLength(1); + const [entry] = valid; + if (!entry) { + throw new Error("Expected one valid commitment candidate"); + } + return entry; + } + it("parses valid candidates from JSON output with surrounding text", () => { const parsed = parseCommitmentExtractionOutput( `noise {"candidates":[${JSON.stringify(candidate())}]} trailing`, @@ -149,9 +160,9 @@ describe("commitment extraction", () => { nowMs: writeMs, }); - expect(valid).toHaveLength(1); - expect(valid[0]?.earliestMs).toBe(writeMs + 10 * 60_000); - expect(valid[0]?.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000); + const validCandidate = expectSingleValidCandidate(valid); + expect(validCandidate.earliestMs).toBe(writeMs + 10 * 60_000); + expect(validCandidate.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000); }); it("persists inferred commitments and dedupes by scope and dedupe key", async () => { From 1eb81f65ca73a1f274bb0366c1be9392319a187c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:07:51 +0100 Subject: [PATCH 458/806] test: tighten commitment full chain assertions --- ...commitments-full-chain.integration.test.ts | 61 ++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/src/commitments/commitments-full-chain.integration.test.ts b/src/commitments/commitments-full-chain.integration.test.ts index 944dd2ef818..97401227a60 100644 --- a/src/commitments/commitments-full-chain.integration.test.ts +++ b/src/commitments/commitments-full-chain.integration.test.ts @@ -61,23 +61,29 @@ describe("commitments full-chain integration", () => { }: { items: CommitmentExtractionItem[]; }): Promise => ({ - candidates: [ - { - itemId: items[0]?.itemId ?? "", - kind: "event_check_in", - sensitivity: "routine", - source: "inferred_user_context", - reason: "The user mentioned an interview happening today.", - suggestedText: "How did the interview go?", - dedupeKey: "interview:2026-04-29", - confidence: 0.93, - dueWindow: { - earliest: new Date(dueMs).toISOString(), - latest: new Date(dueMs + 60 * 60_000).toISOString(), - timezone: "America/Los_Angeles", + candidates: (() => { + const [firstItem] = items; + if (!firstItem) { + throw new Error("Expected commitment extraction item"); + } + return [ + { + itemId: firstItem.itemId, + kind: "event_check_in", + sensitivity: "routine", + source: "inferred_user_context", + reason: "The user mentioned an interview happening today.", + suggestedText: "How did the interview go?", + dedupeKey: "interview:2026-04-29", + confidence: 0.93, + dueWindow: { + earliest: new Date(dueMs).toISOString(), + latest: new Date(dueMs + 60 * 60_000).toISOString(), + timezone: "America/Los_Angeles", + }, }, - }, - ], + ]; + })(), }), ), setTimer: () => ({ unref() {} }) as ReturnType, @@ -102,7 +108,11 @@ describe("commitments full-chain integration", () => { const pendingStore = await loadCommitmentStore(); expect(pendingStore.commitments).toHaveLength(1); - expect(pendingStore.commitments[0]).toMatchObject({ + const [pendingCommitment] = pendingStore.commitments; + if (!pendingCommitment) { + throw new Error("Expected pending commitment"); + } + expect(pendingCommitment).toMatchObject({ status: "pending", agentId: "main", sessionKey, @@ -110,9 +120,9 @@ describe("commitments full-chain integration", () => { to: "155462274", suggestedText: "How did the interview go?", }); - expect(pendingStore.commitments[0]?.dueWindow.earliestMs).toBe(dueMs); - expect(pendingStore.commitments[0]).not.toHaveProperty("sourceUserText"); - expect(pendingStore.commitments[0]).not.toHaveProperty("sourceAssistantText"); + expect(pendingCommitment.dueWindow.earliestMs).toBe(dueMs); + expect(pendingCommitment).not.toHaveProperty("sourceUserText"); + expect(pendingCommitment).not.toHaveProperty("sourceAssistantText"); vi.setSystemTime(dueMs + 60_000); const sendTelegram = vi.fn().mockResolvedValue({ @@ -124,13 +134,16 @@ describe("commitments full-chain integration", () => { ctx: { Body?: string; OriginatingChannel?: string; OriginatingTo?: string }, opts?: { disableTools?: boolean }, ) => { + if (!opts) { + throw new Error("Expected commitment heartbeat reply options"); + } expect(ctx.Body).toContain("Due inferred follow-up commitments"); expect(ctx.Body).toContain("How did the interview go?"); expect(ctx.Body).not.toContain("I have an interview later today."); expect(ctx.Body).not.toContain("Good luck, I hope it goes well."); expect(ctx.OriginatingChannel).toBe("telegram"); expect(ctx.OriginatingTo).toBe("155462274"); - expect(opts?.disableTools).toBe(true); + expect(opts.disableTools).toBe(true); return { text: "How did the interview go?" }; }, ); @@ -154,7 +167,11 @@ describe("commitments full-chain integration", () => { expect.objectContaining({ accountId: "primary" }), ); const deliveredStore = await loadCommitmentStore(); - expect(deliveredStore.commitments[0]).toMatchObject({ + const [deliveredCommitment] = deliveredStore.commitments; + if (!deliveredCommitment) { + throw new Error("Expected delivered commitment"); + } + expect(deliveredCommitment).toMatchObject({ status: "sent", attempts: 1, sentAtMs: dueMs + 60_000, From b7bf5294412132f11603324df8c735d6be26cd72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:08:20 +0100 Subject: [PATCH 459/806] test: require extension scenario schemas --- extensions/google-meet/index.test.ts | 24 ++++++++++++++----- .../src/runners/contract/scenarios.test.ts | 4 ++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index a1d5afad07e..163f8866223 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -50,6 +50,10 @@ import { } from "./src/transports/twilio.js"; import type { GoogleMeetSession } from "./src/transports/types.js"; +type GoogleMeetManifestConfigSchema = JsonSchemaObject & { + properties?: Record }>; +}; + const voiceCallMocks = vi.hoisted(() => ({ joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", @@ -121,6 +125,15 @@ function jsonResponse(value: unknown): Response { }); } +function requireGoogleMeetManifestConfigSchema(manifest: { + configSchema?: GoogleMeetManifestConfigSchema; +}): GoogleMeetManifestConfigSchema { + if (!manifest.configSchema) { + throw new Error("Google Meet manifest did not include a config schema"); + } + return manifest.configSchema; +} + function requestUrl(input: RequestInfo | URL): URL { if (typeof input === "string") { return new URL(input); @@ -548,10 +561,9 @@ describe("google-meet plugin", () => { readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), ) as { uiHints?: Record; - configSchema?: JsonSchemaObject & { - properties?: Record }>; - }; + configSchema?: GoogleMeetManifestConfigSchema; }; + const configSchema = requireGoogleMeetManifestConfigSchema(manifest); const entry = plugin as unknown as { configSchema: { uiHints?: Record; @@ -574,7 +586,7 @@ describe("google-meet plugin", () => { "chrome.bargeInCooldownMs": expect.objectContaining({ advanced: true }), "voiceCall.postDtmfSpeechDelayMs": expect.objectContaining({ advanced: true }), }); - expect(manifest.configSchema?.properties?.chrome?.properties).toMatchObject({ + expect(configSchema.properties?.chrome?.properties).toMatchObject({ audioBufferBytes: expect.objectContaining({ type: "number", default: 4096 }), bargeInInputCommand: expect.objectContaining({ type: "array", @@ -584,11 +596,11 @@ describe("google-meet plugin", () => { bargeInPeakThreshold: expect.objectContaining({ type: "number", default: 2500 }), bargeInCooldownMs: expect.objectContaining({ type: "number", default: 900 }), }); - expect(manifest.configSchema?.properties?.voiceCall?.properties).toMatchObject({ + expect(configSchema.properties?.voiceCall?.properties).toMatchObject({ postDtmfSpeechDelayMs: expect.objectContaining({ type: "number", default: 5000 }), }); const result = validateJsonSchemaValue({ - schema: manifest.configSchema!, + schema: configSchema, cacheKey: "google-meet.manifest.voice-call-post-dtmf-speech-delay", value: { voiceCall: { diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 0f4cdace6b7..1ffcc031f4c 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -938,8 +938,8 @@ describe("matrix live qa scenarios", () => { const topology = scenarioTesting.buildMatrixQaTopologyForScenarios({ defaultRoomName: "OpenClaw Matrix QA run", scenarios: [ - MATRIX_QA_SCENARIOS.find((scenario) => scenario.id === "matrix-e2ee-basic-reply")!, - MATRIX_QA_SCENARIOS.find((scenario) => scenario.id === "matrix-e2ee-thread-follow-up")!, + requireMatrixQaScenario("matrix-e2ee-basic-reply"), + requireMatrixQaScenario("matrix-e2ee-thread-follow-up"), ], }); From 2faf2303a133dc9807eb7d45045ef01c366050c7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:09:01 +0100 Subject: [PATCH 460/806] test: tighten pdf extraction image assertion --- extensions/document-extract/document-extractor.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/document-extract/document-extractor.test.ts b/extensions/document-extract/document-extractor.test.ts index f7f5605bf6f..8c68771e63c 100644 --- a/extensions/document-extract/document-extractor.test.ts +++ b/extensions/document-extract/document-extractor.test.ts @@ -70,7 +70,10 @@ describe("PDF document extractor", () => { minTextChars: 10, }); - expect(result?.images).toHaveLength(1); + if (!result) { + throw new Error("Expected PDF extraction result"); + } + expect(result.images).toHaveLength(1); expect(canvasSizes).toEqual([{ width: 10, height: 10 }]); }); From eb71492d0dfa4c4f2e931ccc3153b4825001ce97 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:10:13 +0100 Subject: [PATCH 461/806] test: tighten canvas snapshot file assertion --- extensions/canvas/src/cli.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/canvas/src/cli.test.ts b/extensions/canvas/src/cli.test.ts index 2dd089a91a1..32cb87b2555 100644 --- a/extensions/canvas/src/cli.test.ts +++ b/extensions/canvas/src/cli.test.ts @@ -68,8 +68,12 @@ describe("canvas CLI", () => { }), ); expect(writtenFiles).toHaveLength(1); - expect(writtenFiles[0]?.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/); - expect(writtenFiles[0]?.base64).toBe("aGk="); + const [writtenFile] = writtenFiles; + if (!writtenFile) { + throw new Error("Expected canvas snapshot file"); + } + expect(writtenFile.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/); + expect(writtenFile.base64).toBe("aGk="); expect(runtime.log).toHaveBeenCalledWith(expect.stringMatching(/^MEDIA:.*\.png$/)); }); }); From ba2c4e075a07bc5343699d5371cbd8a6f360f534 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:10:33 +0100 Subject: [PATCH 462/806] test: require taskflow child task --- src/tasks/task-executor.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index 4ac9b1f745d..91b1e419c43 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -111,6 +111,15 @@ function expectParentFlowId(task: { parentFlowId?: string }): string { return task.parentFlowId; } +function requireCreatedFlowTask( + result: ReturnType, +): NonNullable["task"]> { + if (!result.task) { + throw new Error("Expected TaskFlow child task to be created"); + } + return result.task; +} + function createRunningAcpChildTaskRun( overrides: Partial[0]> = {}, ) { @@ -497,7 +506,8 @@ describe("task-executor", () => { runId: "run-flow-child", }), }); - expect(getTaskById(created.task!.taskId)).toMatchObject({ + const createdTask = requireCreatedFlowTask(created); + expect(getTaskById(createdTask.taskId)).toMatchObject({ parentFlowId: flow.flowId, ownerKey: "agent:main:main", childSessionKey: "agent:codex:acp:child", @@ -548,7 +558,7 @@ describe("task-executor", () => { controllerId: "tests/managed-flow", goal: "Long running batch", }); - const child = runTaskInFlow({ + const created = runTaskInFlow({ flowId: flow.flowId, runtime: "acp", childSessionKey: "agent:codex:acp:child", @@ -557,7 +567,8 @@ describe("task-executor", () => { status: "running", startedAt: 10, lastEventAt: 10, - }).task!; + }); + const child = requireCreatedFlowTask(created); const cancelled = await cancelFlowById({ cfg: {} as never, From 75e13da8fdec0fdd494df63d583f3782b8490ead Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:11:37 +0100 Subject: [PATCH 463/806] test: tighten memory citation result assertions --- .../memory-core/src/tools.citations.test.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index e7a547abfbb..cb9f814bac3 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -70,6 +70,15 @@ beforeEach(() => { }); describe("memory search citations", () => { + function expectFirstMemoryResult(details: { results: T[] }): T { + expect(details.results).toHaveLength(1); + const [result] = details.results; + if (!result) { + throw new Error("Expected memory search result"); + } + return result; + } + it("appends source information when citations are enabled", async () => { setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ @@ -79,8 +88,9 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_on", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/); - expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7"); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).toMatch(/Source: MEMORY.md#L5-L7/); + expect(firstResult.citation).toBe("MEMORY.md#L5-L7"); }); it("leaves snippet untouched when citations are off", async () => { @@ -92,8 +102,9 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_off", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); - expect(details.results[0]?.citation).toBeUndefined(); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).not.toMatch(/Source:/); + expect(firstResult.citation).toBeUndefined(); }); it("clamps decorated snippets to qmd injected budget", async () => { @@ -105,7 +116,8 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_qmd", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet.length).toBeLessThanOrEqual(20); }); it("honors auto mode for direct chats", async () => { @@ -113,7 +125,8 @@ describe("memory search citations", () => { const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123"); const result = await tool.execute("auto_mode_direct", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source:/); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).toMatch(/Source:/); }); it("suppresses citations for auto mode in group chats", async () => { @@ -121,7 +134,8 @@ describe("memory search citations", () => { const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123"); const result = await tool.execute("auto_mode_group", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).not.toMatch(/Source:/); }); }); From 90f821efb4161629ba274bddc81e0cf80a92e9c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:12:10 +0100 Subject: [PATCH 464/806] test: require compaction provider lookup --- src/plugins/compaction-provider.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/plugins/compaction-provider.test.ts b/src/plugins/compaction-provider.test.ts index a4de69a107a..e9ac9175415 100644 --- a/src/plugins/compaction-provider.test.ts +++ b/src/plugins/compaction-provider.test.ts @@ -28,6 +28,14 @@ function makeProvider(id: string, label?: string): CompactionProvider { }; } +function requireCompactionProvider(id: string): CompactionProvider { + const provider = getCompactionProvider(id); + if (!provider) { + throw new Error(`Expected compaction provider ${id}`); + } + return provider; +} + describe("compaction provider registry", () => { it("starts empty", () => { expect(listCompactionProviderIds()).toEqual([]); @@ -88,8 +96,8 @@ describe("compaction provider registry", () => { it("calls summarize and returns expected result", async () => { registerCompactionProvider(makeProvider("my-compactor")); - const provider = getCompactionProvider("my-compactor"); - const result = await provider!.summarize({ messages: [] }); + const provider = requireCompactionProvider("my-compactor"); + const result = await provider.summarize({ messages: [] }); expect(result).toBe("summary-from-my-compactor"); }); From 582895939fb50aa8047e4f1921cf7e946b72c811 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:12:59 +0100 Subject: [PATCH 465/806] test: tighten deepgram media output assertion --- src/media-understanding/runner.deepgram.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 8e4c161e1e1..3a1592b3658 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -116,7 +116,12 @@ describe("runCapability deepgram provider options", () => { media, providerRegistry, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(result.outputs).toHaveLength(1); + const [output] = result.outputs; + if (!output) { + throw new Error("Expected Deepgram media output"); + } + expect(output.text).toBe("ok"); expect(seenBaseUrl).toBe("https://entry.example"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", From e875ba97ab8d45c9a2660101641add24cda93666 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:14:03 +0100 Subject: [PATCH 466/806] test: require media config fixture --- src/media-understanding/apply.echo-transcript.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 94602bd0437..97677efadaf 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -104,6 +104,13 @@ function createAudioConfigWithEcho(opts?: { return { cfg, providers }; } +function disableImageUnderstanding(cfg: OpenClawConfig): void { + if (!cfg.tools?.media) { + throw new Error("Expected media tool config"); + } + cfg.tools.media.image = { enabled: false }; +} + function expectSingleEchoDeliveryCall() { expect(mockDeliverOutboundPayloads).toHaveBeenCalledOnce(); const callArgs = mockDeliverOutboundPayloads.mock.calls[0]?.[0]; @@ -292,7 +299,7 @@ describe("applyMediaUnderstanding – echo transcript", () => { echoTranscript: true, transcribedText: "should not appear", }); - cfg.tools!.media!.image = { enabled: false }; + disableImageUnderstanding(cfg); await applyMediaUnderstanding({ ctx, cfg, providers }); From dc5ebc24fcef0f0072f77fc31365e0a870253cda Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:14:21 +0100 Subject: [PATCH 467/806] test: tighten talkback abort assertion --- src/talk/agent-talkback-runtime.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/talk/agent-talkback-runtime.test.ts b/src/talk/agent-talkback-runtime.test.ts index 59b80107e87..d5ce9f764fc 100644 --- a/src/talk/agent-talkback-runtime.test.ts +++ b/src/talk/agent-talkback-runtime.test.ts @@ -218,7 +218,10 @@ describe("realtime voice agent talkback queue", () => { queue.close(); await vi.runAllTimersAsync(); - expect(signal?.aborted).toBe(true); + if (!signal) { + throw new Error("Expected talkback consult abort signal"); + } + expect(signal.aborted).toBe(true); expect(deliver).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); vi.useRealTimers(); From 9fcb583faf95f5c94c53be38beec0c15e41e0cee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:15:41 +0100 Subject: [PATCH 468/806] test: require plugin update fixtures --- src/plugins/update.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index dd1b944290c..da38aff9ac8 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -12,6 +12,24 @@ function appBundledPluginRoot(pluginId: string): string { return bundledPluginRootAt(APP_ROOT, pluginId); } +function requireExpectedPluginId(params: { expectedPluginId?: string }): string { + if (!params.expectedPluginId) { + throw new Error("Expected npm install params to include expectedPluginId"); + } + return params.expectedPluginId; +} + +function requirePluginPackageName( + plugins: Array<{ pluginId: string; packageName: string }>, + pluginId: string, +): string { + const plugin = plugins.find((candidate) => candidate.pluginId === pluginId); + if (!plugin) { + throw new Error(`Expected plugin fixture ${pluginId}`); + } + return plugin.packageName; +} + const installPluginFromNpmSpecMock = vi.fn(); const installPluginFromMarketplaceMock = vi.fn(); const installPluginFromClawHubMock = vi.fn(); @@ -872,12 +890,12 @@ describe("updateNpmInstalledPlugins", () => { } installPluginFromNpmSpecMock.mockImplementation( (params: { expectedPluginId?: string; spec: string }) => { - const pluginId = params.expectedPluginId!; + const pluginId = requireExpectedPluginId(params); for (const { pluginId: installedPluginId } of plugins) { fs.rmSync(peerLinkPath(installedPluginId), { recursive: true, force: true }); } linkPeer(pluginId); - const packageName = plugins.find((plugin) => plugin.pluginId === pluginId)!.packageName; + const packageName = requirePluginPackageName(plugins, pluginId); return Promise.resolve( createSuccessfulNpmUpdateResult({ pluginId, From df22284f8550c5a31470d678f7cf1c448ff6db8e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:15:54 +0100 Subject: [PATCH 469/806] test: tighten control ui buffer assertions --- ui/src/ui/control-ui-performance.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts index 52b9ab6e799..6812b57c2dc 100644 --- a/ui/src/ui/control-ui-performance.test.ts +++ b/ui/src/ui/control-ui-performance.test.ts @@ -70,8 +70,13 @@ describe("recordControlUiPerformanceEvent", () => { } expect(host.eventLogBuffer).toHaveLength(250); - expect(host.eventLogBuffer[0]?.payload).toEqual({ i: 259 }); - expect(host.eventLogBuffer.at(-1)?.payload).toEqual({ i: 10 }); + const [newestEvent] = host.eventLogBuffer; + const oldestEvent = host.eventLogBuffer.at(-1); + if (!newestEvent || !oldestEvent) { + throw new Error("Expected bounded performance event buffer entries"); + } + expect(newestEvent.payload).toEqual({ i: 259 }); + expect(oldestEvent.payload).toEqual({ i: 10 }); }); }); From 30049c6d5604ea6e4bf3293bbd74b8b270a8d33e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:17:40 +0100 Subject: [PATCH 470/806] test: tighten together video result assertion --- extensions/together/video-generation-provider.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/together/video-generation-provider.test.ts b/extensions/together/video-generation-provider.test.ts index 27097c183a9..e268f75dd50 100644 --- a/extensions/together/video-generation-provider.test.ts +++ b/extensions/together/video-generation-provider.test.ts @@ -57,7 +57,11 @@ describe("together video generation provider", () => { }), ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Together video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ videoId: "video_123", From c201c8dcf62d8e93cd024da605b6daf3d4e4a984 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:18:39 +0100 Subject: [PATCH 471/806] test: require mattermost actions --- .../src/mattermost/interactions.test.ts | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index eead72ad12b..d72f21f6945 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -18,6 +18,34 @@ import { verifyInteractionToken, } from "./interactions.js"; +type ButtonAttachments = ReturnType; +type ButtonAttachment = ButtonAttachments[number]; +type ButtonAction = NonNullable[number]; + +function requireFirstAttachment(attachments: ButtonAttachments): ButtonAttachment { + const [attachment] = attachments; + if (!attachment) { + throw new Error("Expected button attachment fixture"); + } + return attachment; +} + +function requireActions(attachments: ButtonAttachments): ButtonAction[] { + const attachment = requireFirstAttachment(attachments); + if (!attachment.actions) { + throw new Error("Expected button attachment fixture actions"); + } + return attachment.actions; +} + +function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction { + const action = requireActions(attachments).at(index); + if (!action) { + throw new Error(`Expected button attachment action at index ${index}`); + } + return action; +} + // ── HMAC token management ──────────────────────────────────────────── describe("setInteractionSecret / getInteractionSecret", () => { @@ -308,7 +336,7 @@ describe("buildButtonAttachments", () => { }); expect(result).toHaveLength(1); - expect(result[0].actions).toHaveLength(2); + expect(requireActions(result)).toHaveLength(2); }); it("sets type to 'button' on every action", () => { @@ -317,7 +345,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "a", name: "A" }], }); - expect(result[0].actions![0].type).toBe("button"); + expect(requireAction(result).type).toBe("button"); }); it("includes HMAC _token in integration context", () => { @@ -326,7 +354,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "test", name: "Test" }], }); - const action = result[0].actions![0]; + const action = requireAction(result); expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/); }); @@ -336,7 +364,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "my_action", name: "Do It" }], }); - const action = result[0].actions![0]; + const action = requireAction(result); // sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747) expect(action.integration.context.action_id).toBe("myaction"); expect(action.id).toBe("myaction"); @@ -348,7 +376,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; expect(ctx.tweet_id).toBe("123"); expect(ctx.batch).toBe(true); expect(ctx.action_id).toBe("btn"); @@ -365,7 +393,7 @@ describe("buildButtonAttachments", () => { ], }); - for (const action of result[0].actions!) { + for (const action of requireActions(result)) { expect(action.integration.url).toBe(url); } }); @@ -379,8 +407,8 @@ describe("buildButtonAttachments", () => { ], }); - expect(result[0].actions![0].style).toBe("primary"); - expect(result[0].actions![1].style).toBe("danger"); + expect(requireAction(result, 0).style).toBe("primary"); + expect(requireAction(result, 1).style).toBe("danger"); }); it("uses provided text for the attachment", () => { @@ -390,7 +418,7 @@ describe("buildButtonAttachments", () => { text: "Choose an action:", }); - expect(result[0].text).toBe("Choose an action:"); + expect(requireFirstAttachment(result).text).toBe("Choose an action:"); }); it("defaults to empty string text when not provided", () => { @@ -399,7 +427,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "x", name: "X" }], }); - expect(result[0].text).toBe(""); + expect(requireFirstAttachment(result).text).toBe(""); }); it("generates verifiable tokens", () => { @@ -408,7 +436,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; const token = ctx._token as string; const { _token, ...contextWithoutToken } = ctx; expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); @@ -420,7 +448,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; const token = ctx._token as string; // Simulate Mattermost returning context with keys in a different order From f9c8542daee0386a389f8846da85aa8650619d4e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:18:56 +0100 Subject: [PATCH 472/806] test: tighten vydra video result assertions --- .../vydra/video-generation-provider.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/extensions/vydra/video-generation-provider.test.ts b/extensions/vydra/video-generation-provider.test.ts index f1dd7bb83ed..3e4813f81da 100644 --- a/extensions/vydra/video-generation-provider.test.ts +++ b/extensions/vydra/video-generation-provider.test.ts @@ -54,8 +54,13 @@ describe("vydra video-generation provider", () => { "https://www.vydra.ai/api/v1/jobs/job-123", expect.objectContaining({ method: "GET" }), ); - expect(result.videos[0]?.mimeType).toBe("video/webm"); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Vydra video"); + } + expect(video.mimeType).toBe("video/webm"); + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual({ jobId: "job-123", videoUrl: "https://cdn.vydra.ai/generated/test.mp4", @@ -112,7 +117,12 @@ describe("vydra video-generation provider", () => { }), }), ); - expect(result.videos[0]?.mimeType).toBe("video/mp4"); + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Vydra kling video"); + } + expect(video.mimeType).toBe("video/mp4"); expect(result.metadata).toEqual({ jobId: "job-kling", videoUrl: "https://cdn.vydra.ai/generated/kling.mp4", From 5e34a350def30c57cbab28085093dfc7061e1611 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:20:26 +0100 Subject: [PATCH 473/806] test: require discord message adapter --- .../src/channel.message-adapter.test.ts | 78 ++++++++++++++----- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index 31ecd8878c5..cbe9914ebcb 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -19,20 +19,61 @@ beforeAll(async () => { ({ discordPlugin } = await import("./channel.js")); }); +type DiscordMessageAdapter = NonNullable; +type DiscordMessageSender = NonNullable; + +function requireDiscordMessageAdapter(): DiscordMessageAdapter { + const adapter = discordPlugin.message; + if (!adapter) { + throw new Error("Expected discord plugin to expose a channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected discord message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected discord message adapter media sender"); + } + return media; +} + +function requirePayloadSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const payload = adapter.send?.payload; + if (!payload) { + throw new Error("Expected discord message adapter payload sender"); + } + return payload; +} + describe("discord channel message adapter", () => { beforeEach(() => { resetDiscordOutboundMocks(hoisted); }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = discordPlugin.message; - if (!adapter) { - throw new Error("Expected discord plugin to expose a channel message adapter"); - } + const adapter = requireDiscordMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); + const sendPayload = requirePayloadSender(adapter); const proveText = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:123456", text: "hello", @@ -49,7 +90,7 @@ describe("discord channel message adapter", () => { const proveMedia = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.media!({ + const result = await sendMedia({ cfg: {}, to: "channel:123456", text: "caption", @@ -69,7 +110,7 @@ describe("discord channel message adapter", () => { const provePayload = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.payload!({ + const result = await sendPayload({ cfg: {}, to: "channel:123456", text: "payload", @@ -86,7 +127,7 @@ describe("discord channel message adapter", () => { const proveReplyThreadSilent = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -119,43 +160,44 @@ describe("discord channel message adapter", () => { replyTo: proveReplyThreadSilent, thread: proveReplyThreadSilent, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = discordPlugin.message; + const adapter = requireDiscordMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "discordMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "discordMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); From 1849e0c34bdc409ef80344a3055d232421fecec8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:20:26 +0100 Subject: [PATCH 474/806] test: tighten deepinfra video result assertion --- extensions/deepinfra/video-generation-provider.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/extensions/deepinfra/video-generation-provider.test.ts b/extensions/deepinfra/video-generation-provider.test.ts index 9daea7292fc..be9af300939 100644 --- a/extensions/deepinfra/video-generation-provider.test.ts +++ b/extensions/deepinfra/video-generation-provider.test.ts @@ -104,10 +104,15 @@ describe("deepinfra video generation provider", () => { cfg: {}, }); - expect(result.videos[0]).toMatchObject({ + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated DeepInfra video"); + } + expect(video).toMatchObject({ mimeType: "video/webm", fileName: "video-1.webm", }); - expect(result.videos[0]?.buffer).toEqual(Buffer.from("webm-data")); + expect(video.buffer).toEqual(Buffer.from("webm-data")); }); }); From 7ce0532fa58a8ce94aa0aacf5f78f4af6a58801f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:21:57 +0100 Subject: [PATCH 475/806] test: reuse tlon text sender --- extensions/tlon/src/channel.message-adapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 8596f1e5b98..7c79e3716bf 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -94,7 +94,7 @@ describe("tlon channel message adapter", () => { const proveReplyThread = async () => { mocks.sendText.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg, to: "chat/~nec/general", text: "threaded", @@ -121,7 +121,7 @@ describe("tlon channel message adapter", () => { replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); From f9c56bbce0df5db26631978daba68577582cb5f9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:22:17 +0100 Subject: [PATCH 476/806] test: tighten byteplus video result assertion --- extensions/byteplus/video-generation-provider.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index a2659acaa98..72daab0f9c6 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -63,7 +63,11 @@ describe("byteplus video generation provider", () => { }), ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated BytePlus video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ taskId: "task_123", From e328bbc5ade36a101026338eac44fab9427b2983 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:23:31 +0100 Subject: [PATCH 477/806] test: require setup validators --- extensions/irc/src/setup.test.ts | 9 ++++++--- extensions/nextcloud-talk/src/setup.test.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/extensions/irc/src/setup.test.ts b/extensions/irc/src/setup.test.ts index 56829a16577..0422cbec5e2 100644 --- a/extensions/irc/src/setup.test.ts +++ b/extensions/irc/src/setup.test.ts @@ -278,21 +278,24 @@ describe("irc setup", () => { const applyAccountConfig = ircSetupAdapter.applyAccountConfig; expect(validateInput).toBeTypeOf("function"); expect(applyAccountConfig).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected IRC setup validateInput"); + } expect( - validateInput!({ + validateInput({ input: { host: "", nick: "openclaw" }, } as never), ).toBe("IRC requires host."); expect( - validateInput!({ + validateInput({ input: { host: "irc.libera.chat", nick: "" }, } as never), ).toBe("IRC requires nick."); expect( - validateInput!({ + validateInput({ input: { host: "irc.libera.chat", nick: "openclaw" }, } as never), ).toBeNull(); diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index 24f997a301f..9ac0b0f6cb7 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -193,23 +193,26 @@ describe("nextcloud talk setup", () => { const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig; expect(validateInput).toBeTypeOf("function"); expect(applyAccountConfig).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected Nextcloud Talk setup validateInput"); + } expect( - validateInput!({ + validateInput({ accountId: "work", input: { useEnv: true }, } as never), ).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, baseUrl: "", secret: "" }, } as never), ).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env)."); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, secret: "secret", baseUrl: "" }, } as never), From e11a2dcf07f7a563b7253a4566dd5ca231d8460f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:23:58 +0100 Subject: [PATCH 478/806] test: tighten voice consult session assertion --- src/talk/agent-consult-runtime.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index dd217ac613a..ac6e10ccba4 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -110,7 +110,11 @@ describe("realtime voice agent consult runtime", () => { }); expect(result).toEqual({ text: "Speak this." }); - expect(sessionStore["voice:15550001234"]?.sessionId).toEqual(expect.stringMatching(/\S/)); + const voiceSession = sessionStore["voice:15550001234"]; + if (!voiceSession) { + throw new Error("Expected voice consult session entry"); + } + expect(voiceSession.sessionId).toEqual(expect.stringMatching(/\S/)); expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "voice:15550001234", From 46d56725c9540027454fa53044bc090bd141eb7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:25:07 +0100 Subject: [PATCH 479/806] test: require zalouser outbound fixtures --- .../zalouser/src/channel.sendpayload.test.ts | 73 +++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 8709a58910a..55e19b7f76e 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -29,6 +29,47 @@ function baseCtx(payload: ReplyPayload) { }; } +type ZalouserOutbound = NonNullable; +type ZalouserSendPayload = NonNullable; +type ZalouserMessageAdapter = NonNullable; +type ZalouserMessageSender = NonNullable; + +function requireZalouserSendPayload(): ZalouserSendPayload { + const sendPayload = zalouserPlugin.outbound?.sendPayload; + if (!sendPayload) { + throw new Error("Expected Zalouser outbound sendPayload"); + } + return sendPayload; +} + +function requireZalouserMessageAdapter(): ZalouserMessageAdapter { + const adapter = zalouserPlugin.message; + if (!adapter) { + throw new Error("Expected Zalouser message adapter"); + } + return adapter; +} + +function requireZalouserTextSender( + adapter: ZalouserMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected Zalouser message adapter text sender"); + } + return text; +} + +function requireZalouserMediaSender( + adapter: ZalouserMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected Zalouser message adapter media sender"); + } + return media; +} + describe("zalouserPlugin outbound sendPayload", () => { let mockedSend: ReturnType>; @@ -47,8 +88,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("group target delegates with isGroup=true and stripped threadId", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello group" }), to: "group:1471383327500481391", }); @@ -63,8 +105,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("treats bare numeric targets as direct chats for backward compatibility", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello" }), to: "987654321", }); @@ -79,8 +122,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("preserves provider-native group ids when sending to raw g- targets", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello native group" }), to: "g-1471383327500481391", }); @@ -96,8 +140,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("passes long markdown through once so formatting happens before chunking", async () => { const text = `**${"a".repeat(2501)}**`; mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text }), to: "987654321", }); @@ -136,33 +181,34 @@ describe("zalouserPlugin outbound sendPayload", () => { }), }, ); + const adapter = requireZalouserMessageAdapter(); + const sendText = requireZalouserTextSender(adapter); + const sendMedia = requireZalouserMediaSender(adapter); await expect( verifyChannelMessageAdapterCapabilityProofs({ adapterName: "zalouser", - adapter: zalouserPlugin.message!, + adapter, proofs: { text: async () => { - const result = await zalouserPlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {}, to: "user:987654321", text: "hello", }); - expect(result?.receipt.platformMessageIds).toEqual(["zlu-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zlu-text-1"]); }, media: async () => { - const result = await zalouserPlugin.message?.send?.media?.({ + const result = await sendMedia({ cfg: {}, to: "user:987654321", text: "image", mediaUrl: "https://example.com/image.png", }); - expect(result?.receipt.platformMessageIds).toEqual(["zlu-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zlu-media-1"]); }, messageSendingHooks: () => { - expect(zalouserPlugin.message?.durableFinal?.capabilities?.messageSendingHooks).toBe( - true, - ); + expect(adapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true); }, }, }), @@ -194,8 +240,9 @@ describe("zalouserPlugin outbound payload contract", () => { text: "", payload: params.payload, }; + const sendPayload = requireZalouserSendPayload(); return { - run: async () => await zalouserPlugin.outbound!.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: mockedSend, to: "987654321", }; From 0b6f56fae099cbc41d6e8078fe34c67522ca3b9f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:25:36 +0100 Subject: [PATCH 480/806] test: tighten discord proxy abort assertion --- extensions/discord/src/proxy-request-client.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/proxy-request-client.test.ts b/extensions/discord/src/proxy-request-client.test.ts index 9e0ca64226f..3509708d87f 100644 --- a/extensions/discord/src/proxy-request-client.test.ts +++ b/extensions/discord/src/proxy-request-client.test.ts @@ -51,7 +51,10 @@ describe("createDiscordRequestClient", () => { client.abortAllRequests(); await expect(request).rejects.toThrow(); - expect(abortable.receivedSignal?.aborted).toBe(true); + if (!abortable.receivedSignal) { + throw new Error("Expected proxied fetch abort signal"); + } + expect(abortable.receivedSignal.aborted).toBe(true); }); it("provides the REST client's timeout signal even without a caller signal", async () => { From 6cb3effd9c93308fcc9465e43427de0023ad5e9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:26:41 +0100 Subject: [PATCH 481/806] test: require outbound payload senders --- ...outbound-adapter.interactive-order.test.ts | 13 +++++- .../src/outbound-payload.contract.test.ts | 13 +++++- .../src/outbound-payload.contract.test.ts | 43 ++++++++++++++++--- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts index e66c14f84b8..fc9a7e368f5 100644 --- a/extensions/discord/src/outbound-adapter.interactive-order.test.ts +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -10,6 +10,16 @@ await installDiscordOutboundModuleSpies(hoisted); const { discordOutbound } = await import("./outbound-adapter.js"); +type DiscordSendPayload = NonNullable; + +function requireDiscordSendPayload(): DiscordSendPayload { + const sendPayload = discordOutbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected Discord outbound sendPayload"); + } + return sendPayload; +} + describe("discordOutbound shared interactive ordering", () => { beforeEach(() => { resetDiscordOutboundMocks(hoisted); @@ -20,7 +30,8 @@ describe("discordOutbound shared interactive ordering", () => { }); it("keeps shared text blocks in authored order without hoisting fallback text", async () => { - const result = await discordOutbound.sendPayload!({ + const sendPayload = requireDiscordSendPayload(); + const result = await sendPayload({ cfg: {}, to: "channel:123456", text: "", diff --git a/extensions/discord/src/outbound-payload.contract.test.ts b/extensions/discord/src/outbound-payload.contract.test.ts index a6349938103..ad4f3ac2dd2 100644 --- a/extensions/discord/src/outbound-payload.contract.test.ts +++ b/extensions/discord/src/outbound-payload.contract.test.ts @@ -6,6 +6,16 @@ import { import { describe, vi } from "vitest"; import { discordOutbound } from "./outbound-adapter.js"; +type DiscordSendPayload = NonNullable; + +function requireDiscordSendPayload(): DiscordSendPayload { + const sendPayload = discordOutbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected Discord outbound sendPayload"); + } + return sendPayload; +} + function createDiscordHarness(params: OutboundPayloadHarnessParams) { const sendDiscord = vi.fn(); primeChannelOutboundSendMock( @@ -22,8 +32,9 @@ function createDiscordHarness(params: OutboundPayloadHarnessParams) { sendDiscord, }, }; + const sendPayload = requireDiscordSendPayload(); return { - run: async () => await discordOutbound.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendDiscord, to: ctx.to, }; diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts index 9c94ec59b22..31b90bfeb4d 100644 --- a/extensions/zalo/src/outbound-payload.contract.test.ts +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -18,6 +18,34 @@ vi.mock("./channel.runtime.js", () => ({ sendZaloText: sendZaloTextMock, })); +type ZaloOutbound = NonNullable; +type ZaloSendPayload = NonNullable; +type ZaloMessageSender = NonNullable; + +function requireZaloSendPayload(): ZaloSendPayload { + const sendPayload = zaloPlugin.outbound?.sendPayload; + if (!sendPayload) { + throw new Error("Expected Zalo outbound sendPayload"); + } + return sendPayload; +} + +function requireZaloTextSender(): NonNullable { + const text = zaloMessageAdapter.send?.text; + if (!text) { + throw new Error("Expected Zalo message adapter text sender"); + } + return text; +} + +function requireZaloMediaSender(): NonNullable { + const media = zaloMessageAdapter.send?.media; + if (!media) { + throw new Error("Expected Zalo message adapter media sender"); + } + return media; +} + function createZaloHarness(params: OutboundPayloadHarnessParams) { const sendZalo = vi.fn(); primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults); @@ -33,8 +61,9 @@ function createZaloHarness(params: OutboundPayloadHarnessParams) { text: "", payload: params.payload, }; + const sendPayload = requireZaloSendPayload(); return { - run: async () => await zaloPlugin.outbound!.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendZalo, to: ctx.to, }; @@ -67,6 +96,8 @@ describe("Zalo outbound payload contract", () => { }), }, ); + const sendText = requireZaloTextSender(); + const sendMedia = requireZaloMediaSender(); await expect( verifyChannelMessageAdapterCapabilityProofs({ @@ -74,24 +105,24 @@ describe("Zalo outbound payload contract", () => { adapter: zaloMessageAdapter, proofs: { text: async () => { - const result = await zaloMessageAdapter.send?.text?.({ + const result = await sendText({ cfg: {}, to: "123456789", text: "hello", }); - expect(result?.receipt.platformMessageIds).toEqual(["zl-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zl-text-1"]); }, media: async () => { - const result = await zaloMessageAdapter.send?.media?.({ + const result = await sendMedia({ cfg: {}, to: "123456789", text: "image", mediaUrl: "https://example.com/image.png", }); - expect(result?.receipt.platformMessageIds).toEqual(["zl-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zl-media-1"]); }, messageSendingHooks: () => { - expect(zaloMessageAdapter.send?.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }), From 47119a5527d7d05043684439d91c86491f79f0f9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:27:59 +0100 Subject: [PATCH 482/806] test: tighten cli respawn plan assertions --- src/entry.respawn.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts index 1d2f560994d..a8dd88da965 100644 --- a/src/entry.respawn.test.ts +++ b/src/entry.respawn.test.ts @@ -85,7 +85,8 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); }); it("returns null when both respawn guards are already satisfied", () => { @@ -128,8 +129,13 @@ describe("buildCliRespawnPlan", () => { platform: "linux", }); - expect(plan?.command).toBe("node"); - expect(plan?.argv).toEqual([EXPERIMENTAL_WARNING_FLAG, "/usr/local/bin/openclaw", "status"]); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.command).toBe("node"); + expect(respawnPlan.argv).toEqual([ + EXPERIMENTAL_WARNING_FLAG, + "/usr/local/bin/openclaw", + "status", + ]); }); }); From bbead1bb1ee008587b9b1ab724855b06333d92c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:28:23 +0100 Subject: [PATCH 483/806] test: require mattermost message adapter --- .../src/channel.message-adapter.test.ts | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index 3eb5645537a..51edfa1576b 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -13,6 +13,37 @@ vi.mock("./mattermost/send.js", () => ({ import { mattermostPlugin } from "./channel.js"; +type MattermostMessageAdapter = NonNullable; +type MattermostMessageSender = NonNullable; + +function requireMattermostMessageAdapter(): MattermostMessageAdapter { + const adapter = mattermostPlugin.message; + if (!adapter) { + throw new Error("Expected mattermost plugin to expose a channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: MattermostMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected mattermost message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: MattermostMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected mattermost message adapter media sender"); + } + return media; +} + describe("mattermost channel message adapter", () => { beforeEach(() => { sendMessageMattermostMock.mockReset(); @@ -23,14 +54,13 @@ describe("mattermost channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = mattermostPlugin.message; - if (!adapter) { - throw new Error("Expected mattermost plugin to expose a channel message adapter"); - } + const adapter = requireMattermostMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); const proveText = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:team-1", text: "hello", @@ -47,7 +77,7 @@ describe("mattermost channel message adapter", () => { const proveMedia = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.media!({ + const result = await sendMedia({ cfg: {}, to: "channel:team-1", text: "caption", @@ -67,7 +97,7 @@ describe("mattermost channel message adapter", () => { const proveReplyThread = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -84,7 +114,7 @@ describe("mattermost channel message adapter", () => { const proveExplicitReply = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "reply", @@ -109,43 +139,44 @@ describe("mattermost channel message adapter", () => { replyTo: proveExplicitReply, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = mattermostPlugin.message; + const adapter = requireMattermostMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); From 326f637c48dce8e531821675d2b3867bc6e5d7d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:30:04 +0100 Subject: [PATCH 484/806] test: require mattermost setup validator --- extensions/mattermost/src/setup.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/mattermost/src/setup.test.ts b/extensions/mattermost/src/setup.test.ts index f1acb93b7f1..231f6a7be96 100644 --- a/extensions/mattermost/src/setup.test.ts +++ b/extensions/mattermost/src/setup.test.ts @@ -142,9 +142,12 @@ describe("mattermost setup", () => { it("validates env and explicit credential requirements", () => { const validateInput = mattermostSetupAdapter.validateInput; expect(validateInput).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected Mattermost setup validateInput"); + } expect( - validateInput!({ + validateInput({ accountId: "secondary", input: { useEnv: true }, } as never), @@ -152,7 +155,7 @@ describe("mattermost setup", () => { normalizeMattermostBaseUrl.mockReturnValue(undefined); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, botToken: "tok", httpUrl: "not-a-url" }, } as never), @@ -160,7 +163,7 @@ describe("mattermost setup", () => { normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com"); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, botToken: "tok", httpUrl: "https://chat.example.com" }, } as never), From 02f762117d06ec49057a291faf6e2f71b1120bae Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:30:32 +0100 Subject: [PATCH 485/806] test: tighten media proxy output assertions --- src/media-understanding/runner.proxy.test.ts | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/media-understanding/runner.proxy.test.ts b/src/media-understanding/runner.proxy.test.ts index a924eb2775f..ba9695b12e5 100644 --- a/src/media-understanding/runner.proxy.test.ts +++ b/src/media-understanding/runner.proxy.test.ts @@ -57,6 +57,18 @@ function createOpenAiAudioCfg(providerOverrides: Record = {}): } as unknown as OpenClawConfig; } +function expectSingleOutputText( + result: Awaited>, + expectedText: string, +): void { + expect(result.outputs).toHaveLength(1); + const [output] = result.outputs; + if (!output) { + throw new Error("Expected media understanding output"); + } + expect(output.text).toBe(expectedText); +} + async function runAudioCapabilityWithFetchCapture(params: { fixturePrefix: string; outputText: string; @@ -83,7 +95,7 @@ async function runAudioCapabilityWithFetchCapture(params: { providerRegistry, }); - expect(result.outputs[0]?.text).toBe(params.outputText); + expectSingleOutputText(result, params.outputText); }); return seenFetchFn; } @@ -154,7 +166,7 @@ describe("runCapability proxy fetch passthrough", () => { ]), }); - expect(result.outputs[0]?.text).toBe("video ok"); + expectSingleOutputText(result, "video ok"); expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch); }); }); @@ -200,9 +212,12 @@ describe("runCapability proxy fetch passthrough", () => { providerRegistry, }); - expect(result.outputs[0]?.text).toBe("ok"); + expectSingleOutputText(result, "ok"); }); - expect(seenRequest?.allowPrivateNetwork).toBe(true); + if (!seenRequest) { + throw new Error("Expected audio provider request options"); + } + expect(seenRequest.allowPrivateNetwork).toBe(true); }); }); From 86a9b3fcb1c09e71e5408f5ad6679c47307827e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:31:36 +0100 Subject: [PATCH 486/806] test: require msteams message senders --- .../src/channel.message-adapter.test.ts | 61 ++++++++++++++----- extensions/msteams/src/outbound.test.ts | 34 ++++++++++- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/extensions/msteams/src/channel.message-adapter.test.ts b/extensions/msteams/src/channel.message-adapter.test.ts index 9c29f545488..a4d5b65b1f4 100644 --- a/extensions/msteams/src/channel.message-adapter.test.ts +++ b/extensions/msteams/src/channel.message-adapter.test.ts @@ -24,6 +24,37 @@ vi.mock("./channel.runtime.js", () => ({ import { msteamsPlugin } from "./channel.js"; +type MSTeamsMessageAdapter = NonNullable; +type MSTeamsMessageSender = NonNullable; + +function requireMSTeamsMessageAdapter(): MSTeamsMessageAdapter { + const adapter = msteamsPlugin.message; + if (!adapter) { + throw new Error("Expected msteams channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: MSTeamsMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected msteams message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: MSTeamsMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected msteams message adapter media sender"); + } + return media; +} + const cfg = { channels: { msteams: { @@ -50,12 +81,9 @@ describe("msteams channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = msteamsPlugin.message; - if (!adapter?.send?.text || !adapter.send.media) { - throw new Error("expected msteams channel message adapter with text and media senders"); - } - const sendText = adapter.send.text; - const sendMedia = adapter.send.media; + const adapter = requireMSTeamsMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); expect(adapter.durableFinal?.capabilities?.replyTo).toBeUndefined(); expect(adapter.durableFinal?.capabilities?.thread).toBeUndefined(); @@ -116,39 +144,40 @@ describe("msteams channel message adapter", () => { }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = msteamsPlugin.message; + const adapter = requireMSTeamsMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "msteamsMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + expect(adapter.live?.capabilities?.nativeStreaming).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, nativeStreaming: () => { - expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.previewReceipt).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "msteamsMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, previewReceipt: () => { - expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + expect(adapter.live?.capabilities?.nativeStreaming).toBe(true); }, }, }); diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index 2b11ffd6309..629115d16af 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -20,6 +20,34 @@ vi.mock("./polls.js", () => ({ import { msteamsOutbound } from "./outbound.js"; +type MSTeamsSendText = NonNullable; +type MSTeamsSendMedia = NonNullable; +type MSTeamsSendPoll = NonNullable; + +function requireSendText(): MSTeamsSendText { + const sendText = msteamsOutbound.sendText; + if (!sendText) { + throw new Error("Expected msteams outbound sendText"); + } + return sendText; +} + +function requireSendMedia(): MSTeamsSendMedia { + const sendMedia = msteamsOutbound.sendMedia; + if (!sendMedia) { + throw new Error("Expected msteams outbound sendMedia"); + } + return sendMedia; +} + +function requireSendPoll(): MSTeamsSendPoll { + const sendPoll = msteamsOutbound.sendPoll; + if (!sendPoll) { + throw new Error("Expected msteams outbound sendPoll"); + } + return sendPoll; +} + describe("msteamsOutbound cfg threading", () => { beforeEach(() => { mocks.sendMessageMSTeams.mockReset(); @@ -46,7 +74,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendText!({ + await requireSendText()({ cfg, to: "conversation:abc", text: "hello", @@ -68,7 +96,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendMedia!({ + await requireSendMedia()({ cfg, to: "conversation:abc", text: "photo", @@ -94,7 +122,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendPoll!({ + await requireSendPoll()({ cfg, to: "conversation:abc", poll: { From 8f30e37da86d9ebccf8c902c482c5c7dd7b4cff8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:33:13 +0100 Subject: [PATCH 487/806] test: require slack message adapter --- .../slack/src/channel.message-adapter.test.ts | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/extensions/slack/src/channel.message-adapter.test.ts b/extensions/slack/src/channel.message-adapter.test.ts index ffab986f6ff..7c4a676de45 100644 --- a/extensions/slack/src/channel.message-adapter.test.ts +++ b/extensions/slack/src/channel.message-adapter.test.ts @@ -16,6 +16,45 @@ const cfg = { }, } as OpenClawConfig; +type SlackMessageAdapter = NonNullable; +type SlackMessageSender = NonNullable; + +function requireSlackMessageAdapter(): SlackMessageAdapter { + const adapter = slackPlugin.message; + if (!adapter) { + throw new Error("Expected slack channel message adapter"); + } + return adapter; +} + +function requireTextSender(adapter: SlackMessageAdapter): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected slack message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: SlackMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected slack message adapter media sender"); + } + return media; +} + +function requirePayloadSender( + adapter: SlackMessageAdapter, +): NonNullable { + const payload = adapter.send?.payload; + if (!payload) { + throw new Error("Expected slack message adapter payload sender"); + } + return payload; +} + describe("slack channel message adapter", () => { const sendSlack = vi.fn(); @@ -25,13 +64,10 @@ describe("slack channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = slackPlugin.message; - if (!adapter?.send?.text || !adapter.send.media || !adapter.send.payload) { - throw new Error("expected slack channel message adapter with text/media/payload senders"); - } - const sendText = adapter.send.text; - const sendMedia = adapter.send.media; - const sendPayload = adapter.send.payload; + const adapter = requireSlackMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); + const sendPayload = requirePayloadSender(adapter); const proveText = async () => { sendSlack.mockClear(); @@ -152,39 +188,40 @@ describe("slack channel message adapter", () => { }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = slackPlugin.message; + const adapter = requireSlackMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "slackMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, nativeStreaming: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "slackMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); From a130dd080b8a73143ac6f6ebe19be98a29cfd7e5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:34:10 +0100 Subject: [PATCH 488/806] test: tighten image completion call assertions --- src/media-understanding/image.test.ts | 39 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 43a9a2b593a..65a4d700662 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -382,8 +382,12 @@ describe("describeImageWithModel", () => { }), expect.any(Object), ); - const [, context] = completeMock.mock.calls[0] ?? []; - expect(context?.messages?.[0]?.content).toHaveLength(1); + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected image completion call"); + } + const [, context] = firstCall; + expect(context.messages[0]?.content).toHaveLength(1); }); it("places OpenRouter image prompts in user content before images", async () => { @@ -422,9 +426,13 @@ describe("describeImageWithModel", () => { text: "openrouter ok", model: "google/gemini-2.5-flash", }); - const [, context] = completeMock.mock.calls[0] ?? []; - expect(context?.systemPrompt).toBeUndefined(); - expect(context?.messages?.[0]?.content).toEqual([ + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected OpenRouter image completion call"); + } + const [, context] = firstCall; + expect(context.systemPrompt).toBeUndefined(); + expect(context.messages[0]?.content).toEqual([ { type: "text", text: "Describe the image." }, expect.objectContaining({ type: "image", @@ -536,7 +544,11 @@ describe("describeImageWithModel", () => { model: model.id, }); expect(completeMock).toHaveBeenCalledTimes(2); - const [, , retryOptions] = completeMock.mock.calls[1] ?? []; + const retryCall = completeMock.mock.calls[1]; + if (!retryCall) { + throw new Error("Expected retry image completion call"); + } + const [retryModel, , retryOptions] = retryCall; if (!retryOptions?.onPayload) { throw new Error("expected retry payload mapper"); } @@ -546,7 +558,7 @@ describe("describeImageWithModel", () => { reasoning_effort: "high", include: ["reasoning.encrypted_content"], }, - completeMock.mock.calls[1]?.[0], + retryModel, ); expect(retryPayload).toEqual(expectedRetryPayload); }, @@ -580,9 +592,16 @@ describe("describeImageWithModel", () => { const assertion = expect(result).rejects.toThrow("image description timed out after 25ms"); await vi.advanceTimersByTimeAsync(25); await assertion; - const [, , options] = completeMock.mock.calls[0] ?? []; - expect(options?.signal?.aborted).toBe(true); - expect(options?.timeoutMs).toBe(25); + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected timed image completion call"); + } + const [, , options] = firstCall; + if (!options?.signal) { + throw new Error("Expected image completion abort signal"); + } + expect(options.signal.aborted).toBe(true); + expect(options.timeoutMs).toBe(25); }); it("rejects when image runtime setup exceeds the request timeout", async () => { From d7ce507d6f2b6984e976e96cad198d9faec0a57c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:35:20 +0100 Subject: [PATCH 489/806] test: require feishu outbound fixtures --- extensions/feishu/src/outbound.test.ts | 56 +++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 0c1edf5c9eb..a7aa30593f8 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -75,7 +75,48 @@ vi.mock("./comment-reaction.js", () => ({ import { feishuPlugin } from "./channel.js"; import { feishuOutbound } from "./outbound.js"; import { createFeishuSendReceipt } from "./send-result.js"; -const sendText = feishuOutbound.sendText!; + +type FeishuSendText = NonNullable; +type FeishuMessageAdapter = NonNullable; +type FeishuMessageSender = NonNullable; + +function requireFeishuSendText(): FeishuSendText { + const sendText = feishuOutbound.sendText; + if (!sendText) { + throw new Error("Expected Feishu outbound sendText"); + } + return sendText; +} + +function requireFeishuMessageAdapter(): FeishuMessageAdapter { + const adapter = feishuPlugin.message; + if (!adapter) { + throw new Error("Expected Feishu message adapter"); + } + return adapter; +} + +function requireFeishuTextSender( + adapter: FeishuMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected Feishu message adapter text sender"); + } + return text; +} + +function requireFeishuMediaSender( + adapter: FeishuMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected Feishu message adapter media sender"); + } + return media; +} + +const sendText = requireFeishuSendText(); const emptyConfig: ClawdbotConfig = {}; const cardRenderConfig: ClawdbotConfig = { channels: { @@ -133,14 +174,17 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { kind: "media", }), }); + const adapter = requireFeishuMessageAdapter(); + const adapterSendText = requireFeishuTextSender(adapter); + const adapterSendMedia = requireFeishuMediaSender(adapter); await expect( verifyChannelMessageAdapterCapabilityProofs({ adapterName: "feishu", - adapter: feishuPlugin.message!, + adapter, proofs: { text: async () => { - const result = await feishuPlugin.message?.send?.text?.({ + const result = await adapterSendText({ cfg: emptyConfig, to: "chat:chat-1", text: "hello", @@ -153,10 +197,10 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { accountId: "default", }), ); - expect(result?.receipt.platformMessageIds).toEqual(["feishu-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["feishu-text-1"]); }, media: async () => { - const result = await feishuPlugin.message?.send?.media?.({ + const result = await adapterSendMedia({ cfg: emptyConfig, to: "chat:chat-1", text: "", @@ -170,7 +214,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { accountId: "default", }), ); - expect(result?.receipt.platformMessageIds).toEqual(["feishu-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["feishu-media-1"]); }, }, }), From 9a83706da4168cdb4ea5fec16e6252fea70d6ee9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:36:18 +0100 Subject: [PATCH 490/806] test: tighten groq media provider assertion --- extensions/groq/index.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/groq/index.test.ts b/extensions/groq/index.test.ts index a18bb3a2a6c..34b98ba3c86 100644 --- a/extensions/groq/index.test.ts +++ b/extensions/groq/index.test.ts @@ -46,6 +46,11 @@ describe("groq provider compat", () => { label: "Groq", envVars: ["GROQ_API_KEY"], }); - expect(captured.mediaUnderstandingProviders[0]?.id).toBe("groq"); + expect(captured.mediaUnderstandingProviders).toHaveLength(1); + const [mediaProvider] = captured.mediaUnderstandingProviders; + if (!mediaProvider) { + throw new Error("Expected Groq media understanding provider"); + } + expect(mediaProvider.id).toBe("groq"); }); }); From a7b359d319e459443bebf784fb975f2776f691e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:36:55 +0100 Subject: [PATCH 491/806] test: require channel lifecycle starters --- .../nextcloud-talk/src/channel.lifecycle.test.ts | 14 ++++++++++++-- extensions/zalo/src/channel.startup.test.ts | 13 ++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts index 2c0cb17b39f..9ad5f348f45 100644 --- a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts +++ b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts @@ -17,6 +17,16 @@ vi.mock("./monitor-runtime.js", () => ({ const { nextcloudTalkGatewayAdapter } = await import("./gateway.js"); +type NextcloudTalkStartAccount = NonNullable; + +function requireStartAccount(): NextcloudTalkStartAccount { + const startAccount = nextcloudTalkGatewayAdapter.startAccount; + if (!startAccount) { + throw new Error("Expected Nextcloud Talk gateway startAccount"); + } + return startAccount; +} + function buildAccount(): ResolvedNextcloudTalkAccount { return { accountId: "default", @@ -40,7 +50,7 @@ function mockStartedMonitor() { } function startNextcloudAccount(abortSignal?: AbortSignal) { - return nextcloudTalkGatewayAdapter.startAccount!( + return requireStartAccount()( createStartAccountContext({ account: buildAccount(), abortSignal, @@ -56,7 +66,7 @@ describe("nextcloud-talk startAccount lifecycle", () => { it("keeps startAccount pending until abort, then stops the monitor", async () => { const stop = mockStartedMonitor(); const { abort, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: nextcloudTalkGatewayAdapter.startAccount!, + startAccount: requireStartAccount(), account: buildAccount(), }); await expectStopPendingUntilAbort({ diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts index 11070a51343..8486c631059 100644 --- a/extensions/zalo/src/channel.startup.test.ts +++ b/extensions/zalo/src/channel.startup.test.ts @@ -48,6 +48,17 @@ vi.mock("./channel.runtime.js", () => ({ import { zaloPlugin } from "./channel.js"; +type ZaloGateway = NonNullable; +type ZaloStartAccount = NonNullable; + +function requireStartAccount(): ZaloStartAccount { + const startAccount = zaloPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("Expected Zalo gateway startAccount"); + } + return startAccount; +} + function buildAccount(): ResolvedZaloAccount { return { accountId: "default", @@ -76,7 +87,7 @@ describe("zaloPlugin gateway.startAccount", () => { ); const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: zaloPlugin.gateway!.startAccount!, + startAccount: requireStartAccount(), account: buildAccount(), }); From 5cd175bde9e7ea15e5854e873fc9142ac3da8c5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:38:52 +0100 Subject: [PATCH 492/806] test: require provider optional hooks --- extensions/minimax/speech-provider.test.ts | 11 +++++++++-- .../searxng/src/searxng-search-provider.test.ts | 6 +++++- extensions/volcengine/tts.test.ts | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/extensions/minimax/speech-provider.test.ts b/extensions/minimax/speech-provider.test.ts index e8ea8969138..42ecba258ca 100644 --- a/extensions/minimax/speech-provider.test.ts +++ b/extensions/minimax/speech-provider.test.ts @@ -349,7 +349,10 @@ describe("buildMinimaxSpeechProvider", () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0]; expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2"); - const body = JSON.parse(init!.body as string); + if (!init?.body) { + throw new Error("Expected MiniMax TTS fetch init body"); + } + const body = JSON.parse(init.body as string); expect(body.model).toBe("speech-2.8-hd"); expect(body.text).toBe("Hello world"); expect(body.voice_setting.voice_id).toBe("English_expressive_narrator"); @@ -505,7 +508,11 @@ describe("buildMinimaxSpeechProvider", () => { describe("listVoices", () => { it("returns known voices", async () => { - const voices = await provider.listVoices!({} as never); + const listVoices = provider.listVoices; + if (!listVoices) { + throw new Error("Expected MiniMax provider listVoices"); + } + const voices = await listVoices({} as never); expect(voices.length).toBeGreaterThan(0); expect(voices[0].id).toBe("English_expressive_narrator"); }); diff --git a/extensions/searxng/src/searxng-search-provider.test.ts b/extensions/searxng/src/searxng-search-provider.test.ts index 20db0fdf142..8675f50e026 100644 --- a/extensions/searxng/src/searxng-search-provider.test.ts +++ b/extensions/searxng/src/searxng-search-provider.test.ts @@ -155,8 +155,12 @@ describe("searxng web search provider", () => { it("persists base URL to plugin config via setConfiguredCredentialValue", () => { const provider = createSearxngWebSearchProvider(); const config = {} as Record; + const setConfiguredCredentialValue = provider.setConfiguredCredentialValue; + if (!setConfiguredCredentialValue) { + throw new Error("Expected SearXNG provider setConfiguredCredentialValue"); + } - provider.setConfiguredCredentialValue!(config, "http://search.local:9000"); + setConfiguredCredentialValue(config, "http://search.local:9000"); expect( ( diff --git a/extensions/volcengine/tts.test.ts b/extensions/volcengine/tts.test.ts index 9af0ad8cae7..34ea952b6a5 100644 --- a/extensions/volcengine/tts.test.ts +++ b/extensions/volcengine/tts.test.ts @@ -108,7 +108,11 @@ describe("Volcengine speech provider", () => { }); it("lists voices with locale and gender", async () => { - const voices = await provider.listVoices!({}); + const listVoices = provider.listVoices; + if (!listVoices) { + throw new Error("Expected Volcengine provider listVoices"); + } + const voices = await listVoices({}); expect(voices.length).toBeGreaterThan(0); expect(voices[0]).toMatchObject({ locale: "en-US" }); expect(voices[0].gender).toMatch(/^(female|male)$/u); From 38e9d93da7d4ac7fd7b17457a51952d4e8166d8f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:38:49 +0100 Subject: [PATCH 493/806] test: tighten tool planner hidden assertions --- src/tools/planner.test.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/tools/planner.test.ts b/src/tools/planner.test.ts index 6d3c26c1d46..bc2db436f9e 100644 --- a/src/tools/planner.test.ts +++ b/src/tools/planner.test.ts @@ -16,6 +16,16 @@ function descriptor(name: string, overrides: Partial = {}): Tool }; } +type ToolPlan = ReturnType; + +function expectHiddenTool(plan: ToolPlan, index: number): ToolPlan["hidden"][number] { + const entry = plan.hidden[index]; + if (!entry) { + throw new Error(`Expected hidden tool at index ${index}`); + } + return entry; +} + describe("buildToolPlan", () => { it("sorts visible and hidden tools deterministically", () => { const plan = buildToolPlan({ @@ -32,7 +42,9 @@ describe("buildToolPlan", () => { expect(plan.visible.map((entry) => entry.descriptor.name)).toEqual(["alpha", "zeta"]); expect(plan.hidden.map((entry) => entry.descriptor.name)).toEqual(["hidden"]); - expect(plan.hidden[0]?.diagnostics.map((entry) => entry.reason)).toEqual(["env-missing"]); + expect(expectHiddenTool(plan, 0).diagnostics.map((entry) => entry.reason)).toEqual([ + "env-missing", + ]); }); it("fails deterministically on duplicate tool names", () => { @@ -81,8 +93,9 @@ describe("buildToolPlan", () => { }); expect(plan.visible).toEqual([]); - expect(plan.hidden[0]?.descriptor.name).toBe("plugin_tool"); - expect(plan.hidden[0]?.diagnostics[0]?.reason).toBe("plugin-disabled"); + const hiddenTool = expectHiddenTool(plan, 0); + expect(hiddenTool.descriptor.name).toBe("plugin_tool"); + expect(hiddenTool.diagnostics.map((entry) => entry.reason)).toEqual(["plugin-disabled"]); }); it("hides descriptors with malformed empty allOf availability", () => { @@ -91,8 +104,9 @@ describe("buildToolPlan", () => { }); expect(plan.visible).toEqual([]); - expect(plan.hidden[0]?.descriptor.name).toBe("malformed"); - expect(plan.hidden[0]?.diagnostics).toEqual([ + const hiddenTool = expectHiddenTool(plan, 0); + expect(hiddenTool.descriptor.name).toBe("malformed"); + expect(hiddenTool.diagnostics).toEqual([ { reason: "unsupported-signal", message: "Empty availability allOf group", From 80cc3e66fd58729642ab836300da1bf6a2112202 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:40:59 +0100 Subject: [PATCH 494/806] test: require msteams config fixture --- .../msteams/src/monitor.lifecycle.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index 11d4f6ef412..f65cc063b99 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -205,6 +205,20 @@ function createConfig(port: number): OpenClawConfig { } as OpenClawConfig; } +function updateMSTeamsConfig( + cfg: OpenClawConfig, + patch: NonNullable["msteams"]>, +): void { + const msteams = cfg.channels?.msteams; + if (!cfg.channels || !msteams) { + throw new Error("Expected Microsoft Teams config fixture"); + } + cfg.channels.msteams = { + ...msteams, + ...patch, + }; +} + function createRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -331,8 +345,7 @@ describe("monitorMSTeamsProvider lifecycle", () => { it("does not resolve user allowlists by display name unless name matching is enabled", async () => { const abort = new AbortController(); const cfg = createConfig(0); - cfg.channels!.msteams = { - ...cfg.channels!.msteams!, + updateMSTeamsConfig(cfg, { allowFrom: ["Alice", "user:40a1a0ed-4ff2-4164-a219-55518990c197"], groupAllowFrom: ["Bob", "msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198"], teams: { @@ -342,7 +355,7 @@ describe("monitorMSTeamsProvider lifecycle", () => { }, }, }, - }; + }); resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockResolvedValueOnce([ { input: "Product/Roadmap", @@ -394,12 +407,11 @@ describe("monitorMSTeamsProvider lifecycle", () => { const abort = new AbortController(); const cfg = createConfig(0); - cfg.channels!.msteams = { - ...cfg.channels!.msteams!, + updateMSTeamsConfig(cfg, { dangerouslyAllowNameMatching: true, allowFrom: ["Alice"], groupAllowFrom: ["Bob"], - }; + }); const task = monitorMSTeamsProvider({ cfg, From 84094573fbb2f126614d49a77497466195980366 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:42:11 +0100 Subject: [PATCH 495/806] test: tighten deepgram audio request assertion --- extensions/deepgram/audio.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/deepgram/audio.test.ts b/extensions/deepgram/audio.test.ts index 657d2308fa1..92f424d0cee 100644 --- a/extensions/deepgram/audio.test.ts +++ b/extensions/deepgram/audio.test.ts @@ -55,14 +55,17 @@ describe("transcribeDeepgramAudio", () => { expect(seenUrl).toBe( "https://api.example.com/v1/listen?model=nova-3&language=en&punctuate=false&smart_format=true", ); - expect(seenInit?.method).toBe("POST"); - expect(seenInit?.signal).toBeInstanceOf(AbortSignal); + if (!seenInit) { + throw new Error("Expected Deepgram fetch request init"); + } + expect(seenInit.method).toBe("POST"); + expect(seenInit.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(seenInit?.headers); + const headers = new Headers(seenInit.headers); expect(headers.get("authorization")).toBe("Token test-key"); expect(headers.get("x-custom")).toBe("1"); expect(headers.get("content-type")).toBe("audio/wav"); - expect(seenInit?.body).toBeInstanceOf(Uint8Array); + expect(seenInit.body).toBeInstanceOf(Uint8Array); }); it("throws when the provider response omits transcript", async () => { From edb3e6732c1fccdaf6f0c69afc98dd63786d106f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:42:33 +0100 Subject: [PATCH 496/806] test: require dreaming markdown paths --- .../memory-core/src/dreaming-markdown.test.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/extensions/memory-core/src/dreaming-markdown.test.ts b/extensions/memory-core/src/dreaming-markdown.test.ts index e2293baa36a..6bb3c3c6be6 100644 --- a/extensions/memory-core/src/dreaming-markdown.test.ts +++ b/extensions/memory-core/src/dreaming-markdown.test.ts @@ -6,6 +6,20 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js"; const { createTempWorkspace } = createMemoryCoreTestHarness(); +function requireInlinePath(result: { inlinePath?: string }): string { + if (!result.inlinePath) { + throw new Error("Expected inline dreaming markdown path"); + } + return result.inlinePath; +} + +function requireReportPath(reportPath: string | undefined): string { + if (!reportPath) { + throw new Error("Expected deep dreaming report path"); + } + return reportPath; +} + describe("dreaming markdown storage", () => { const nowMs = Date.parse("2026-04-05T10:00:00Z"); const timezone = "UTC"; @@ -25,8 +39,9 @@ describe("dreaming markdown storage", () => { }, }); - expect(result.inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); - const content = await fs.readFile(result.inlinePath!, "utf-8"); + const inlinePath = requireInlinePath(result); + expect(inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); + const content = await fs.readFile(inlinePath, "utf-8"); expect(content).toContain("## Light Sleep"); expect(content).toContain("- Candidate: remember the API key is fake"); }); @@ -82,8 +97,9 @@ describe("dreaming markdown storage", () => { }, }); - expect(result.inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); - const content = await fs.readFile(result.inlinePath!, "utf-8"); + const inlinePath = requireInlinePath(result); + expect(inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); + const content = await fs.readFile(inlinePath, "utf-8"); expect(content).toContain("## REM Sleep"); expect(content).toContain("- Theme: `glacier` kept surfacing."); await expect(fs.readFile(lowercasePath, "utf-8")).resolves.toBe("# Scratch\n\n"); @@ -103,8 +119,11 @@ describe("dreaming markdown storage", () => { timezone: "UTC", }); - expect(reportPath).toBe(path.join(workspaceDir, "memory", "dreaming", "deep", "2026-04-05.md")); - const content = await fs.readFile(reportPath!, "utf-8"); + const requiredReportPath = requireReportPath(reportPath); + expect(requiredReportPath).toBe( + path.join(workspaceDir, "memory", "dreaming", "deep", "2026-04-05.md"), + ); + const content = await fs.readFile(requiredReportPath, "utf-8"); expect(content).toContain("# Deep Sleep"); expect(content).toContain("- Promoted: durable preference"); From d3e3c96a803884858280e88e719c44be104a947f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:43:39 +0100 Subject: [PATCH 497/806] test: tighten task flow snapshot assertions --- src/tasks/task-flow-registry.store.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tasks/task-flow-registry.store.test.ts b/src/tasks/task-flow-registry.store.test.ts index 83d0bde4f86..a18f9e38be8 100644 --- a/src/tasks/task-flow-registry.store.test.ts +++ b/src/tasks/task-flow-registry.store.test.ts @@ -110,11 +110,19 @@ describe("task-flow-registry store runtime", () => { }); expect(saveSnapshot).toHaveBeenCalled(); - const latestSnapshot = saveSnapshot.mock.calls.at(-1)?.[0] as { + const latestCall = saveSnapshot.mock.calls.at(-1); + if (!latestCall) { + throw new Error("Expected task flow snapshot save call"); + } + const latestSnapshot = latestCall[0] as { flows: ReadonlyMap; }; expect(latestSnapshot.flows.size).toBe(2); - expect(latestSnapshot.flows.get("flow-restored")?.goal).toBe("Restored flow"); + const restoredFlow = latestSnapshot.flows.get("flow-restored"); + if (!restoredFlow) { + throw new Error("Expected restored task flow"); + } + expect(restoredFlow.goal).toBe("Restored flow"); }); it("restores persisted wait-state, revision, and cancel intent from sqlite", async () => { From 2956013a237a4f72417eafc2afa2d1d2141d67ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:45:12 +0100 Subject: [PATCH 498/806] test: require outbound contract hooks --- extensions/opencode/index.test.ts | 5 ++++- .../plugins/contracts/outbound-payload.contract.test.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index 1723a499122..a1a59de1117 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -51,7 +51,10 @@ describe("opencode provider plugin", () => { name: "OpenCode Zen Provider", }); const provider = requireRegisteredProvider(providers, "opencode"); - const resolveThinkingProfile = provider.resolveThinkingProfile!; + const resolveThinkingProfile = provider.resolveThinkingProfile; + if (!resolveThinkingProfile) { + throw new Error("Expected OpenCode provider resolveThinkingProfile"); + } expect( resolveThinkingProfile({ diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index e0025a76ef7..ebff136cd21 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -22,8 +22,12 @@ function createDirectTextMediaHarness(params: OutboundPayloadHarnessParams) { text: "", payload: params.payload, }; + const sendPayload = outbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected direct text/media outbound sendPayload"); + } return { - run: async () => await outbound.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendFn, to: ctx.to, }; From e3d23114b85eca4b13f312e8591fca467681875d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:45:29 +0100 Subject: [PATCH 499/806] test: tighten deepinfra image result assertions --- .../image-generation-provider.test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/extensions/deepinfra/image-generation-provider.test.ts b/extensions/deepinfra/image-generation-provider.test.ts index f485233c323..c2b6a66e91f 100644 --- a/extensions/deepinfra/image-generation-provider.test.ts +++ b/extensions/deepinfra/image-generation-provider.test.ts @@ -116,9 +116,14 @@ describe("deepinfra image generation provider", () => { }, }), ); - expect(result.images[0]?.mimeType).toBe("image/jpeg"); - expect(result.images[0]?.fileName).toBe("image-1.jpg"); - expect(result.images[0]?.revisedPrompt).toBe("red square"); + expect(result.images).toHaveLength(1); + const [firstImage] = result.images; + if (!firstImage) { + throw new Error("Expected generated DeepInfra image"); + } + expect(firstImage.mimeType).toBe("image/jpeg"); + expect(firstImage.fileName).toBe("image-1.jpg"); + expect(firstImage.revisedPrompt).toBe("red square"); expect(release).toHaveBeenCalledOnce(); }); @@ -152,11 +157,20 @@ describe("deepinfra image generation provider", () => { url: "https://api.deepinfra.com/v1/openai/images/edits", }), ); - const form = postMultipartRequestMock.mock.calls[0]?.[0].body as FormData; + const firstCall = postMultipartRequestMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected DeepInfra multipart request"); + } + const form = firstCall[0].body as FormData; expect(form.get("model")).toBe("black-forest-labs/FLUX-1-schnell"); expect(form.get("prompt")).toBe("make it neon"); expect(form.get("response_format")).toBe("b64_json"); expect(form.get("image")).toBeInstanceOf(File); - expect(result.images[0]?.mimeType).toBe("image/png"); + expect(result.images).toHaveLength(1); + const [image] = result.images; + if (!image) { + throw new Error("Expected edited DeepInfra image"); + } + expect(image.mimeType).toBe("image/png"); }); }); From f972d9e7d1be977c652a98ba681c605feb058a80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:46:55 +0100 Subject: [PATCH 500/806] test: require provider rollback fixtures --- extensions/gradium/speech-provider.test.ts | 6 +++++- extensions/memory-wiki/src/cli.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/extensions/gradium/speech-provider.test.ts b/extensions/gradium/speech-provider.test.ts index e98c4beb922..784828f85d9 100644 --- a/extensions/gradium/speech-provider.test.ts +++ b/extensions/gradium/speech-provider.test.ts @@ -94,8 +94,12 @@ describe("gradium speech provider", () => { const audioData = Buffer.from("ulaw-audio-data"); const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 })); vi.stubGlobal("fetch", fetchMock); + const synthesizeTelephony = provider.synthesizeTelephony; + if (!synthesizeTelephony) { + throw new Error("Expected Gradium provider synthesizeTelephony"); + } - const result = await provider.synthesizeTelephony!({ + const result = await synthesizeTelephony({ text: "Telephony test", cfg: {} as never, providerConfig: { apiKey: "gsk_test123", voiceId: "default-voice" }, diff --git a/extensions/memory-wiki/src/cli.test.ts b/extensions/memory-wiki/src/cli.test.ts index f676a08d57b..37a4925cc9a 100644 --- a/extensions/memory-wiki/src/cli.test.ts +++ b/extensions/memory-wiki/src/cli.test.ts @@ -508,10 +508,13 @@ cli note expect(secondDryRun.createdCount).toBe(0); expect(secondDryRun.updatedCount).toBe(0); expect(secondDryRun.skippedCount).toBe(1); + if (!applied.runId) { + throw new Error("Expected ChatGPT import dry-run apply runId"); + } const rollback = await runWikiChatGptRollback({ config, - runId: applied.runId!, + runId: applied.runId, json: true, }); expect(rollback.alreadyRolledBack).toBe(false); From af4213c5a34152a435572107c9d561235330c0df Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:46:56 +0100 Subject: [PATCH 501/806] test: tighten echo transcript delivery assertion --- src/media-understanding/apply.echo-transcript.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 97677efadaf..bf1305b3831 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -113,7 +113,11 @@ function disableImageUnderstanding(cfg: OpenClawConfig): void { function expectSingleEchoDeliveryCall() { expect(mockDeliverOutboundPayloads).toHaveBeenCalledOnce(); - const callArgs = mockDeliverOutboundPayloads.mock.calls[0]?.[0]; + const firstCall = mockDeliverOutboundPayloads.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected echo transcript delivery call"); + } + const callArgs = firstCall[0]; if (!callArgs) { throw new Error("Expected one echo transcript delivery call"); } From 6d1c5c9df32b195195c3a46fb7d2c711196a2a4c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:48:59 +0100 Subject: [PATCH 502/806] test: tighten custom theme parse assertion --- ui/src/ui/custom-theme.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/ui/custom-theme.test.ts index 4cddb4d0fe5..0272ebd07c5 100644 --- a/ui/src/ui/custom-theme.test.ts +++ b/ui/src/ui/custom-theme.test.ts @@ -299,7 +299,11 @@ describe("custom theme import helpers", () => { it("parses stored imported themes and rejects malformed records", () => { const imported = createImportedTheme(); - expect(parseImportedCustomTheme(imported)?.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z"); + const parsed = parseImportedCustomTheme(imported); + if (!parsed) { + throw new Error("Expected imported custom theme to parse"); + } + expect(parsed.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z"); expect(parseImportedCustomTheme({ ...imported, light: {} })).toBeNull(); }); From fb689b9b97aeb91a96e62eca8935e1e32144d8fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:49:01 +0100 Subject: [PATCH 503/806] test: require imessage adapter fixtures --- extensions/imessage/src/test-plugin.test.ts | 97 +++++++++++++++++---- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index b6aff0a77c0..c2b88d9aa17 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -19,6 +19,66 @@ afterEach(() => { resetFacadeRuntimeStateForTest(); }); +type IMessageOutbound = NonNullable["outbound"]>; +type IMessageMessageAdapter = NonNullable; +type IMessageMessageSender = NonNullable; + +function requireOutbound(): IMessageOutbound { + const outbound = createIMessageTestPlugin().outbound; + if (!outbound) { + throw new Error("Expected iMessage test plugin outbound adapter"); + } + return outbound; +} + +function requireOutboundSendText( + outbound: IMessageOutbound, +): NonNullable { + const sendText = outbound.sendText; + if (!sendText) { + throw new Error("Expected iMessage outbound sendText"); + } + return sendText; +} + +function requireOutboundSendMedia( + outbound: IMessageOutbound, +): NonNullable { + const sendMedia = outbound.sendMedia; + if (!sendMedia) { + throw new Error("Expected iMessage outbound sendMedia"); + } + return sendMedia; +} + +function requireMessageAdapter(): IMessageMessageAdapter { + const adapter = imessagePlugin.message; + if (!adapter) { + throw new Error("Expected iMessage message adapter"); + } + return adapter; +} + +function requireMessageSendText( + adapter: IMessageMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected iMessage message adapter text sender"); + } + return text; +} + +function requireMessageSendMedia( + adapter: IMessageMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected iMessage message adapter media sender"); + } + return media; +} + describe("createIMessageTestPlugin", () => { it("does not load the bundled iMessage facade by default", () => { expect(listImportedBundledPluginFacadeIds()).toEqual([]); @@ -55,7 +115,9 @@ describe("createIMessageTestPlugin", () => { }); it("backs declared durable final capabilities with delivery proofs", async () => { - const outbound = createIMessageTestPlugin().outbound!; + const outbound = requireOutbound(); + const sendText = requireOutboundSendText(outbound); + const sendMedia = requireOutboundSendMedia(outbound); const sendIMessage = async () => ({ messageId: "imsg-1" }); await verifyDurableFinalCapabilityProofs({ @@ -64,7 +126,7 @@ describe("createIMessageTestPlugin", () => { proofs: { text: async () => { await expect( - outbound.sendText?.({ + sendText({ cfg: {} as never, to: "+15551234567", text: "hello", @@ -74,7 +136,7 @@ describe("createIMessageTestPlugin", () => { }, media: async () => { await expect( - outbound.sendMedia?.({ + sendMedia({ cfg: {} as never, to: "+15551234567", text: "caption", @@ -86,7 +148,7 @@ describe("createIMessageTestPlugin", () => { }, replyTo: async () => { await expect( - outbound.sendText?.({ + sendText({ cfg: {} as never, to: "+15551234567", text: "reply", @@ -96,7 +158,7 @@ describe("createIMessageTestPlugin", () => { ).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" }); }, messageSendingHooks: () => { - expect(outbound.sendText).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); @@ -119,49 +181,52 @@ describe("createIMessageTestPlugin", () => { }), }; }; + const adapter = requireMessageAdapter(); + const sendText = requireMessageSendText(adapter); + const sendMedia = requireMessageSendMedia(adapter); await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "imessageMessage", - adapter: imessagePlugin.message!, + adapter, proofs: { text: async () => { - const result = await imessagePlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {} as never, to: "+15551234567", text: "hello", deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.platformMessageIds).toEqual(["imsg-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["imsg-text-1"]); }, media: async () => { - const result = await imessagePlugin.message?.send?.media?.({ + const result = await sendMedia({ cfg: {} as never, to: "+15551234567", text: "caption", mediaUrl: "/tmp/image.png", mediaLocalRoots: ["/tmp"], deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.platformMessageIds).toEqual(["imsg-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["imsg-media-1"]); }, replyTo: async () => { - const result = await imessagePlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {} as never, to: "+15551234567", text: "reply", replyToId: "reply-1", deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.replyToId).toBe("reply-1"); + expect(result.receipt.replyToId).toBe("reply-1"); }, messageSendingHooks: () => { - expect(imessagePlugin.message?.send?.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); From 7d3cb57f924529c2c710409cb59088369e57f166 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:51:12 +0100 Subject: [PATCH 504/806] test: require node host path token --- src/node-host/invoke-system-run-plan.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 638fc1cfe54..fefaffb9ccc 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -53,6 +53,13 @@ type UnsafeRuntimeInvocationCase = { setup?: (tmp: string) => void; }; +function requirePathToken(pathToken: PathTokenSetup | null): PathTokenSetup { + if (!pathToken) { + throw new Error("Expected PATH token fixture"); + } + return pathToken; +} + function createScriptOperandFixture(tmp: string, fixture?: RuntimeFixture): ScriptOperandFixture { if (fixture) { return { @@ -386,7 +393,7 @@ describe("hardenApprovedExecutionPaths", () => { argv: ["poccmd", "SAFE"], shellCommand: null, withPathToken: true, - expectedArgv: ({ pathToken }) => [pathToken!.expected, "SAFE"], + expectedArgv: ({ pathToken }) => [requirePathToken(pathToken).expected, "SAFE"], expectedArgvChanged: true, }, { @@ -403,7 +410,7 @@ describe("hardenApprovedExecutionPaths", () => { mode: "build-plan", argv: ["poccmd", "hello"], withPathToken: true, - expectedArgv: ({ pathToken }) => [pathToken!.expected, "hello"], + expectedArgv: ({ pathToken }) => [requirePathToken(pathToken).expected, "hello"], checkRawCommandMatchesArgv: true, expectedCommandPreview: null, }, From 7c86f7434d92a3638c85258b57a5d4a0135276d3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:51:16 +0100 Subject: [PATCH 505/806] test: tighten qianfan provider assertions --- extensions/qianfan/index.test.ts | 47 ++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/extensions/qianfan/index.test.ts b/extensions/qianfan/index.test.ts index abe70506997..db793f3d58f 100644 --- a/extensions/qianfan/index.test.ts +++ b/extensions/qianfan/index.test.ts @@ -12,6 +12,13 @@ import { QIANFAN_DEFAULT_MODEL_REF, } from "./onboard.js"; +function expectRecord(value: T | undefined, label: string): T { + if (!value) { + throw new Error(`Expected ${label}`); + } + return value; +} + describe("qianfan provider plugin", () => { it("registers Qianfan with api-key auth wizard metadata", async () => { const provider = await registerSingleProviderPlugin(qianfanPlugin); @@ -37,11 +44,17 @@ describe("qianfan provider plugin", () => { expect(catalogProvider.api).toBe("openai-completions"); expect(catalogProvider.baseUrl).toBe("https://qianfan.baidubce.com/v2"); - expect(catalogProvider.models?.map((model) => model.id)).toEqual([ + const models = expectRecord(catalogProvider.models, "Qianfan catalog models"); + expect(models.map((model) => model.id)).toEqual([ "deepseek-v3.2", "ernie-5.0-thinking-preview", ]); - expect(catalogProvider.models?.find((model) => model.id === "deepseek-v3.2")).toMatchObject({ + expect( + expectRecord( + models.find((model) => model.id === "deepseek-v3.2"), + "deepseek model", + ), + ).toMatchObject({ name: "DEEPSEEK V3.2", reasoning: true, input: ["text"], @@ -49,7 +62,10 @@ describe("qianfan provider plugin", () => { maxTokens: 32768, }); expect( - catalogProvider.models?.find((model) => model.id === "ernie-5.0-thinking-preview"), + expectRecord( + models.find((model) => model.id === "ernie-5.0-thinking-preview"), + "ernie model", + ), ).toMatchObject({ name: "ERNIE-5.0-Thinking-Preview", reasoning: true, @@ -68,25 +84,34 @@ describe("qianfan provider plugin", () => { }, }); - expect(cfg.models?.providers?.qianfan).toMatchObject({ + const modelsConfig = expectRecord(cfg.models, "models config"); + const providers = expectRecord(modelsConfig.providers, "model providers"); + const providerConfig = expectRecord(providers.qianfan, "Qianfan provider config"); + expect(providerConfig).toMatchObject({ api: "openai-completions", baseUrl: "https://qianfan.baidubce.com/v2", }); - expect(cfg.models?.providers?.qianfan?.models?.map((model) => model.id)).toEqual([ + const providerModels = expectRecord(providerConfig.models, "Qianfan provider models"); + expect(providerModels.map((model) => model.id)).toEqual([ "deepseek-v3.2", "ernie-5.0-thinking-preview", ]); - expect(cfg.agents?.defaults?.models?.[QIANFAN_DEFAULT_MODEL_REF]?.alias).toBe("QIANFAN"); - expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( - "anthropic/claude-opus-4-6", + const agentsConfig = expectRecord(cfg.agents, "agents config"); + const agentDefaults = expectRecord(agentsConfig.defaults, "agent defaults"); + const agentModelAliases = expectRecord(agentDefaults.models, "agent model aliases"); + const qianfanAlias = expectRecord( + agentModelAliases[QIANFAN_DEFAULT_MODEL_REF], + "Qianfan model alias", ); + expect(qianfanAlias.alias).toBe("QIANFAN"); + expect(resolveAgentModelPrimaryValue(agentDefaults.model)).toBe("anthropic/claude-opus-4-6"); }); it("sets Qianfan as the agent primary model in full onboarding mode", () => { const cfg = applyQianfanConfig({}); - expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( - QIANFAN_DEFAULT_MODEL_REF, - ); + const agentsConfig = expectRecord(cfg.agents, "agents config"); + const agentDefaults = expectRecord(agentsConfig.defaults, "agent defaults"); + expect(resolveAgentModelPrimaryValue(agentDefaults.model)).toBe(QIANFAN_DEFAULT_MODEL_REF); }); }); From 8543ba40ded792f647a5efba85fbfbb822c783ff Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:53:29 +0100 Subject: [PATCH 506/806] test: tighten huggingface provider assertion --- extensions/huggingface/index.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/huggingface/index.test.ts b/extensions/huggingface/index.test.ts index c6c89a8ad2d..d4cb715d3cc 100644 --- a/extensions/huggingface/index.test.ts +++ b/extensions/huggingface/index.test.ts @@ -40,7 +40,12 @@ function registerProviderWithPluginConfig(pluginConfig: Record) ); expect(registerProviderMock).toHaveBeenCalledTimes(1); - return registerProviderMock.mock.calls[0]?.[0]; + const firstCall = registerProviderMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected huggingface provider registration"); + } + return firstCall[0]; } describe("huggingface plugin", () => { From a4c95bbb80a13a534d57c6a51675ea03461307bd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:54:12 +0100 Subject: [PATCH 507/806] test: tighten locale version assertion --- ui/src/i18n/test/translate.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index f7e83caf0c2..d57acacbf80 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -143,8 +143,12 @@ describe("i18n", () => { it("keeps the version label available in shipped locales", () => { for (const [locale, value] of Object.entries(shippedLocales)) { - expect((value.common as { version?: string }).version, locale).toBeTypeOf("string"); - expect((value.common as { version?: string }).version?.trim(), locale).not.toBe(""); + const version = (value.common as { version?: unknown }).version; + expect(version, locale).toBeTypeOf("string"); + if (typeof version !== "string") { + throw new Error(`expected ${locale} common.version to be a string`); + } + expect(version.trim(), locale).not.toBe(""); } }); From d98752155dcfe588b8eed07f67c89f7dc4e66c88 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:55:07 +0100 Subject: [PATCH 508/806] test: tighten kilocode provider assertions --- extensions/kilocode/implicit-provider.test.ts | 2 +- extensions/kilocode/provider-models.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/kilocode/implicit-provider.test.ts b/extensions/kilocode/implicit-provider.test.ts index b549238d372..67040e3a698 100644 --- a/extensions/kilocode/implicit-provider.test.ts +++ b/extensions/kilocode/implicit-provider.test.ts @@ -7,6 +7,6 @@ describe("Kilo Gateway implicit provider", () => { expect(provider.baseUrl).toBe("https://api.kilo.ai/api/gateway/"); expect(provider.api).toBe("openai-completions"); - expect(provider.models?.length).toBeGreaterThan(0); + expect(provider.models.length).toBeGreaterThan(0); }); }); diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 72c7f663f50..ebb13257638 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -175,8 +175,8 @@ describe("discoverKilocodeModels (fetch path)", () => { expect(sonnet.cost.cacheWrite).toBeCloseTo(3.75); expect(sonnet.input).toEqual(["text", "image"]); expect(sonnet.reasoning).toBe(true); - expect(sonnet?.contextWindow).toBe(200000); - expect(sonnet?.maxTokens).toBe(8192); + expect(sonnet.contextWindow).toBe(200000); + expect(sonnet.maxTokens).toBe(8192); }); }); @@ -234,9 +234,9 @@ describe("discoverKilocodeModels (fetch path)", () => { }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); - const textModel = models.find((m) => m.id === "some/text-model"); - expect(textModel?.input).toEqual(["text"]); - expect(textModel?.reasoning).toBe(false); + const textModel = requireModelById(models, "some/text-model"); + expect(textModel.input).toEqual(["text"]); + expect(textModel.reasoning).toBe(false); }); }); From f29327b65ddbde947a1558b46e81ffafbb556351 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:55:52 +0100 Subject: [PATCH 509/806] test: require channels controller fixtures --- ui/src/ui/controllers/channels.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts index f55e679dce1..25012353eda 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/ui/controllers/channels.test.ts @@ -3,12 +3,15 @@ import type { ChannelsStatusSnapshot } from "../types.ts"; import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./channels.ts"; function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } @@ -29,6 +32,14 @@ function createState(): ChannelsState { }; } +function requireClientRequest(state: ChannelsState) { + const request = state.client?.request; + if (!request) { + throw new Error("Expected channels controller client request"); + } + return vi.mocked(request); +} + describe("channels controller WhatsApp wait", () => { beforeEach(() => { vi.clearAllMocks(); @@ -36,7 +47,7 @@ describe("channels controller WhatsApp wait", () => { it("passes the currently displayed QR and replaces it when the login QR rotates", async () => { const state = createState(); - const request = vi.mocked(state.client!.request); + const request = requireClientRequest(state); request.mockResolvedValueOnce({ connected: false, message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.", @@ -76,7 +87,7 @@ describe("loadChannels", () => { ts: 2, }; const deferred = createDeferred(); - const request = vi.mocked(state.client!.request); + const request = requireClientRequest(state); request.mockReturnValueOnce(deferred.promise); state.channelsSnapshot = previous; state.channelsLastSuccess = 10; From 7f5df0b97d2ef6ee8efa13a8811e322891e80580 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:56:24 +0100 Subject: [PATCH 510/806] test: tighten mattermost config assertions --- ...hannel-actions-setup-status.contract.test.ts | 11 ++++++++--- extensions/mattermost/src/doctor.test.ts | 17 +++++++++-------- extensions/mattermost/src/setup.test.ts | 9 +++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts index cb32b3fa034..3ed1863efa9 100644 --- a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts @@ -70,9 +70,14 @@ describe("mattermost setup contract", () => { }, expectedAccountId: "default", assertPatchedConfig: (cfg) => { - expect(cfg.channels?.mattermost?.enabled).toBe(true); - expect(cfg.channels?.mattermost?.botToken).toBe("test-token"); - expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com"); + const mattermostConfig = cfg.channels?.mattermost; + expect(mattermostConfig).toBeDefined(); + if (!mattermostConfig) { + throw new Error("expected Mattermost config patch"); + } + expect(mattermostConfig.enabled).toBe(true); + expect(mattermostConfig.botToken).toBe("test-token"); + expect(mattermostConfig.baseUrl).toBe("https://chat.example.com"); }, }, { diff --git a/extensions/mattermost/src/doctor.test.ts b/extensions/mattermost/src/doctor.test.ts index e5e425d23ad..96ecd7b0357 100644 --- a/extensions/mattermost/src/doctor.test.ts +++ b/extensions/mattermost/src/doctor.test.ts @@ -30,16 +30,17 @@ describe("mattermost doctor", () => { } as never, }); - expect(result.config.channels?.mattermost?.network).toEqual({ + const mattermostConfig = result.config.channels?.mattermost; + expect(mattermostConfig).toBeDefined(); + if (!mattermostConfig) { + throw new Error("expected normalized Mattermost config"); + } + expect(mattermostConfig.network).toEqual({ dangerouslyAllowPrivateNetwork: true, }); - expect( - ( - result.config.channels?.mattermost?.accounts?.work as - | { network?: Record } - | undefined - )?.network, - ).toEqual({ + const workAccount = mattermostConfig.accounts?.work as { network?: Record }; + expect(workAccount).toBeDefined(); + expect(workAccount.network).toEqual({ dangerouslyAllowPrivateNetwork: false, }); }); diff --git a/extensions/mattermost/src/setup.test.ts b/extensions/mattermost/src/setup.test.ts index 231f6a7be96..361abb6b12d 100644 --- a/extensions/mattermost/src/setup.test.ts +++ b/extensions/mattermost/src/setup.test.ts @@ -391,8 +391,13 @@ describe("mattermost setup", () => { ([params]) => (params as { message: string }).message, ); expect(textMessages).toEqual(["Enter Mattermost bot token", "Enter Mattermost base URL"]); - expect(result.cfg.channels?.mattermost?.botToken).toBe("bot-token"); - expect(result.cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com"); + const mattermostConfig = result.cfg.channels?.mattermost; + expect(mattermostConfig).toBeDefined(); + if (!mattermostConfig) { + throw new Error("expected Mattermost config"); + } + expect(mattermostConfig.botToken).toBe("bot-token"); + expect(mattermostConfig.baseUrl).toBe("https://chat.example.com"); expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); }); }); From db883ec26a24a99cd8524af2a71522fc30bb0cd9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:57:27 +0100 Subject: [PATCH 511/806] test: tighten fireworks provider assertions --- extensions/fireworks/index.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/extensions/fireworks/index.test.ts b/extensions/fireworks/index.test.ts index 920fd69a587..52e655f8442 100644 --- a/extensions/fireworks/index.test.ts +++ b/extensions/fireworks/index.test.ts @@ -48,8 +48,12 @@ describe("fireworks provider plugin", () => { expect(provider.aliases).toEqual(["fireworks-ai"]); expect(provider.envVars).toEqual(["FIREWORKS_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved?.provider.id).toBe("fireworks"); - expect(resolved?.method.id).toBe("api-key"); + expect(resolved).toBeDefined(); + if (!resolved) { + throw new Error("expected Fireworks api-key auth choice"); + } + expect(resolved.provider.id).toBe("fireworks"); + expect(resolved.method.id).toBe("api-key"); }); it("builds the Fireworks catalog", async () => { @@ -58,17 +62,22 @@ describe("fireworks provider plugin", () => { expect(catalogProvider.api).toBe("openai-completions"); expect(catalogProvider.baseUrl).toBe(FIREWORKS_BASE_URL); - expect(catalogProvider.models?.map((model) => model.id)).toEqual([ + const models = catalogProvider.models; + expect(models).toBeDefined(); + if (!models) { + throw new Error("expected Fireworks catalog models"); + } + expect(models.map((model) => model.id)).toEqual([ FIREWORKS_K2_6_MODEL_ID, FIREWORKS_DEFAULT_MODEL_ID, ]); - expect(catalogProvider.models?.[0]).toMatchObject({ + expect(models[0]).toMatchObject({ reasoning: false, input: ["text", "image"], contextWindow: FIREWORKS_K2_6_CONTEXT_WINDOW, maxTokens: FIREWORKS_K2_6_MAX_TOKENS, }); - expect(catalogProvider.models?.[1]).toMatchObject({ + expect(models[1]).toMatchObject({ reasoning: false, input: ["text", "image"], contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW, From 45f3ec2ead24fcb0cce378a2f9ba98c538de2711 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:57:30 +0100 Subject: [PATCH 512/806] test: require deferred callbacks --- extensions/feishu/src/sequential-queue.test.ts | 5 ++++- ui/src/ui/controllers/assistant-identity.test.ts | 5 ++++- ui/src/ui/controllers/chat.test.ts | 7 +++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/extensions/feishu/src/sequential-queue.test.ts b/extensions/feishu/src/sequential-queue.test.ts index a0ea3dfd4d6..2f4d6971860 100644 --- a/extensions/feishu/src/sequential-queue.test.ts +++ b/extensions/feishu/src/sequential-queue.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { createSequentialQueue } from "./sequential-queue.js"; function createDeferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts index 2b18d927da4..f67126723ed 100644 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -5,10 +5,13 @@ import { loadLocalAssistantIdentity } from "../storage.ts"; import { loadAssistantIdentity, setAssistantAvatarOverride } from "./assistant-identity.ts"; function createDeferred() { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((next) => { resolve = next; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 85241cc0de8..4c165f4e5a4 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -39,12 +39,15 @@ afterEach(() => { }); function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From b7bdcaeb886b3d77811cf8eb52275b4408f03341 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:58:18 +0100 Subject: [PATCH 513/806] test: tighten web search provider assertions --- .../duckduckgo/src/ddg-search-provider.test.ts | 7 ++++++- extensions/exa/src/exa-web-search-provider.test.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 6ce0880810b..597fa401bbe 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -46,7 +46,12 @@ describe("duckduckgo web search provider", () => { ]); expect(provider.requiresCredential).toBe(false); expect(provider.credentialPath).toBe(""); - expect(applied.plugins?.entries?.duckduckgo?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.duckduckgo; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected DuckDuckGo plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("maps generic tool arguments into DuckDuckGo search params", async () => { diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 5fd9dae255d..cffa03c2baf 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -14,7 +14,12 @@ describe("exa web search provider", () => { expect(provider.id).toBe("exa"); expect(provider.onboardingScopes).toEqual(["text-inference"]); expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey"); - expect(applied.plugins?.entries?.exa?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.exa; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected Exa plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("keeps the lightweight contract surface aligned with provider metadata", () => { @@ -39,7 +44,12 @@ describe("exa web search provider", () => { credentialPath: provider.credentialPath, }); expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull(); - expect(applied.plugins?.entries?.exa?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.exa; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected contract Exa plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("prefers scoped configured api keys over environment fallbacks", () => { From 64510002290f7e5a3e713cbea73fdbe05a39edb1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:58:56 +0100 Subject: [PATCH 514/806] test: tighten discord api request assertion --- extensions/discord/src/api.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 69d8a75bc81..375182c58d0 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -142,8 +142,12 @@ describe("fetchDiscord", () => { }); expect(result).toEqual({ id: "42" }); - expect(request?.method).toBe("POST"); - expect(request?.body).toBe(JSON.stringify({ content: "hello" })); - expect(new Headers(request?.headers).get("content-type")).toBe("application/json"); + expect(request).toBeDefined(); + if (!request) { + throw new Error("expected Discord request init"); + } + expect(request.method).toBe("POST"); + expect(request.body).toBe(JSON.stringify({ content: "hello" })); + expect(new Headers(request.headers).get("content-type")).toBe("application/json"); }); }); From 9c91e174839439fecb5a01781ca459bffc2a8ecf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 18:59:18 +0100 Subject: [PATCH 515/806] test: require plugin async gates --- src/plugins/interactive.test.ts | 10 ++++++++-- src/plugins/runtime.test.ts | 18 +++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 0d180fc0022..6783078b9c0 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -836,10 +836,13 @@ describe("plugin interactive handlers", () => { }); it("dedupes concurrent interactive dispatches while a handler is still running", async () => { - let releaseHandler!: () => void; + let releaseHandler: (() => void) | undefined; const handlerGate = new Promise((resolve) => { releaseHandler = resolve; }); + if (!releaseHandler) { + throw new Error("Expected handler release callback to be initialized"); + } const handler = vi.fn(async () => { await handlerGate; return { handled: true }; @@ -880,10 +883,13 @@ describe("plugin interactive handlers", () => { }); it("releases inflight interactive dedupe keys after a handler failure", async () => { - let rejectHandler!: (error: Error) => void; + let rejectHandler: ((error: Error) => void) | undefined; const handlerGate = new Promise((_, reject) => { rejectHandler = reject; }); + if (!rejectHandler) { + throw new Error("Expected handler reject callback to be initialized"); + } const handler = vi .fn(async () => ({ handled: true })) .mockImplementationOnce(async () => await handlerGate) diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 79db5ed32dc..e43feb19b32 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -233,14 +233,19 @@ describe("setActivePluginRegistry", () => { }, ] as const)("continues cleanup when the $name", async ({ refresh }) => { let releaseFirstCleanup: (() => void) | undefined; - let markFirstCleanupStarted!: () => void; - let markSecondCleanupCalled!: () => void; + let markFirstCleanupStarted: (() => void) | undefined; + let markSecondCleanupCalled: (() => void) | undefined; const firstCleanupStarted = new Promise((resolve) => { markFirstCleanupStarted = resolve; }); const secondCleanupCalled = new Promise((resolve) => { markSecondCleanupCalled = resolve; }); + if (!markFirstCleanupStarted || !markSecondCleanupCalled) { + throw new Error("Expected cleanup signal callbacks to be initialized"); + } + const notifyFirstCleanupStarted = markFirstCleanupStarted; + const notifySecondCleanupCalled = markSecondCleanupCalled; const previous = createEmptyPluginRegistry(); previous.plugins.push( createPluginRecord({ @@ -256,7 +261,7 @@ describe("setActivePluginRegistry", () => { lifecycle: { id: "first-cleanup", async cleanup() { - markFirstCleanupStarted(); + notifyFirstCleanupStarted(); await new Promise((resolve) => { releaseFirstCleanup = resolve; }); @@ -271,7 +276,7 @@ describe("setActivePluginRegistry", () => { lifecycle: { id: "second-cleanup", cleanup() { - markSecondCleanupCalled(); + notifySecondCleanupCalled(); }, }, source: "/virtual/cleanup-refresh-race/index.ts", @@ -285,7 +290,10 @@ describe("setActivePluginRegistry", () => { await waitForCleanupSignal(firstCleanupStarted, "first cleanup start"); refresh(next); - releaseFirstCleanup?.(); + if (!releaseFirstCleanup) { + throw new Error("Expected first cleanup release callback to be initialized"); + } + releaseFirstCleanup(); await waitForCleanupSignal(secondCleanupCalled, "second cleanup"); }); From 429d7238d41f3b446da6d78cab4c04a1080e4756 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 18:59:37 +0100 Subject: [PATCH 516/806] test: tighten discord guild lookup assertion --- extensions/discord/src/resolve-allowlist-common.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/resolve-allowlist-common.test.ts b/extensions/discord/src/resolve-allowlist-common.test.ts index 338fae1bd0d..163a708057c 100644 --- a/extensions/discord/src/resolve-allowlist-common.test.ts +++ b/extensions/discord/src/resolve-allowlist-common.test.ts @@ -13,7 +13,12 @@ describe("resolve-allowlist-common", () => { ]; it("resolves and filters guilds by id or name", () => { - expect(findDiscordGuildByName(guilds, "Main Guild")?.id).toBe("1"); + const mainGuild = findDiscordGuildByName(guilds, "Main Guild"); + expect(mainGuild).toBeDefined(); + if (!mainGuild) { + throw new Error("expected Main Guild lookup result"); + } + expect(mainGuild.id).toBe("1"); expect(filterDiscordGuilds(guilds, { guildId: "2" })).toEqual([guilds[1]]); expect(filterDiscordGuilds(guilds, { guildName: "main-guild" })).toEqual([guilds[0]]); }); From cf8be4adda94ac6e0ab2d7799278778d73b99781 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:00:34 +0100 Subject: [PATCH 517/806] test: tighten chutes model assertions --- extensions/chutes/models.test.ts | 37 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/extensions/chutes/models.test.ts b/extensions/chutes/models.test.ts index be80d1e76e1..be87ac2919e 100644 --- a/extensions/chutes/models.test.ts +++ b/extensions/chutes/models.test.ts @@ -53,6 +53,17 @@ function createAuthEchoFetchMock() { }); } +function requireChutesModel( + models: Awaited>, + index: number, +): Awaited>[number] { + const model = models[index]; + if (!model) { + throw new Error(`expected Chutes model at index ${index}`); + } + return model; +} + describe("chutes-models", () => { beforeEach(() => { clearChutesModelCacheForTests(); @@ -68,7 +79,11 @@ describe("chutes-models", () => { expect(def.cost).toEqual(entry.cost); expect(def.contextWindow).toBe(entry.contextWindow); expect(def.maxTokens).toBe(entry.maxTokens); - expect(def.compat?.supportsUsageInStreaming).toBe(false); + expect(def.compat).toBeDefined(); + if (!def.compat) { + throw new Error("expected Chutes model compat"); + } + expect(def.compat.supportsUsageInStreaming).toBe(false); }); it("discoverChutesModels returns static catalog when accessToken is empty", async () => { @@ -80,7 +95,7 @@ describe("chutes-models", () => { it("discoverChutesModels returns static catalog in test env by default", async () => { const models = await discoverChutesModels("test-token"); expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); - expect(models[0]?.id).toBe("Qwen/Qwen3-32B"); + expect(requireChutesModel(models, 0).id).toBe("Qwen/Qwen3-32B"); }); it("discoverChutesModels correctly maps API response when not in test env", async () => { @@ -105,9 +120,15 @@ describe("chutes-models", () => { const models = await discoverChutesModels("test-token-real-fetch"); expect(models.length).toBeGreaterThan(0); if (models.length === 3) { - expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE"); - expect(models[1]?.reasoning).toBe(true); - expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); + const firstModel = requireChutesModel(models, 0); + const secondModel = requireChutesModel(models, 1); + expect(firstModel.id).toBe("zai-org/GLM-4.7-TEE"); + expect(secondModel.reasoning).toBe(true); + expect(secondModel.compat).toBeDefined(); + if (!secondModel.compat) { + throw new Error("expected Chutes API model compat"); + } + expect(secondModel.compat.supportsUsageInStreaming).toBe(false); } }); }); @@ -208,9 +229,9 @@ describe("chutes-models", () => { const modelsA = await discoverChutesModels("chutes-token-a"); const modelsB = await discoverChutesModels("chutes-token-b"); const modelsASecond = await discoverChutesModels("chutes-token-a"); - expect(modelsA[0]?.id).toBe("private/model-a"); - expect(modelsB[0]?.id).toBe("private/model-b"); - expect(modelsASecond[0]?.id).toBe("private/model-a"); + expect(requireChutesModel(modelsA, 0).id).toBe("private/model-a"); + expect(requireChutesModel(modelsB, 0).id).toBe("private/model-b"); + expect(requireChutesModel(modelsASecond, 0).id).toBe("private/model-a"); expect(mockFetch).toHaveBeenCalledTimes(2); }); }); From 520fe726af1f9feb7a5056d0c7a57cef567d6c3b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:01:28 +0100 Subject: [PATCH 518/806] test: tighten canvas migration assertions --- extensions/canvas/src/config-migration.test.ts | 14 +++++++++++--- extensions/canvas/src/host/server.test.ts | 7 ++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/extensions/canvas/src/config-migration.test.ts b/extensions/canvas/src/config-migration.test.ts index 7040eb04af5..78dde201702 100644 --- a/extensions/canvas/src/config-migration.test.ts +++ b/extensions/canvas/src/config-migration.test.ts @@ -12,8 +12,12 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result?.changes).toEqual(["migrated canvasHost to plugins.entries.canvas.config.host"]); - expect(result?.config).toEqual({ + expect(result).toBeDefined(); + if (!result) { + throw new Error("expected Canvas config migration result"); + } + expect(result.changes).toEqual(["migrated canvasHost to plugins.entries.canvas.config.host"]); + expect(result.config).toEqual({ plugins: { entries: { canvas: { @@ -51,7 +55,11 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result?.config).toEqual({ + expect(result).toBeDefined(); + if (!result) { + throw new Error("expected Canvas config migration result"); + } + expect(result.config).toEqual({ plugins: { entries: { canvas: { diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index d3dc5572840..530f1923533 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -342,7 +342,12 @@ describe("canvas host", () => { Buffer.alloc(0), ); expect(upgraded).toBe(true); - expect(TrackingWebSocketServerClass.latestInstance?.connectionCount).toBe(1); + const latestServer = TrackingWebSocketServerClass.latestInstance; + expect(latestServer).toBeDefined(); + if (!latestServer) { + throw new Error("expected Canvas host websocket server"); + } + expect(latestServer.connectionCount).toBe(1); const ws = TrackingWebSocketServerClass.latestSocket; if (!ws) { throw new Error("expected Canvas host websocket"); From 0df60360e76f9f0c1b923cec6d5c0b7ab61161c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:01:31 +0100 Subject: [PATCH 519/806] test: require queue deferred callbacks --- src/auto-reply/reply/pending-tool-task-drain.test.ts | 7 +++++-- src/plugin-sdk/keyed-async-queue.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/reply/pending-tool-task-drain.test.ts b/src/auto-reply/reply/pending-tool-task-drain.test.ts index ed37dab6492..75695d78c10 100644 --- a/src/auto-reply/reply/pending-tool-task-drain.test.ts +++ b/src/auto-reply/reply/pending-tool-task-drain.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { drainPendingToolTasks } from "./pending-tool-task-drain.js"; function deferredTask() { - let resolve!: () => void; - let reject!: (error: Error) => void; + let resolve: (() => void) | undefined; + let reject: ((error: Error) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred task callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/plugin-sdk/keyed-async-queue.test.ts b/src/plugin-sdk/keyed-async-queue.test.ts index d68cc22a8b9..7047c8240f2 100644 --- a/src/plugin-sdk/keyed-async-queue.test.ts +++ b/src/plugin-sdk/keyed-async-queue.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js"; function deferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From 97fdba06142001b7ae27e9488ef59c96b3bf9d2d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:02:17 +0100 Subject: [PATCH 520/806] test: tighten image prompt assertions --- src/media-understanding/image.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 65a4d700662..6ad71796ac6 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -387,7 +387,12 @@ describe("describeImageWithModel", () => { throw new Error("Expected image completion call"); } const [, context] = firstCall; - expect(context.messages[0]?.content).toHaveLength(1); + const userMessage = context.messages[0]; + expect(userMessage).toBeDefined(); + if (!userMessage) { + throw new Error("expected image completion user message"); + } + expect(userMessage.content).toHaveLength(1); }); it("places OpenRouter image prompts in user content before images", async () => { @@ -432,7 +437,12 @@ describe("describeImageWithModel", () => { } const [, context] = firstCall; expect(context.systemPrompt).toBeUndefined(); - expect(context.messages[0]?.content).toEqual([ + const userMessage = context.messages[0]; + expect(userMessage).toBeDefined(); + if (!userMessage) { + throw new Error("expected OpenRouter image completion user message"); + } + expect(userMessage.content).toEqual([ { type: "text", text: "Describe the image." }, expect.objectContaining({ type: "image", From 9c496467cd994d7b4748dc854fd5d28840e5434b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:02:51 +0100 Subject: [PATCH 521/806] test: require gateway deferred callbacks --- src/gateway/exec-approval-ios-push.test.ts | 7 +++++-- src/gateway/server-model-catalog.test.ts | 7 +++++-- src/gateway/server-restart-deferral.test.ts | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index f291850426b..93b2afa2539 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -15,12 +15,15 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index d25695d1a39..eb5f16497ac 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -17,12 +17,15 @@ type LoadModelCatalogForTest = NonNullable< >; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-restart-deferral.test.ts b/src/gateway/server-restart-deferral.test.ts index 709cd2c504d..042afbee347 100644 --- a/src/gateway/server-restart-deferral.test.ts +++ b/src/gateway/server-restart-deferral.test.ts @@ -13,12 +13,15 @@ async function flushMicrotasks(count = 10): Promise { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From 228c60064f763b7541044e80f4ab7d63ec5def75 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:03:23 +0100 Subject: [PATCH 522/806] test: tighten moonshot video request assertions --- .../media-understanding-provider.test.ts | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/extensions/moonshot/media-understanding-provider.test.ts b/extensions/moonshot/media-understanding-provider.test.ts index 85b90068d4a..879a935be42 100644 --- a/extensions/moonshot/media-understanding-provider.test.ts +++ b/extensions/moonshot/media-understanding-provider.test.ts @@ -28,27 +28,49 @@ describe("describeMoonshotVideo", () => { expect(result.text).toBe("video ok"); expect(result.model).toBe("kimi-k2.6"); expect(url).toBe("https://api.moonshot.ai/v1/chat/completions"); - expect(init?.method).toBe("POST"); - expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init).toBeDefined(); + if (!init) { + throw new Error("expected Moonshot request init"); + } + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(init?.headers); + const headers = new Headers(init.headers); expect(headers.get("authorization")).toBe("Bearer moonshot-test"); expect(headers.get("content-type")).toBe("application/json"); expect(headers.get("x-trace")).toBe("1"); - const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as { + expect(init.body).toBeTypeOf("string"); + if (typeof init.body !== "string") { + throw new Error("expected Moonshot JSON request body"); + } + const body = JSON.parse(init.body) as { model?: string; messages?: Array<{ content?: Array<{ type?: string; text?: string; video_url?: { url?: string } }>; }>; }; expect(body.model).toBe("kimi-k2.6"); - expect(body.messages?.[0]?.content?.[0]).toMatchObject({ + const content = body.messages?.[0]?.content; + expect(content).toBeDefined(); + if (!content) { + throw new Error("expected Moonshot user content"); + } + expect(content[0]).toMatchObject({ type: "text", text: "Describe the video.", }); - expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url"); - expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe( + const videoContent = content[1]; + expect(videoContent).toBeDefined(); + if (!videoContent) { + throw new Error("expected Moonshot video content"); + } + expect(videoContent.type).toBe("video_url"); + expect(videoContent.video_url).toBeDefined(); + if (!videoContent.video_url) { + throw new Error("expected Moonshot video URL payload"); + } + expect(videoContent.video_url.url).toBe( `data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`, ); }); From 8da80d57dad8fd948319caee36967302615ae807 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:04:19 +0100 Subject: [PATCH 523/806] test: tighten cloudflare gateway assertions --- .../cloudflare-ai-gateway/index.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/extensions/cloudflare-ai-gateway/index.test.ts b/extensions/cloudflare-ai-gateway/index.test.ts index 3c8b46b97de..cb06a205f42 100644 --- a/extensions/cloudflare-ai-gateway/index.test.ts +++ b/extensions/cloudflare-ai-gateway/index.test.ts @@ -6,14 +6,21 @@ import plugin from "./index.js"; function registerProvider() { const captured = capturePluginRegistration(plugin); const provider = captured.providers[0]; - expect(provider?.id).toBe("cloudflare-ai-gateway"); + expect(provider).toBeDefined(); + if (!provider) { + throw new Error("expected Cloudflare AI Gateway provider"); + } + expect(provider.id).toBe("cloudflare-ai-gateway"); return provider; } describe("cloudflare-ai-gateway plugin", () => { it("registers a stream wrapper that strips Anthropic thinking assistant prefill", () => { const provider = registerProvider(); - expect(provider?.wrapStreamFn).toBeTypeOf("function"); + expect(provider.wrapStreamFn).toBeTypeOf("function"); + if (!provider.wrapStreamFn) { + throw new Error("expected Cloudflare AI Gateway stream wrapper"); + } let capturedPayload: Record | undefined; const baseStreamFn: StreamFn = (_model, _context, options) => { @@ -29,19 +36,23 @@ describe("cloudflare-ai-gateway plugin", () => { return {} as ReturnType; }; - const wrapped = provider?.wrapStreamFn?.({ + const wrapped = provider.wrapStreamFn({ provider: "cloudflare-ai-gateway", modelId: "claude-sonnet-4-6", model: { api: "anthropic-messages" }, streamFn: baseStreamFn, } as never); - void wrapped?.( + void wrapped( { provider: "cloudflare-ai-gateway", api: "anthropic-messages" } as never, {} as never, {}, ); - expect(capturedPayload?.messages).toEqual([{ role: "user", content: "Return JSON." }]); + expect(capturedPayload).toBeDefined(); + if (!capturedPayload) { + throw new Error("expected Cloudflare AI Gateway payload capture"); + } + expect(capturedPayload.messages).toEqual([{ role: "user", content: "Return JSON." }]); }); }); From b71312d7d5392047e7659688a62a5a340547ba58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:04:42 +0100 Subject: [PATCH 524/806] test: require signal deferred callbacks --- extensions/matrix/src/matrix/client/file-sync-store.test.ts | 5 ++++- extensions/telegram/src/bot-update-tracker.test.ts | 5 ++++- extensions/telegram/src/bot.command-menu.test.ts | 5 ++++- src/channels/typing.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index a5b7053f94a..6c8acebed96 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -52,10 +52,13 @@ function createSyncResponse(nextBatch: string): ISyncResponse { } function createDeferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/telegram/src/bot-update-tracker.test.ts b/extensions/telegram/src/bot-update-tracker.test.ts index 2352bdf1f8a..7c8d24080d3 100644 --- a/extensions/telegram/src/bot-update-tracker.test.ts +++ b/extensions/telegram/src/bot-update-tracker.test.ts @@ -15,10 +15,13 @@ async function flushTrackerMicrotasks() { } function deferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + if (!resolve) { + throw new Error("Expected tracker deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts index 926e425972c..b19c3de45fc 100644 --- a/extensions/telegram/src/bot.command-menu.test.ts +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -23,10 +23,13 @@ let createTelegramBot: ( const loadConfig = getLoadConfigMock(); function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected command sync signal resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index d8ddae0a592..7f78c9e2eef 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -73,7 +73,7 @@ describe("createTypingCallbacks", () => { }); it("does not block reply start on a pending typing request", async () => { - let resolveStart!: () => void; + let resolveStart: (() => void) | undefined; const { start, callbacks } = createTypingHarness({ start: vi.fn( () => @@ -86,6 +86,9 @@ describe("createTypingCallbacks", () => { await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); + if (!resolveStart) { + throw new Error("Expected typing start resolver to be initialized"); + } resolveStart(); }); From f2c917d104b0fcfdcdb8bb6e32d580f3aaea16b0 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:05:03 +0100 Subject: [PATCH 525/806] test: tighten firecrawl config assertion --- extensions/firecrawl/src/firecrawl-tools.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index a494e699614..b2b4e801966 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -82,7 +82,12 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webSearch.apiKey"); - expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.firecrawl; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected Firecrawl plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("parses scrape payloads into wrapped external-content results", () => { From bdec274079b155529830317177c00505d1f79ec3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:05:45 +0100 Subject: [PATCH 526/806] test: tighten mattermost slash command assertion --- .../mattermost/src/mattermost/slash-commands.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts index fa356afdb23..2e3fb09f192 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.test.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -130,8 +130,13 @@ describe("slash-commands", () => { const result = await registerSingleStatusCommand(request); expect(result).toHaveLength(1); - expect(result[0]?.managed).toBe(false); - expect(result[0]?.id).toBe("cmd-1"); + const firstCommand = result[0]; + expect(firstCommand).toBeDefined(); + if (!firstCommand) { + throw new Error("expected Mattermost slash command result"); + } + expect(firstCommand.managed).toBe(false); + expect(firstCommand.id).toBe("cmd-1"); expect(request).toHaveBeenCalledTimes(1); }); From d0c18501528dbc6335ebba81a6bd90243a710e81 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:06:37 +0100 Subject: [PATCH 527/806] test: tighten qqbot queue assertions --- .../src/engine/gateway/message-queue.test.ts | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts index 5e56dd9a4b8..77277430ae2 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.test.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.test.ts @@ -14,6 +14,14 @@ function groupMsg(overrides: Partial = {}): QueuedMessage { }; } +function requireMergeMetadata(message: QueuedMessage): NonNullable { + expect(message.merge).toBeDefined(); + if (!message.merge) { + throw new Error("expected QQBot merged message metadata"); + } + return message.merge; +} + describe("engine/gateway/message-queue", () => { describe("mergeGroupMessages", () => { it("returns the single message unchanged", () => { @@ -28,8 +36,9 @@ describe("engine/gateway/message-queue", () => { groupMsg({ senderName: "B", content: "yo" }), ]); expect(merged.content).toBe("[A]: hi\n[B]: yo"); - expect(merged.merge?.count).toBe(2); - expect(merged.merge?.messages).toHaveLength(2); + const merge = requireMergeMetadata(merged); + expect(merge.count).toBe(2); + expect(merge.messages).toHaveLength(2); }); it("takes messageId / msgIdx / timestamp from the last message", () => { @@ -62,7 +71,11 @@ describe("engine/gateway/message-queue", () => { ], }), ]); - expect(merged.attachments?.map((a) => a.url)).toEqual(["a", "b", "c"]); + expect(merged.attachments).toBeDefined(); + if (!merged.attachments) { + throw new Error("expected QQBot merged attachments"); + } + expect(merged.attachments.map((a) => a.url)).toEqual(["a", "b", "c"]); }); it("deduplicates mentions by member/user openid", () => { @@ -70,7 +83,11 @@ describe("engine/gateway/message-queue", () => { groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Y" }] }), groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Z" }] }), ]); - expect(merged.mentions?.map((m) => m.member_openid)).toEqual(["X", "Y", "Z"]); + expect(merged.mentions).toBeDefined(); + if (!merged.mentions) { + throw new Error("expected QQBot merged mentions"); + } + expect(merged.mentions.map((m) => m.member_openid)).toEqual(["X", "Y", "Z"]); }); it("flags merged turn as @bot when ANY source was GROUP_AT_MESSAGE_CREATE", () => { @@ -197,7 +214,9 @@ describe("engine/gateway/message-queue", () => { // varies; we only assert the bot message id never appeared.) const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1); if (mergedCall) { - expect(mergedCall.merge?.messages.map((m) => m.messageId)).not.toContain("B1"); + expect(requireMergeMetadata(mergedCall).messages.map((m) => m.messageId)).not.toContain( + "B1", + ); } else { expect(seenIds).not.toContain("B1"); } From b5c8f6dd01143f55f55adc475f6dae1c451442fb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:07:26 +0100 Subject: [PATCH 528/806] test: tighten inworld directive assertions --- extensions/inworld/speech-provider.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/extensions/inworld/speech-provider.test.ts b/extensions/inworld/speech-provider.test.ts index 3fd8437fd65..cd79f58b3c5 100644 --- a/extensions/inworld/speech-provider.test.ts +++ b/extensions/inworld/speech-provider.test.ts @@ -111,12 +111,18 @@ describe("buildInworldSpeechProvider", () => { allowSeed: true, }; - expect(provider.parseDirectiveToken?.({ key: "voice", value: "Ashley", policy })).toEqual({ + const parseDirectiveToken = provider.parseDirectiveToken; + expect(parseDirectiveToken).toBeTypeOf("function"); + if (!parseDirectiveToken) { + throw new Error("expected Inworld directive parser"); + } + + expect(parseDirectiveToken({ key: "voice", value: "Ashley", policy })).toEqual({ handled: true, overrides: { voiceId: "Ashley" }, }); expect( - provider.parseDirectiveToken?.({ + parseDirectiveToken({ key: "model", value: "inworld-tts-1.5-mini", policy, @@ -125,7 +131,7 @@ describe("buildInworldSpeechProvider", () => { handled: true, overrides: { modelId: "inworld-tts-1.5-mini" }, }); - expect(provider.parseDirectiveToken?.({ key: "temperature", value: "0.7", policy })).toEqual({ + expect(parseDirectiveToken({ key: "temperature", value: "0.7", policy })).toEqual({ handled: true, overrides: { temperature: 0.7 }, }); From 8100984e0e89b213ddce879a8d967d30d9d8aa43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:07:11 +0100 Subject: [PATCH 529/806] test: require extension async gates --- .../cloudflare-ai-gateway/index.test.ts | 4 ++++ extensions/feishu/src/monitor.startup.test.ts | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/extensions/cloudflare-ai-gateway/index.test.ts b/extensions/cloudflare-ai-gateway/index.test.ts index cb06a205f42..e4f0733e8ff 100644 --- a/extensions/cloudflare-ai-gateway/index.test.ts +++ b/extensions/cloudflare-ai-gateway/index.test.ts @@ -42,6 +42,10 @@ describe("cloudflare-ai-gateway plugin", () => { model: { api: "anthropic-messages" }, streamFn: baseStreamFn, } as never); + expect(wrapped).toBeTypeOf("function"); + if (!wrapped) { + throw new Error("expected Cloudflare AI Gateway wrapped stream function"); + } void wrapped( { provider: "cloudflare-ai-gateway", api: "anthropic-messages" } as never, diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index c8f2bdc6b7b..3e7d0cf1b84 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -64,10 +64,14 @@ describe("Feishu monitor startup preflight", () => { let inFlight = 0; let maxInFlight = 0; const started: string[] = []; - let releaseProbes!: () => void; + let releaseProbes: (() => void) | undefined; const probesReleased = new Promise((resolve) => { releaseProbes = () => resolve(); }); + if (!releaseProbes) { + throw new Error("Expected probe release callback to be initialized"); + } + const releaseStartedProbes = releaseProbes; probeFeishuMock.mockImplementation(async (account: { accountId: string }) => { started.push(account.accountId); inFlight += 1; @@ -88,7 +92,7 @@ describe("Feishu monitor startup preflight", () => { expect(started).toEqual(["alpha"]); expect(maxInFlight).toBe(1); } finally { - releaseProbes(); + releaseStartedProbes(); abortController.abort(); await monitorPromise; } @@ -96,10 +100,14 @@ describe("Feishu monitor startup preflight", () => { it("does not refetch bot info after a failed sequential preflight", async () => { const started: string[] = []; - let releaseBetaProbe!: () => void; + let releaseBetaProbe: (() => void) | undefined; const betaProbeReleased = new Promise((resolve) => { releaseBetaProbe = () => resolve(); }); + if (!releaseBetaProbe) { + throw new Error("Expected beta probe release callback to be initialized"); + } + const releaseStartedBetaProbe = releaseBetaProbe; probeFeishuMock.mockImplementation(async (account: { accountId: string }) => { started.push(account.accountId); @@ -121,7 +129,7 @@ describe("Feishu monitor startup preflight", () => { expect(started).toEqual(["alpha", "beta"]); expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1); } finally { - releaseBetaProbe(); + releaseStartedBetaProbe(); abortController.abort(); await monitorPromise; } @@ -129,10 +137,14 @@ describe("Feishu monitor startup preflight", () => { it("continues startup when probe layer reports timeout", async () => { const started: string[] = []; - let releaseBetaProbe!: () => void; + let releaseBetaProbe: (() => void) | undefined; const betaProbeReleased = new Promise((resolve) => { releaseBetaProbe = () => resolve(); }); + if (!releaseBetaProbe) { + throw new Error("Expected beta probe release callback to be initialized"); + } + const releaseStartedBetaProbe = releaseBetaProbe; probeFeishuMock.mockImplementation((account: { accountId: string }) => { started.push(account.accountId); @@ -157,7 +169,7 @@ describe("Feishu monitor startup preflight", () => { expect.stringContaining("bot info probe timed out"), ); } finally { - releaseBetaProbe(); + releaseStartedBetaProbe(); abortController.abort(); await monitorPromise; } From d65098e89ffc3f7b262976c83eb1c80add2683de Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:08:11 +0100 Subject: [PATCH 530/806] test: tighten inworld tts request assertion --- extensions/inworld/tts.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/inworld/tts.test.ts b/extensions/inworld/tts.test.ts index 22e71e3233f..f901d19a489 100644 --- a/extensions/inworld/tts.test.ts +++ b/extensions/inworld/tts.test.ts @@ -266,8 +266,12 @@ describe("inworldTTS", () => { expect(request.url).toBe("https://api.inworld.ai/tts/v1/voice:stream"); expect(request.auditContext).toBe("inworld-tts"); expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] }); - expect(request.init?.method).toBe("POST"); - const headers = new Headers(request.init?.headers); + expect(request.init).toBeDefined(); + if (!request.init) { + throw new Error("expected Inworld TTS request init"); + } + expect(request.init.method).toBe("POST"); + const headers = new Headers(request.init.headers); expect(headers.get("authorization")).toBe("Basic test-key"); expect(headers.get("content-type")).toBe("application/json"); expect(JSON.parse(readRequestBody(request))).toEqual({ From ab6e4963179ecefc5e2ee9c3a5e9efc56fef5ffe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:09:09 +0100 Subject: [PATCH 531/806] test: require memory deferred callbacks --- extensions/memory-core/src/dreaming-narrative.test.ts | 5 ++++- extensions/memory-core/src/memory/manager-cache.test.ts | 7 +++++-- extensions/memory-core/src/memory/qmd-manager.test.ts | 7 +++++-- extensions/memory-core/src/memory/search-manager.test.ts | 7 +++++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index 9708e5b434b..e929e84625a 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -1021,10 +1021,13 @@ describe("generateAndAppendDreamNarrative", () => { describe("runDetachedDreamNarrative", () => { type Deferred = { promise: Promise; resolve: (v: T) => void }; function deferred(): Deferred { - let resolve!: (v: T) => void; + let resolve: ((v: T) => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected dream narrative deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/memory-core/src/memory/manager-cache.test.ts b/extensions/memory-core/src/memory/manager-cache.test.ts index af28895712d..6263c3b32c0 100644 --- a/extensions/memory-core/src/memory/manager-cache.test.ts +++ b/extensions/memory-core/src/memory/manager-cache.test.ts @@ -23,12 +23,15 @@ function createEntry(id: string): TestEntry { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 76dd3e0edcf..6baee0c0d77 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -4995,11 +4995,14 @@ describe("QmdMemoryManager", () => { }); function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 6b84d11fb1c..36a2691c5c8 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -180,12 +180,15 @@ function requireManager(result: SearchManagerResult): SearchManager { } function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From 25cac635630ce4c53db7be15f0784f745f9d7906 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:09:28 +0100 Subject: [PATCH 532/806] test: tighten moonshot catalog assertions --- extensions/moonshot/provider-catalog.test.ts | 43 +++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/extensions/moonshot/provider-catalog.test.ts b/extensions/moonshot/provider-catalog.test.ts index f4a65b4d042..d1e69ae2c89 100644 --- a/extensions/moonshot/provider-catalog.test.ts +++ b/extensions/moonshot/provider-catalog.test.ts @@ -6,6 +6,33 @@ import { MOONSHOT_CN_BASE_URL, } from "./api.js"; +type MoonshotProvider = ReturnType; +type MoonshotModel = MoonshotProvider["models"][number]; + +function requireMoonshotModel(provider: MoonshotProvider, modelId: string): MoonshotModel { + const model = provider.models.find((candidate) => candidate.id === modelId); + if (!model) { + throw new Error(`expected Moonshot model ${modelId}`); + } + return model; +} + +function requireFirstMoonshotModel(provider: MoonshotProvider): MoonshotModel { + const model = provider.models[0]; + if (!model) { + throw new Error("expected first Moonshot model"); + } + return model; +} + +function requireMoonshotCompat(model: MoonshotModel): NonNullable { + expect(model.compat).toBeDefined(); + if (!model.compat) { + throw new Error(`expected Moonshot model ${model.id} compat`); + } + return model.compat; +} + describe("moonshot provider catalog", () => { it("builds the bundled Moonshot provider defaults", () => { const provider = buildMoonshotProvider(); @@ -19,13 +46,13 @@ describe("moonshot provider catalog", () => { "kimi-k2-thinking-turbo", "kimi-k2-turbo", ]); - expect(provider.models.find((model) => model.id === "kimi-k2.6")?.cost).toEqual({ + expect(requireMoonshotModel(provider, "kimi-k2.6").cost).toEqual({ input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0, }); - expect(provider.models.find((model) => model.id === "kimi-k2.5")?.cost).toEqual({ + expect(requireMoonshotModel(provider, "kimi-k2.5").cost).toEqual({ input: 0.6, output: 3, cacheRead: 0.1, @@ -35,18 +62,24 @@ describe("moonshot provider catalog", () => { it("opts native Moonshot baseUrls into streaming usage only inside the extension", () => { const defaultProvider = applyMoonshotNativeStreamingUsageCompat(buildMoonshotProvider()); - expect(defaultProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + expect( + requireMoonshotCompat(requireFirstMoonshotModel(defaultProvider)).supportsUsageInStreaming, + ).toBe(true); const cnProvider = applyMoonshotNativeStreamingUsageCompat({ ...buildMoonshotProvider(), baseUrl: MOONSHOT_CN_BASE_URL, }); - expect(cnProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + expect( + requireMoonshotCompat(requireFirstMoonshotModel(cnProvider)).supportsUsageInStreaming, + ).toBe(true); const customProvider = applyMoonshotNativeStreamingUsageCompat({ ...buildMoonshotProvider(), baseUrl: "https://proxy.example.com/v1", }); - expect(customProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + expect( + "supportsUsageInStreaming" in (requireFirstMoonshotModel(customProvider).compat ?? {}), + ).toBe(false); }); }); From 873e26adbbca1686d3a3e6e59ea92e4f0165810c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:10:29 +0100 Subject: [PATCH 533/806] test: tighten synology chat tls assertions --- extensions/synology-chat/src/client.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index d5c55db21bb..37bf9cd26a4 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -121,7 +121,12 @@ describe("Synology Chat TLS verification defaults", () => { mockSuccessResponse(); await settleTimers(invoke()); const httpsRequest = vi.mocked(https.request); - expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true }); + const firstCall = httpsRequest.mock.calls[0]; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS request"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: true }); }); }); @@ -153,7 +158,12 @@ describe("sendMessage", () => { mockSuccessResponse(); await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", undefined, true)); const httpsRequest = vi.mocked(https.request); - expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: false }); + const firstCall = httpsRequest.mock.calls[0]; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS request"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: false }); }); }); @@ -376,6 +386,11 @@ describe("fetchChatUsers", () => { await fetchChatUsers(freshUrl); const httpsGet = vi.mocked(https.get); - expect(httpsGet.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true }); + const firstCall = httpsGet.mock.calls[0]; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS get"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: true }); }); }); From a91267c1d991d4ff025228f9e43b8739432028ed Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:11:50 +0100 Subject: [PATCH 534/806] test: tighten mattermost client request assertion --- extensions/mattermost/src/mattermost/client.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts index 3aa3c3ebe92..c280fcfd903 100644 --- a/extensions/mattermost/src/mattermost/client.test.ts +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -276,8 +276,17 @@ describe("updateMattermostPost", () => { it("sends PUT to /posts/{id}", async () => { const { calls } = await updatePostAndCapture({ message: "Updated" }); - expect(calls[0].url).toContain("/posts/post1"); - expect(calls[0].init?.method).toBe("PUT"); + const firstCall = calls[0]; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected Mattermost update post request"); + } + expect(firstCall.url).toContain("/posts/post1"); + expect(firstCall.init).toBeDefined(); + if (!firstCall.init) { + throw new Error("expected Mattermost update post request init"); + } + expect(firstCall.init.method).toBe("PUT"); }); it("includes post id in the body", async () => { From c8d7db55addc244ac0588f8e7fd829ceee754e1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:11:44 +0100 Subject: [PATCH 535/806] test: require voice-call async gates --- .../voice-call/src/media-stream.test.ts | 12 ++++++-- extensions/voice-call/src/webhook.test.ts | 30 ++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index 18eaf5113c4..1a6a194a255 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -40,12 +40,15 @@ const createDeferred = (): { resolve: () => void; reject: (error: Error) => void; } => { - let resolve!: () => void; - let reject!: (error: Error) => void; + let resolve: (() => void) | undefined; + let reject: ((error: Error) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; @@ -80,10 +83,13 @@ describe("MediaStreamHandler TTS queue", () => { const started: number[] = []; const finished: number[] = []; - let resolveFirst!: () => void; + let resolveFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { resolveFirst = resolve; }); + if (!resolveFirst) { + throw new Error("Expected first TTS gate resolver to be initialized"); + } const first = handler.queueTts("stream-1", async () => { started.push(1); diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 83254d182fd..6792d366829 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -1081,14 +1081,19 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { const server = new VoiceCallWebhookServer(config, manager, twilioProvider); let enteredReads = 0; - let releaseReads!: () => void; - let unblockReadBodies!: () => void; + let releaseReads: (() => void) | undefined; + let unblockReadBodies: (() => void) | undefined; const enteredEightReads = new Promise((resolve) => { releaseReads = resolve; }); const unblockReads = new Promise((resolve) => { unblockReadBodies = resolve; }); + if (!releaseReads || !unblockReadBodies) { + throw new Error("Expected webhook read gates to be initialized"); + } + const releaseEnteredReads = releaseReads; + const unblockStartedReads = unblockReadBodies; const readBodySpy = vi.spyOn( server as unknown as { readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; @@ -1098,7 +1103,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { readBodySpy.mockImplementation(async () => { enteredReads += 1; if (enteredReads === 8) { - releaseReads(); + releaseEnteredReads(); } if (enteredReads <= 8) { await unblockReads; @@ -1118,12 +1123,12 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { expect(rejected.status).toBe(429); expect(await rejected.text()).toBe("Too Many Requests"); - unblockReadBodies(); + unblockStartedReads(); const settled = await Promise.all(inFlightRequests); expect(settled.map((response) => response.status)).toEqual(Array(8).fill(200)); } finally { - unblockReadBodies(); + unblockStartedReads(); readBodySpy.mockRestore(); await server.stop(); } @@ -1148,14 +1153,19 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { ).runWebhookPipeline.bind(server); let enteredReads = 0; - let releaseReads!: () => void; - let unblockReadBodies!: () => void; + let releaseReads: (() => void) | undefined; + let unblockReadBodies: (() => void) | undefined; const enteredEightReads = new Promise((resolve) => { releaseReads = resolve; }); const unblockReads = new Promise((resolve) => { unblockReadBodies = resolve; }); + if (!releaseReads || !unblockReadBodies) { + throw new Error("Expected webhook read gates to be initialized"); + } + const releaseEnteredReads = releaseReads; + const unblockStartedReads = unblockReadBodies; const readBodySpy = vi.spyOn( server as unknown as { readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; @@ -1165,7 +1175,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { readBodySpy.mockImplementation(async () => { enteredReads += 1; if (enteredReads === 8) { - releaseReads(); + releaseEnteredReads(); } await unblockReads; return "CallSid=CA123&SpeechResult=hello"; @@ -1193,12 +1203,12 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { expect(rejected.body).toBe("Too Many Requests"); expect(readBodySpy).toHaveBeenCalledTimes(8); - unblockReadBodies(); + unblockStartedReads(); const settled = await Promise.all(inFlightRequests); expect(settled.map((response) => response.statusCode)).toEqual(Array(8).fill(200)); } finally { - unblockReadBodies(); + unblockStartedReads(); readBodySpy.mockRestore(); } }); From b332b7dff72905cb0cdb779d8475b6ce8341dea6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:13:38 +0100 Subject: [PATCH 536/806] test: tighten zalo api request assertions --- extensions/zalo/src/api.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts index cdcef5a2dfb..919d6f0b4b7 100644 --- a/extensions/zalo/src/api.test.ts +++ b/extensions/zalo/src/api.test.ts @@ -18,8 +18,12 @@ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => await run("test-token", fetcher); expect(fetcher).toHaveBeenCalledTimes(1); const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.method).toBe("POST"); - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + expect(init).toBeDefined(); + if (!init) { + throw new Error("expected Zalo request init"); + } + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ "Content-Type": "application/json" }); } describe("Zalo API request methods", () => { @@ -67,7 +71,15 @@ describe("Zalo API request methods", () => { await rejected; const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.signal?.aborted).toBe(true); + expect(init).toBeDefined(); + if (!init) { + throw new Error("expected Zalo chat action request init"); + } + expect(init.signal).toBeDefined(); + if (!init.signal) { + throw new Error("expected Zalo chat action abort signal"); + } + expect(init.signal.aborted).toBe(true); } finally { vi.useRealTimers(); } From 71a20422a0d2a663ce3bb4fd162bd877003b7131 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:14:39 +0100 Subject: [PATCH 537/806] test: tighten zalo setup assertions --- extensions/zalo/src/setup-surface.test.ts | 39 ++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index baa75346eb1..856963f292b 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -60,9 +60,14 @@ describe("zalo setup wizard", () => { }); expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalo?.enabled).toBe(true); - expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz"); - expect(result.cfg.channels?.zalo?.webhookUrl).toBeUndefined(); + const zaloConfig = result.cfg.channels?.zalo; + expect(zaloConfig).toBeDefined(); + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.enabled).toBe(true); + expect(zaloConfig.botToken).toBe("12345689:abc-xyz"); + expect(zaloConfig.webhookUrl).toBeUndefined(); }); it("reads the named-account DM policy instead of the channel root", () => { @@ -117,11 +122,20 @@ describe("zalo setup wizard", () => { }); const next = zaloDmPolicy.setPolicy(cfg, "open"); - expect(next.channels?.zalo?.dmPolicy).toBe("disabled"); + const zaloConfig = next.channels?.zalo; + expect(zaloConfig).toBeDefined(); + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.dmPolicy).toBe("disabled"); const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount?.dmPolicy).toBe("open"); + expect(workAccount).toBeDefined(); + if (!workAccount) { + throw new Error("expected Zalo work account"); + } + expect(workAccount.dmPolicy).toBe("open"); }); it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => { @@ -142,12 +156,21 @@ describe("zalo setup wizard", () => { "work", ); - expect(next.channels?.zalo?.dmPolicy).toBeUndefined(); + const zaloConfig = next.channels?.zalo; + expect(zaloConfig).toBeDefined(); + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.dmPolicy).toBeUndefined(); const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount?.dmPolicy).toBe("open"); - expect(workAccount?.allowFrom).toEqual(["123456789", "*"]); + expect(workAccount).toBeDefined(); + if (!workAccount) { + throw new Error("expected Zalo work account"); + } + expect(workAccount.dmPolicy).toBe("open"); + expect(workAccount.allowFrom).toEqual(["123456789", "*"]); }); it("uses configured defaultAccount for omitted setup configured state", async () => { From 076526b5c0ede42db57d8fc58080561719dcc3f7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:15:27 +0100 Subject: [PATCH 538/806] test: tighten zalouser setup assertions --- extensions/zalouser/src/setup-surface.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 846fd0a3f55..79e0955889e 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -32,10 +32,20 @@ describe("zalouser setup wizard", () => { dmPolicy?: "pairing" | "allowlist", ) { expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalouser?.enabled).toBe(true); - expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + const channelConfig = result.cfg.channels?.zalouser; + expect(channelConfig).toBeDefined(); + if (!channelConfig) { + throw new Error("expected Zalo Personal channel config"); + } + const pluginEntry = result.cfg.plugins?.entries?.zalouser; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected Zalo Personal plugin entry"); + } + expect(channelConfig.enabled).toBe(true); + expect(pluginEntry.enabled).toBe(true); if (dmPolicy) { - expect(result.cfg.channels?.zalouser?.dmPolicy).toBe(dmPolicy); + expect(channelConfig.dmPolicy).toBe(dmPolicy); } } @@ -98,9 +108,7 @@ describe("zalouser setup wizard", () => { const result = await runSetup({ prompter }); - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalouser?.enabled).toBe(true); - expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expectEnabledDefaultSetup(result); }); it("prompts DM policy before group access in quickstart", async () => { From d642cce5aee4391ca1599decbd98037e6638658e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:15:33 +0100 Subject: [PATCH 539/806] test: require ui deferred callbacks --- ui/src/ui/app-chat.test.ts | 7 +- ui/src/ui/app-lifecycle-connect.node.test.ts | 31 +++++---- ...p-settings.refresh-active-tab.node.test.ts | 69 ++++++++----------- ui/src/ui/gateway.node.test.ts | 22 +++--- 4 files changed, 65 insertions(+), 64 deletions(-) diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 369495d7e4a..6c6459d1b05 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -133,12 +133,15 @@ function row(key: string, overrides?: Partial): GatewaySessio } function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/ui/src/ui/app-lifecycle-connect.node.test.ts b/ui/src/ui/app-lifecycle-connect.node.test.ts index c49adc81a5b..e77f56f5d47 100644 --- a/ui/src/ui/app-lifecycle-connect.node.test.ts +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -41,6 +41,17 @@ vi.mock("./app-scroll.ts", () => ({ import { handleConnected } from "./app-lifecycle.ts"; +function createDeferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected bootstrap deferred resolver to be initialized"); + } + return { promise, resolve }; +} + function createHost() { return { basePath: "", @@ -77,30 +88,22 @@ describe("handleConnected", () => { }); it("waits for bootstrap load before first gateway connect", async () => { - let resolveBootstrap!: () => void; - loadBootstrapMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveBootstrap = resolve; - }), - ); + const bootstrap = createDeferred(); + loadBootstrapMock.mockReturnValueOnce(bootstrap.promise); connectGatewayMock.mockReset(); const host = createHost(); handleConnected(host as never); expect(connectGatewayMock).not.toHaveBeenCalled(); - resolveBootstrap(); + bootstrap.resolve(); await Promise.resolve(); expect(connectGatewayMock).toHaveBeenCalledTimes(1); }); it("skips deferred connect when disconnected before bootstrap resolves", async () => { - let resolveBootstrap!: () => void; - loadBootstrapMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveBootstrap = resolve; - }), - ); + const bootstrap = createDeferred(); + loadBootstrapMock.mockReturnValueOnce(bootstrap.promise); connectGatewayMock.mockReset(); const host = createHost(); @@ -108,7 +111,7 @@ describe("handleConnected", () => { expect(connectGatewayMock).not.toHaveBeenCalled(); host.connectGeneration += 1; - resolveBootstrap(); + bootstrap.resolve(); await Promise.resolve(); expect(connectGatewayMock).not.toHaveBeenCalled(); diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 8038adb5af2..d05829b9a3a 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -3,6 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; type CronRunsLoadStatus = "ok" | "error" | "skipped"; +function createDeferred() { + let resolve: ((value: T | PromiseLike) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } + return { promise, resolve }; +} + const mocks = vi.hoisted(() => ({ refreshChatMock: vi.fn(async () => {}), scheduleChatScrollMock: vi.fn(), @@ -196,12 +207,8 @@ describe("refreshActiveTab", () => { it("records tab visible timing without waiting for the tab refresh RPC", async () => { const host = createHost(); host.tab = "chat"; - let resolveSessions!: () => void; - mocks.loadSessionsMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSessions = resolve; - }), - ); + const sessions = createDeferred(); + mocks.loadSessionsMock.mockReturnValueOnce(sessions.promise); setTab(host as never, "sessions"); @@ -221,7 +228,7 @@ describe("refreshActiveTab", () => { ); }); - resolveSessions(); + sessions.resolve(); }); it("does not wait for secondary overview refreshes before resolving", async () => { @@ -244,12 +251,8 @@ describe("refreshActiveTab", () => { it("does not wait for config schema before resolving config tab refresh", async () => { const host = createHost(); host.tab = "config"; - let resolveSchema!: () => void; - mocks.loadConfigSchemaMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSchema = resolve; - }), - ); + const schema = createDeferred(); + mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); const refresh = refreshActiveTab(host as never); const outcome = await Promise.race([ @@ -262,7 +265,7 @@ describe("refreshActiveTab", () => { expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); expect(host.requestUpdate).not.toHaveBeenCalled(); - resolveSchema(); + schema.resolve(); await vi.waitFor(() => { expect(host.requestUpdate).toHaveBeenCalledOnce(); @@ -272,18 +275,12 @@ describe("refreshActiveTab", () => { it("renders channels from the cheap snapshot before starting slow probes", async () => { const host = createHost(); host.tab = "channels"; - let resolveSchema!: () => void; - let resolveProbe!: () => void; - mocks.loadConfigSchemaMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSchema = resolve; - }), - ); + const schema = createDeferred(); + const channelProbe = createDeferred(); + mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); mocks.loadChannelsMock.mockImplementation(async (_host, probe) => { if (probe) { - await new Promise((resolve) => { - resolveProbe = resolve; - }); + await channelProbe.promise; } }); @@ -298,8 +295,8 @@ describe("refreshActiveTab", () => { expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); expect(host.requestUpdate).not.toHaveBeenCalled(); - resolveSchema(); - resolveProbe(); + schema.resolve(); + channelProbe.resolve(); await vi.waitFor(() => { expect(host.requestUpdate).toHaveBeenCalledTimes(2); @@ -309,16 +306,12 @@ describe("refreshActiveTab", () => { it("records overview secondary refresh duration and aggregate status", async () => { const host = createHost(); host.tab = "overview"; - let resolveUsage!: () => void; - mocks.loadUsageMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveUsage = resolve; - }), - ); + const usage = createDeferred(); + mocks.loadUsageMock.mockReturnValueOnce(usage.promise); mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed")); await refreshActiveTab(host as never); - resolveUsage(); + usage.resolve(); await vi.waitFor(() => { expect(host.eventLogBuffer).toEqual( @@ -401,16 +394,12 @@ describe("refreshActiveTab", () => { it("does not record stale cron run timing after leaving the cron tab", async () => { const host = createHost(); host.tab = "cron"; - let resolveRuns!: () => void; - mocks.loadCronRunsMock.mockReturnValueOnce( - new Promise<"ok">((resolve) => { - resolveRuns = () => resolve("ok"); - }), - ); + const runs = createDeferred<"ok">(); + mocks.loadCronRunsMock.mockReturnValueOnce(runs.promise); await refreshActiveTab(host as never); host.tab = "chat"; - resolveRuns(); + runs.resolve("ok"); await Promise.resolve(); expect(host.eventLogBuffer).not.toEqual( diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 0d31467b194..dd40cc789a1 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -27,6 +27,17 @@ type HandlerMap = { type MockWebSocketHandler = (ev?: { code?: number; data?: string; reason?: string }) => void; +function createDeferred() { + let resolve: ((value: T) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } + return { promise, resolve }; +} + class MockWebSocket { static OPEN = 1; @@ -556,13 +567,8 @@ describe("GatewayBrowserClient", () => { it("does not send stale connect frames on a replacement socket", async () => { vi.useFakeTimers(); - let resolveIdentity!: (identity: DeviceIdentity) => void; - loadOrCreateDeviceIdentityMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveIdentity = resolve; - }), - ); + const identity = createDeferred(); + loadOrCreateDeviceIdentityMock.mockImplementationOnce(() => identity.promise); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", @@ -585,7 +591,7 @@ describe("GatewayBrowserClient", () => { const secondWs = getLatestWebSocket(); expect(secondWs).not.toBe(firstWs); - resolveIdentity({ + identity.resolve({ deviceId: "device-1", privateKey: "private-key", // pragma: allowlist secret publicKey: "public-key", // pragma: allowlist secret From 358c182a7f1da8af1f218ab3b4a342a4d5fb4370 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:16:23 +0100 Subject: [PATCH 540/806] test: tighten openrouter image assertions --- .../image-generation-provider.test.ts | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 59a83901bf4..da42646b109 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -31,6 +31,31 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, })); +function requireOpenRouterPostBody(): { + messages?: Array<{ content?: unknown }>; +} { + const request = postJsonRequestMock.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + if (!request) { + throw new Error("expected OpenRouter image generation request"); + } + return request.body as { messages?: Array<{ content?: unknown }> }; +} + +function requireGeneratedImage( + result: Awaited< + ReturnType["generateImage"]> + >, + index: number, +) { + const image = result.images[index]; + expect(image).toBeDefined(); + if (!image) { + throw new Error(`expected OpenRouter generated image at index ${index}`); + } + return image; +} + describe("openrouter image generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -125,8 +150,9 @@ describe("openrouter image generation provider", () => { }), }), ); - expect(result.images[0]?.buffer.toString()).toBe("png-one"); - expect(result.images[0]?.mimeType).toBe("image/png"); + const image = requireGeneratedImage(result, 0); + expect(image.buffer.toString()).toBe("png-one"); + expect(image.mimeType).toBe("image/png"); expect(release).toHaveBeenCalledOnce(); }); @@ -162,9 +188,7 @@ describe("openrouter image generation provider", () => { cfg: {} as never, }); - const body = postJsonRequestMock.mock.calls[0]?.[0].body as { - messages?: Array<{ content?: unknown }>; - }; + const body = requireOpenRouterPostBody(); expect(body.messages?.[0]?.content).toEqual([ { type: "text", text: "turn this into watercolor" }, { @@ -174,8 +198,9 @@ describe("openrouter image generation provider", () => { }, }, ]); - expect(result.images[0]?.buffer.toString()).toBe("webp-one"); - expect(result.images[0]?.mimeType).toBe("image/webp"); + const image = requireGeneratedImage(result, 0); + expect(image.buffer.toString()).toBe("webp-one"); + expect(image.mimeType).toBe("image/webp"); }); it("extracts image fallbacks from string content and raw b64 parts", () => { From 2844eb0f7b4aaaaca1fdbb6a5e0b0084b60ca698 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:17:48 +0100 Subject: [PATCH 541/806] test: tighten openrouter video assertions --- .../video-generation-provider.test.ts | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index da110dab135..b1ee473bb7b 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -61,6 +61,41 @@ function releasedVideo(params: { contentType: string; bytes: string }) { }; } +type OpenRouterVideoProvider = ReturnType; +type OpenRouterVideoResult = Awaited>; + +function requireGenerateCapabilities(provider: OpenRouterVideoProvider) { + const capabilities = provider.capabilities.generate; + expect(capabilities).toBeDefined(); + if (!capabilities) { + throw new Error("expected OpenRouter generate capabilities"); + } + return capabilities; +} + +function requireFetchCallHeaders(index: number): Headers { + const call = fetchWithTimeoutGuardedMock.mock.calls[index]; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`expected OpenRouter fetch call ${index + 1}`); + } + const init = call[1] as { headers?: HeadersInit } | undefined; + expect(init).toBeDefined(); + if (!init) { + throw new Error(`expected OpenRouter fetch call ${index + 1} init`); + } + return new Headers(init.headers); +} + +function requireGeneratedVideo(result: OpenRouterVideoResult, index: number) { + const video = result.videos[index]; + expect(video).toBeDefined(); + if (!video) { + throw new Error(`expected OpenRouter generated video at index ${index}`); + } + return video; +} + describe("openrouter video generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -77,12 +112,13 @@ describe("openrouter video generation provider", () => { expectExplicitVideoGenerationCapabilities(provider); expect(provider.id).toBe("openrouter"); expect(provider.defaultModel).toBe("google/veo-3.1-fast"); - expect(provider.capabilities.generate?.supportsAudio).toBe(true); - expect(provider.capabilities.generate?.supportedDurationSeconds).toEqual([4, 6, 8]); - expect(provider.capabilities.generate?.resolutions).toEqual(["720P", "1080P"]); - expect(provider.capabilities.generate?.aspectRatios).toEqual(["16:9", "9:16"]); - expect(provider.capabilities.imageToVideo?.enabled).toBe(true); - expect(provider.capabilities.videoToVideo?.enabled).toBe(false); + const generateCapabilities = requireGenerateCapabilities(provider); + expect(generateCapabilities.supportsAudio).toBe(true); + expect(generateCapabilities.supportedDurationSeconds).toEqual([4, 6, 8]); + expect(generateCapabilities.resolutions).toEqual(["720P", "1080P"]); + expect(generateCapabilities.aspectRatios).toEqual(["16:9", "9:16"]); + expect(provider.capabilities.imageToVideo).toMatchObject({ enabled: true }); + expect(provider.capabilities.videoToVideo).toMatchObject({ enabled: false }); }); it("submits OpenRouter video jobs, polls completion, and downloads the result", async () => { @@ -204,11 +240,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-status" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBe("Bearer openrouter-key"); + expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer openrouter-key"); expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( 2, "https://custom.openrouter.test/api/v1/videos/job-123/content?index=0", @@ -217,13 +249,10 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBe("Bearer openrouter-key"); - expect(result.videos[0]?.buffer?.toString()).toBe("mp4-bytes"); - expect(result.videos[0]?.mimeType).toBe("video/mp4"); + expect(requireFetchCallHeaders(1).get("authorization")).toBe("Bearer openrouter-key"); + const video = requireGeneratedVideo(result, 0); + expect(video.buffer.toString()).toBe("mp4-bytes"); + expect(video.mimeType).toBe("video/mp4"); expect(result.metadata).toEqual({ jobId: "job-123", status: "completed", @@ -266,11 +295,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-status" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBeNull(); + expect(requireFetchCallHeaders(0).get("authorization")).toBeNull(); expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( 2, "https://cdn.openrouter.test/video.mp4", @@ -279,11 +304,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBeNull(); + expect(requireFetchCallHeaders(1).get("authorization")).toBeNull(); }); it("falls back to the documented content endpoint when a completed job has no output URL", async () => { @@ -313,8 +334,9 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect(result.videos[0]?.buffer?.toString()).toBe("webm-bytes"); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const video = requireGeneratedVideo(result, 0); + expect(video.buffer.toString()).toBe("webm-bytes"); + expect(video.fileName).toBe("video-1.webm"); }); it("rejects video reference inputs", async () => { From d39f4dcce6d8b6acf032b6ca4049ed101636e261 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:18:44 +0100 Subject: [PATCH 542/806] test: tighten byteplus video assertions --- .../video-generation-provider.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index 72daab0f9c6..70827e9e43c 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -41,6 +41,21 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) { }); } +function requireBytePlusPostBody(): Record { + const request = postJsonRequestMock.mock.calls[0]?.[0] as + | { body?: Record } + | undefined; + expect(request).toBeDefined(); + if (!request) { + throw new Error("expected BytePlus video request"); + } + expect(request.body).toBeDefined(); + if (!request.body) { + throw new Error("expected BytePlus video request body"); + } + return request.body; +} + describe("byteplus video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider()); @@ -88,8 +103,7 @@ describe("byteplus video generation provider", () => { cfg: {}, }); - const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record }; - expect(request.body).toMatchObject({ + expect(requireBytePlusPostBody()).toMatchObject({ model: "seedance-1-0-lite-i2v-250428", resolution: "720p", content: [ @@ -119,8 +133,7 @@ describe("byteplus video generation provider", () => { cfg: {}, }); - const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record }; - expect(request.body).toMatchObject({ + expect(requireBytePlusPostBody()).toMatchObject({ model: "seedance-1-0-pro-250528", seed: 42, resolution: "480p", From 8db4b3af6f7d51329a809de6ff73f8a7413b02e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:18:33 +0100 Subject: [PATCH 543/806] test: require core deferred callbacks --- src/acp/control-plane/manager.test.ts | 5 +++- src/gateway/server-lanes.test.ts | 7 +++-- src/gateway/server-methods/models.test.ts | 7 +++-- .../server.chat.gateway-server-chat-b.test.ts | 7 +++-- .../websocket-session.test.ts | 29 +++++++++++-------- 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 6f02d1c979c..16cb9317712 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -87,10 +87,13 @@ async function flushMicrotasks(rounds = 3): Promise { } function createDeferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((next) => { resolve = next; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/gateway/server-lanes.test.ts b/src/gateway/server-lanes.test.ts index bd2ea60aa0b..d51335acb73 100644 --- a/src/gateway/server-lanes.test.ts +++ b/src/gateway/server-lanes.test.ts @@ -5,12 +5,15 @@ import { CommandLane } from "../process/lanes.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index b47b027c5a2..fc918fe473f 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -10,12 +10,15 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 1f8118e7b7c..b494dbacfc0 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -51,12 +51,15 @@ const sendReq = ( }; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/realtime-transcription/websocket-session.test.ts b/src/realtime-transcription/websocket-session.test.ts index 5607cb15754..496e6e07a54 100644 --- a/src/realtime-transcription/websocket-session.test.ts +++ b/src/realtime-transcription/websocket-session.test.ts @@ -60,18 +60,26 @@ async function createRealtimeServer(params?: { return { url: `ws://127.0.0.1:${port}` }; } +function createSignal() { + let resolve: (() => void) | undefined; + const promise = new Promise((next) => { + resolve = next; + }); + if (!resolve) { + throw new Error("Expected frame signal resolver to be initialized"); + } + return { promise, resolve }; +} + describe("createRealtimeTranscriptionWebSocketSession", () => { it("flushes queued binary audio after an open-ready connection", async () => { const frames: Buffer[] = []; - let resolveFrames!: () => void; - const framesReady = new Promise((resolve) => { - resolveFrames = resolve; - }); + const framesReady = createSignal(); const server = await createRealtimeServer({ onBinary: (payload) => { frames.push(payload); if (Buffer.concat(frames).toString() === "queuedafter") { - resolveFrames(); + framesReady.resolve(); } }, }); @@ -88,7 +96,7 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { session.sendAudio(Buffer.from("queued")); await session.connect(); session.sendAudio(Buffer.from("after")); - await framesReady; + await framesReady.promise; expect(Buffer.concat(frames).toString()).toBe("queuedafter"); expect(session.isConnected()).toBe(true); session.close(); @@ -96,16 +104,13 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { it("lets providers mark ready after a JSON handshake", async () => { const frames: unknown[] = []; - let resolveFrames!: () => void; - const framesReady = new Promise((resolve) => { - resolveFrames = resolve; - }); + const framesReady = createSignal(); const server = await createRealtimeServer({ initialEvent: { type: "session.created" }, onText: (payload) => { frames.push(payload); if (frames.length === 2) { - resolveFrames(); + framesReady.resolve(); } }, }); @@ -126,7 +131,7 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { session.sendAudio(Buffer.from("queued")); await session.connect(); - await framesReady; + await framesReady.promise; expect(frames).toEqual([ { type: "session.update" }, { type: "input_audio.append", audio: Buffer.from("queued").toString("base64") }, From e733351413f8b3ec6d3145f74c852a890253876c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:19:38 +0100 Subject: [PATCH 544/806] test: tighten runway video assertion --- extensions/runway/video-generation-provider.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index 796d61bad2e..ae56769e184 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -72,7 +72,12 @@ describe("runway video generation provider", () => { fetch, ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const video = result.videos[0]; + expect(video).toBeDefined(); + if (!video) { + throw new Error("expected Runway generated video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ taskId: "task-1", From 2f247cf20c47887546bda9f3583f0c0a563329a8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:20:28 +0100 Subject: [PATCH 545/806] test: tighten readability extractor assertions --- .../web-content-extractor.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/extensions/web-readability/web-content-extractor.test.ts b/extensions/web-readability/web-content-extractor.test.ts index 91f526a8bdd..62c0fa0693b 100644 --- a/extensions/web-readability/web-content-extractor.test.ts +++ b/extensions/web-readability/web-content-extractor.test.ts @@ -25,6 +25,18 @@ const SAMPLE_HTML = ` `; +type ReadabilityResult = Awaited< + ReturnType["extract"]> +>; + +function requireReadabilityResult(result: ReadabilityResult): NonNullable { + expect(result).toBeDefined(); + if (!result) { + throw new Error("expected readability extraction result"); + } + return result; +} + describe("web readability extractor", () => { it("extracts readable text", async () => { const extractor = createReadabilityWebContentExtractor(); @@ -33,8 +45,9 @@ describe("web readability extractor", () => { url: "https://example.com/article", extractMode: "text", }); - expect(result?.text).toContain("Main content starts here"); - expect(result?.title).toBe("Example Article"); + const extracted = requireReadabilityResult(result); + expect(extracted.text).toContain("Main content starts here"); + expect(extracted.title).toBe("Example Article"); }); it("extracts readable markdown", async () => { @@ -44,7 +57,8 @@ describe("web readability extractor", () => { url: "https://example.com/article", extractMode: "markdown", }); - expect(result?.text).toContain("Main content starts here"); - expect(result?.title).toBe("Example Article"); + const extracted = requireReadabilityResult(result); + expect(extracted.text).toContain("Main content starts here"); + expect(extracted.title).toBe("Example Article"); }); }); From 16c54655d391a0513b2902a56885fdcd1d2e4a4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:20:36 +0100 Subject: [PATCH 546/806] test: require common deferred callbacks --- src/config/sessions.test.ts | 7 +++++-- src/config/sessions/store-writer.test.ts | 7 +++++-- src/cron/service.rearm-timer-when-running.test.ts | 5 ++++- src/infra/exec-approval-channel-runtime.test.ts | 7 +++++-- src/test-utils/session-state-cleanup.test.ts | 7 +++++-- src/tui/embedded-backend.test.ts | 7 +++++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 9de3d1d4d4f..ddc6f072dcc 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -760,12 +760,15 @@ describe("sessions", () => { }); const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; const firstStarted = createDeferred(); diff --git a/src/config/sessions/store-writer.test.ts b/src/config/sessions/store-writer.test.ts index f6e91055344..473f40264a0 100644 --- a/src/config/sessions/store-writer.test.ts +++ b/src/config/sessions/store-writer.test.ts @@ -6,12 +6,15 @@ import { } from "./store.js"; const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; diff --git a/src/cron/service.rearm-timer-when-running.test.ts b/src/cron/service.rearm-timer-when-running.test.ts index 126e994bfe7..83909cb699f 100644 --- a/src/cron/service.rearm-timer-when-running.test.ts +++ b/src/cron/service.rearm-timer-when-running.test.ts @@ -35,10 +35,13 @@ function createDueRecurringJob(params: { } function createDeferred() { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/infra/exec-approval-channel-runtime.test.ts b/src/infra/exec-approval-channel-runtime.test.ts index 181eea169cb..ae7fab6af0c 100644 --- a/src/infra/exec-approval-channel-runtime.test.ts +++ b/src/infra/exec-approval-channel-runtime.test.ts @@ -33,12 +33,15 @@ let createExecApprovalChannelRuntime: typeof import("./exec-approval-channel-run let ExecApprovalChannelRuntimeTerminalStartError: typeof import("./exec-approval-channel-runtime.js").ExecApprovalChannelRuntimeTerminalStartError; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/test-utils/session-state-cleanup.test.ts b/src/test-utils/session-state-cleanup.test.ts index 79cfd4de925..dbe1d2671d2 100644 --- a/src/test-utils/session-state-cleanup.test.ts +++ b/src/test-utils/session-state-cleanup.test.ts @@ -20,12 +20,15 @@ const drainSessionStoreWriterQueuesMock = vi.hoisted(() => vi.fn(async () => und const drainSessionWriteLockStateMock = vi.hoisted(() => vi.fn(async () => undefined)); function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index ae4963cacdf..f7708c98808 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -113,12 +113,15 @@ vi.mock("../gateway/server-methods/agent-timestamp.js", () => ({ })); function deferred() { - let resolve!: (value: T) => void; - let reject!: (error?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From 67e40485cd87a3d5ca32097980ea883b5d207349 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:21:18 +0100 Subject: [PATCH 547/806] test: tighten debug view command assertion --- ui/src/ui/views/debug.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/debug.test.ts b/ui/src/ui/views/debug.test.ts index eeaf9599da8..af731c661cd 100644 --- a/ui/src/ui/views/debug.test.ts +++ b/ui/src/ui/views/debug.test.ts @@ -58,7 +58,11 @@ describe("renderDebug", () => { ); const command = container.querySelector(".callout .mono"); - expect(command?.textContent).toBe("openclaw security audit --deep"); + expect(command).toBeTruthy(); + if (!command) { + throw new Error("expected debug security audit command"); + } + expect(command.textContent).toBe("openclaw security audit --deep"); expect(container.textContent).toContain("安全审计"); }); }); From a1ea0b65de58e67403bfea2ee83e844d584a10a1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:22:12 +0100 Subject: [PATCH 548/806] test: tighten firecrawl fetch config assertion --- extensions/firecrawl/src/firecrawl-tools.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index b2b4e801966..027a8e9df04 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -353,7 +353,12 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); - expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.firecrawl; + expect(pluginEntry).toBeDefined(); + if (!pluginEntry) { + throw new Error("expected Firecrawl fetch plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("passes proxy and storeInCache through the fetch provider tool", async () => { From 17127ef02269eded2c50bc31af68841e6b99debc Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:23:12 +0100 Subject: [PATCH 549/806] test: tighten usage aggregate assertion --- ui/src/ui/views/usage-render-details.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/ui/views/usage-render-details.test.ts index 32bc0507cec..c24fec16071 100644 --- a/ui/src/ui/views/usage-render-details.test.ts +++ b/ui/src/ui/views/usage-render-details.test.ts @@ -72,7 +72,11 @@ describe("computeFilteredUsage", () => { expect(result).toMatchObject({ totalTokens: 300, // 100 + 200 }); - expect(result?.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 + expect(result).toBeDefined(); + if (!result) { + throw new Error("expected filtered usage aggregate"); + } + expect(result.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 }); it("handles reversed range (end < start)", () => { From 73faa75be1139ed0cd4850bcd6563f87086c040d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:23:39 +0100 Subject: [PATCH 550/806] test: require browser async callbacks --- .../src/browser/cdp-proxy-bypass.test.ts | 7 +++++-- .../browser/src/browser/chrome-mcp.test.ts | 5 ++++- ...ols-core.interactions.evaluate.abort.test.ts | 5 ++++- .../video-generation-provider.test.ts | 17 +++++++++++++---- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/extensions/browser/src/browser/cdp-proxy-bypass.test.ts b/extensions/browser/src/browser/cdp-proxy-bypass.test.ts index 4ebe20093ad..bbcd4bd82af 100644 --- a/extensions/browser/src/browser/cdp-proxy-bypass.test.ts +++ b/extensions/browser/src/browser/cdp-proxy-bypass.test.ts @@ -13,12 +13,15 @@ beforeEach(() => { }); function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index ddcce2cc8cb..b3f52de0939 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -349,10 +349,13 @@ describe("chrome MCP page parsing", () => { it("reuses a single pending session for concurrent requests", async () => { let factoryCalls = 0; - let releaseFactory!: () => void; + let releaseFactory: (() => void) | undefined; const factoryGate = new Promise((resolve) => { releaseFactory = resolve; }); + if (!releaseFactory) { + throw new Error("Expected Chrome MCP factory release callback to be initialized"); + } const factory: ChromeMcpSessionFactory = async () => { factoryCalls += 1; diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts index e619aab3783..18430643db0 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts @@ -34,10 +34,13 @@ vi.mock("./pw-session.js", () => { const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"); function createPendingEval() { - let evalCalled!: () => void; + let evalCalled: (() => void) | undefined; const evalCalledPromise = new Promise((resolve) => { evalCalled = resolve; }); + if (!evalCalled) { + throw new Error("Expected evaluate callback to be initialized"); + } return { evalCalledPromise, resolveEvalCalled: evalCalled, diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index b1ee473bb7b..11216b0e6b9 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -96,6 +96,15 @@ function requireGeneratedVideo(result: OpenRouterVideoResult, index: number) { return video; } +function requireGeneratedVideoBuffer(result: OpenRouterVideoResult, index: number) { + const video = requireGeneratedVideo(result, index); + expect(video.buffer).toBeInstanceOf(Buffer); + if (!video.buffer) { + throw new Error(`expected OpenRouter generated video ${index} buffer`); + } + return { video, buffer: video.buffer }; +} + describe("openrouter video generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -250,8 +259,8 @@ describe("openrouter video generation provider", () => { expect.objectContaining({ auditContext: "openrouter-video-download" }), ); expect(requireFetchCallHeaders(1).get("authorization")).toBe("Bearer openrouter-key"); - const video = requireGeneratedVideo(result, 0); - expect(video.buffer.toString()).toBe("mp4-bytes"); + const { video, buffer } = requireGeneratedVideoBuffer(result, 0); + expect(buffer.toString()).toBe("mp4-bytes"); expect(video.mimeType).toBe("video/mp4"); expect(result.metadata).toEqual({ jobId: "job-123", @@ -334,8 +343,8 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - const video = requireGeneratedVideo(result, 0); - expect(video.buffer.toString()).toBe("webm-bytes"); + const { video, buffer } = requireGeneratedVideoBuffer(result, 0); + expect(buffer.toString()).toBe("webm-bytes"); expect(video.fileName).toBe("video-1.webm"); }); From 9acf08a38a040e1010a2764e93576e65d0c9c58d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:24:07 +0100 Subject: [PATCH 551/806] test: tighten mattermost model picker assertion --- .../mattermost/src/mattermost/model-picker.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 9886a205fe4..2e53b9e336f 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -59,7 +59,17 @@ describe("Mattermost model picker", () => { expect(view.text).toContain("/oc_model to switch"); expect(view.text).toContain("Browse keeps the current runtime"); expect(view.text).toContain("/oc_model --runtime "); - expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); + const firstRow = view.buttons[0]; + expect(firstRow).toBeDefined(); + if (!firstRow) { + throw new Error("expected Mattermost model picker button row"); + } + const browseButton = firstRow[0]; + expect(browseButton).toBeDefined(); + if (!browseButton) { + throw new Error("expected Mattermost browse providers button"); + } + expect(browseButton.text).toBe("Browse providers"); }); it("trims accidental model spacing in Mattermost current-model text", () => { From 29ac446afe3000851c5e2d63854beeaec0ba9e3a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:25:50 +0100 Subject: [PATCH 552/806] test: tighten qwen catalog assertions --- extensions/qwen/provider-catalog.test.ts | 33 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/extensions/qwen/provider-catalog.test.ts b/extensions/qwen/provider-catalog.test.ts index 0f155b0f140..3e73879878a 100644 --- a/extensions/qwen/provider-catalog.test.ts +++ b/extensions/qwen/provider-catalog.test.ts @@ -7,15 +7,23 @@ import { QWEN_DEFAULT_MODEL_ID, } from "./api.js"; +type QwenProvider = ReturnType; + +function getQwenModelIds(provider: QwenProvider): string[] { + expect(provider.models).toBeDefined(); + return provider.models.map((model) => model.id); +} + describe("qwen provider catalog", () => { it("builds the bundled Qwen provider defaults", () => { const provider = buildQwenProvider(); expect(provider.baseUrl).toBe(QWEN_BASE_URL); expect(provider.api).toBe("openai-completions"); - expect(provider.models?.length).toBeGreaterThan(0); - expect(provider.models?.map((model) => model.id)).toContain(QWEN_DEFAULT_MODEL_ID); - expect(provider.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); + const modelIds = getQwenModelIds(provider); + expect(modelIds.length).toBeGreaterThan(0); + expect(modelIds).toContain(QWEN_DEFAULT_MODEL_ID); + expect(modelIds).not.toContain("qwen3.6-plus"); }); it("only advertises qwen3.6-plus on Standard endpoints", () => { @@ -25,15 +33,22 @@ describe("qwen provider catalog", () => { }); const standard = buildQwenProvider({ baseUrl: QWEN_STANDARD_GLOBAL_BASE_URL }); - expect(coding.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); - expect(codingTrailingDot.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); - expect(standard.models?.map((model) => model.id)).toContain("qwen3.6-plus"); + expect(getQwenModelIds(coding)).not.toContain("qwen3.6-plus"); + expect(getQwenModelIds(codingTrailingDot)).not.toContain("qwen3.6-plus"); + expect(getQwenModelIds(standard)).toContain("qwen3.6-plus"); }); it("opts native Qwen baseUrls into streaming usage only inside the extension", () => { const nativeProvider = applyQwenNativeStreamingUsageCompat(buildQwenProvider()); + expect(nativeProvider.models.length).toBeGreaterThan(0); expect( - nativeProvider.models?.every((model) => model.compat?.supportsUsageInStreaming === true), + nativeProvider.models.every((model) => { + expect(model.compat).toBeDefined(); + if (!model.compat) { + throw new Error(`expected Qwen model ${model.id} compat`); + } + return model.compat.supportsUsageInStreaming === true; + }), ).toBe(true); const customProvider = applyQwenNativeStreamingUsageCompat({ @@ -41,7 +56,9 @@ describe("qwen provider catalog", () => { baseUrl: "https://proxy.example.com/v1", }); expect( - customProvider.models?.some((model) => model.compat?.supportsUsageInStreaming === true), + customProvider.models.some( + (model) => model.compat && model.compat.supportsUsageInStreaming === true, + ), ).toBe(false); }); }); From 0c34f7ac1cab9cea4c0345900141b09f0cdac7c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:26:29 +0100 Subject: [PATCH 553/806] test: reuse command queue deferred helper --- src/process/command-queue.test.ts | 52 ++++++++++++------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 56fa3df2182..f842fd53747 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -38,10 +38,13 @@ let setCommandLaneConcurrency: CommandQueueModule["setCommandLaneConcurrency"]; let waitForActiveTasks: CommandQueueModule["waitForActiveTasks"]; function createDeferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -145,12 +148,9 @@ describe("command queue", () => { vi.useFakeTimers(); try { - let releaseFirst!: () => void; - const blocker = new Promise((resolve) => { - releaseFirst = resolve; - }); + const blocker = createDeferred(); const first = enqueueCommand(async () => { - await blocker; + await blocker.promise; }); const second = enqueueCommand(async () => {}, { @@ -162,7 +162,7 @@ describe("command queue", () => { }); await vi.advanceTimersByTimeAsync(6); - releaseFirst(); + blocker.resolve(); await Promise.all([first, second]); expect(typeof waited).toBe("number"); @@ -255,14 +255,11 @@ describe("command queue", () => { const lane = `reset-test-${Date.now()}-${Math.random().toString(16).slice(2)}`; setCommandLaneConcurrency(lane, 1); - let resolve1!: () => void; - const blocker = new Promise((r) => { - resolve1 = r; - }); + const blocker = createDeferred(); // Start a task that blocks the lane const task1 = enqueueCommandInLane(lane, async () => { - await blocker; + await blocker.promise; }); expect(getActiveTaskCount()).toBeGreaterThanOrEqual(1); @@ -282,7 +279,7 @@ describe("command queue", () => { // Complete the stale in-flight task; generation mismatch makes its // completion path a no-op for queue bookkeeping. - resolve1(); + blocker.resolve(); await task1; // task2 should have been pumped by resetAllLanes's drain pass. @@ -454,34 +451,28 @@ describe("command queue", () => { const lane = `drain-snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; setCommandLaneConcurrency(lane, 2); - let resolve1!: () => void; - const blocker1 = new Promise((r) => { - resolve1 = r; - }); - let resolve2!: () => void; - const blocker2 = new Promise((r) => { - resolve2 = r; - }); + const blocker1 = createDeferred(); + const blocker2 = createDeferred(); const firstStarted = createDeferred(); const first = enqueueCommandInLane(lane, async () => { firstStarted.resolve(); - await blocker1; + await blocker1.promise; }); await firstStarted.promise; const drainPromise = waitForActiveTasks(2000); // Starts after waitForActiveTasks snapshot and should not block drain completion. const second = enqueueCommandInLane(lane, async () => { - await blocker2; + await blocker2.promise; }); expect(getActiveTaskCount()).toBeGreaterThanOrEqual(2); - resolve1(); + blocker1.resolve(); const { drained } = await drainPromise; expect(drained).toBe(true); - resolve2(); + blocker2.resolve(); await Promise.all([first, second]); }); @@ -593,27 +584,24 @@ describe("command queue", () => { ); const lane = `shared-state-${Date.now()}-${Math.random().toString(16).slice(2)}`; - let release!: () => void; - const blocker = new Promise((resolve) => { - release = resolve; - }); + const blocker = createDeferred(); commandQueueA.resetAllLanes(); try { const task = commandQueueA.enqueueCommandInLane(lane, async () => { - await blocker; + await blocker.promise; return "done"; }); expect(commandQueueB.getQueueSize(lane)).toBe(1); expect(commandQueueB.getActiveTaskCount()).toBe(1); - release(); + blocker.resolve(); await expect(task).resolves.toBe("done"); expect(commandQueueB.getQueueSize(lane)).toBe(0); } finally { - release(); + blocker.resolve(); commandQueueA.resetAllLanes(); } }); From 0043560cca2a6f8d203a27dac0ba100950cb410b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:27:07 +0100 Subject: [PATCH 554/806] test: tighten qwen video request assertions --- .../qwen/media-understanding-provider.test.ts | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/extensions/qwen/media-understanding-provider.test.ts b/extensions/qwen/media-understanding-provider.test.ts index 3b747aa1812..348b64fe607 100644 --- a/extensions/qwen/media-understanding-provider.test.ts +++ b/extensions/qwen/media-understanding-provider.test.ts @@ -36,25 +36,44 @@ describe("describeQwenVideo", () => { expect(result.model).toBe("qwen-vl-max"); expect(result.text).toBe("first\nsecond"); expect(url).toBe("https://example.com/v1/chat/completions"); - expect(init?.method).toBe("POST"); - expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init).toBeDefined(); + if (!init) { + throw new Error("expected Qwen request init"); + } + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(init?.headers); + const headers = new Headers(init.headers); expect(headers.get("authorization")).toBe("Bearer test-key"); expect(headers.get("content-type")).toBe("application/json"); expect(headers.get("x-other")).toBe("1"); const bodyText = - typeof init?.body === "string" + typeof init.body === "string" ? init.body - : Buffer.isBuffer(init?.body) + : Buffer.isBuffer(init.body) ? init.body.toString("utf8") : ""; + expect(bodyText).not.toBe(""); const body = JSON.parse(bodyText); expect(body.model).toBe("qwen-vl-max"); - expect(body.messages?.[0]?.content?.[0]?.text).toBe("summarize the clip"); - expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url"); - expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe( + const content = body.messages?.[0]?.content; + expect(content).toBeDefined(); + if (!content) { + throw new Error("expected Qwen user content"); + } + expect(content[0]?.text).toBe("summarize the clip"); + const videoContent = content[1]; + expect(videoContent).toBeDefined(); + if (!videoContent) { + throw new Error("expected Qwen video content"); + } + expect(videoContent.type).toBe("video_url"); + expect(videoContent.video_url).toBeDefined(); + if (!videoContent.video_url) { + throw new Error("expected Qwen video URL payload"); + } + expect(videoContent.video_url.url).toBe( `data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`, ); }); From 7a6b98c3df92a06b31b1ace8d19aa1b7dfdadbc3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:28:42 +0100 Subject: [PATCH 555/806] test: tighten video runner output assertions --- src/media-understanding/runner.video.test.ts | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 58647ba9f02..e6263bea0dc 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -22,6 +22,17 @@ vi.mock("../plugins/capability-provider-runtime.js", async () => { return createEmptyCapabilityProviderMockModule(); }); +type CapabilityResult = Awaited>; + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + expect(output).toBeDefined(); + if (!output) { + throw new Error(`expected media-understanding output at index ${index}`); + } + return output; +} + describe("runCapability video provider wiring", () => { it("merges video baseUrl and headers with entry precedence", async () => { let seenBaseUrl: string | undefined; @@ -83,8 +94,9 @@ describe("runCapability video provider wiring", () => { ]), }); - expect(result.outputs[0]?.text).toBe("video ok"); - expect(result.outputs[0]?.provider).toBe("moonshot"); + const output = requireCapabilityOutput(result, 0); + expect(output.text).toBe("video ok"); + expect(output.provider).toBe("moonshot"); expect(seenBaseUrl).toBe("https://entry.example/v1"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", @@ -154,8 +166,9 @@ describe("runCapability video provider wiring", () => { }); expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("moonshot"); - expect(result.outputs[0]?.text).toBe("moonshot"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("moonshot"); + expect(output.text).toBe("moonshot"); }); }, ); From d5ccdab3d874130c21b57259c61722c323fb04da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:29:32 +0100 Subject: [PATCH 556/806] test: require matrix async callbacks --- extensions/matrix/src/matrix/direct-management.test.ts | 5 ++++- .../matrix/src/matrix/monitor/handler.group-history.test.ts | 5 ++++- .../matrix/src/matrix/sdk/verification-manager.test.ts | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/extensions/matrix/src/matrix/direct-management.test.ts b/extensions/matrix/src/matrix/direct-management.test.ts index 7cab96fa1b7..4fa17052cd8 100644 --- a/extensions/matrix/src/matrix/direct-management.test.ts +++ b/extensions/matrix/src/matrix/direct-management.test.ts @@ -305,12 +305,15 @@ describe("promoteMatrixDirectRoomCandidate", () => { it("serializes concurrent m.direct writes so distinct mappings are not lost", async () => { let directContent: Record = {}; - let releaseFirstWrite!: () => void; + let releaseFirstWrite: (() => void) | undefined; const firstWriteStarted = new Promise((resolve) => { releaseFirstWrite = () => { resolve(); }; }); + if (!releaseFirstWrite) { + throw new Error("Expected first m.direct write release callback to be initialized"); + } let writeCount = 0; const setAccountData = vi.fn(async (_eventType: string, content: Record) => { writeCount += 1; diff --git a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts index 613a7b49f2b..98d0dc3a8fa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts @@ -90,10 +90,13 @@ beforeEach(() => { }); function deferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts index d7507d38aa7..34a1b49ac79 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -548,10 +548,13 @@ describe("MatrixVerificationManager", () => { }); it("confirmVerificationSas awaits the verifier's verify promise before resolving", async () => { - let resolveVerify!: () => void; + let resolveVerify: (() => void) | undefined; const verifyPromise = new Promise((res) => { resolveVerify = res; }); + if (!resolveVerify) { + throw new Error("Expected verification resolver to be initialized"); + } const verifyImpl = vi.fn(() => verifyPromise); const { confirm, verifier } = createSasVerifierFixture({ decimal: [111, 222, 333], From 3a0b81af9d7d86807be030415e2d1b1b38dae949 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:30:21 +0100 Subject: [PATCH 557/806] test: tighten auto audio output assertions --- .../runner.auto-audio.test.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 4e26677609b..55949bbac50 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -81,6 +81,17 @@ async function runAutoAudioCase(params: { return runResult; } +type CapabilityResult = Awaited>; + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + expect(output).toBeDefined(); + if (!output) { + throw new Error(`expected media-understanding output at index ${index}`); + } + return output; +} + describe("runCapability auto audio entries", () => { it("uses provider keys to auto-enable audio transcription", async () => { let seenModel: string | undefined; @@ -90,7 +101,7 @@ describe("runCapability auto audio entries", () => { return { text: "ok", model: req.model ?? "unknown" }; }, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenModel).toBe("gpt-4o-transcribe"); expect(result.decision.outcome).toBe("success"); }); @@ -133,7 +144,11 @@ describe("runCapability auto audio entries", () => { }); }); - expect(runResult?.outputs[0]).toMatchObject({ + expect(runResult).toBeDefined(); + if (!runResult) { + throw new Error("expected Codex audio result"); + } + expect(requireCapabilityOutput(runResult, 0)).toMatchObject({ provider: "openai-codex", model: "gpt-4o-transcribe", text: "codex audio", @@ -163,8 +178,9 @@ describe("runCapability auto audio entries", () => { }), ); - expect(result.outputs[0]?.provider).toBe("openai"); - expect(result.outputs[0]?.text).toBe("provider transcription"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("openai"); + expect(output.text).toBe("provider transcription"); expect(seenModel).toBe("gpt-4o-transcribe"); } finally { clearMediaUnderstandingBinaryCacheForTests(); @@ -210,7 +226,7 @@ describe("runCapability auto audio entries", () => { }, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenModel).toBe("whisper-1"); }); @@ -246,7 +262,7 @@ describe("runCapability auto audio entries", () => { } as Partial, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenLanguage).toBe("en"); expect(seenPrompt).toBe("Focus on names"); }); @@ -322,8 +338,9 @@ describe("runCapability auto audio entries", () => { throw new Error("Expected auto audio mistral result"); } expect(runResult.decision.outcome).toBe("success"); - expect(runResult.outputs[0]?.provider).toBe("mistral"); - expect(runResult.outputs[0]?.model).toBe("voxtral-mini-latest"); - expect(runResult.outputs[0]?.text).toBe("mistral"); + const output = requireCapabilityOutput(runResult, 0); + expect(output.provider).toBe("mistral"); + expect(output.model).toBe("voxtral-mini-latest"); + expect(output.text).toBe("mistral"); }); }); From 4e0f193e2edf089830defd25863c5400a13a2853 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:30:58 +0100 Subject: [PATCH 558/806] test: require slack async callbacks --- extensions/slack/src/monitor/slash.test.ts | 5 ++++- extensions/slack/src/send.upload.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index b2dbc7d2f6d..79f910636d9 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -287,10 +287,13 @@ function findFirstActionsBlock(payload: { blocks?: Array<{ type: string }> }) { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Slack slash deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index 58a96067101..b85e5a08185 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -178,7 +178,7 @@ describe("sendMessageSlack file upload with user IDs", () => { it("serializes concurrent sends to the same Slack target", async () => { const client = createUploadTestClient(); - let resolveFirst!: () => void; + let resolveFirst: (() => void) | undefined; client.chat.postMessage.mockImplementation(async (payload: { text?: string }) => { if (payload.text === "first") { await new Promise((resolve) => { @@ -204,6 +204,9 @@ describe("sendMessageSlack file upload with user IDs", () => { await Promise.resolve(); expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + if (!resolveFirst) { + throw new Error("Expected first Slack send release callback to be initialized"); + } resolveFirst(); await expect(first).resolves.toMatchObject({ From ddccd22b1e629d1f6557ce1be6e6f06577cba1f2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:31:17 +0100 Subject: [PATCH 559/806] test: tighten tiny audio failure assertions --- .../runner.skip-tiny-audio.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/media-understanding/runner.skip-tiny-audio.test.ts b/src/media-understanding/runner.skip-tiny-audio.test.ts index fc0386c7a83..5f769401182 100644 --- a/src/media-understanding/runner.skip-tiny-audio.test.ts +++ b/src/media-understanding/runner.skip-tiny-audio.test.ts @@ -179,9 +179,19 @@ describe("runCapability skips tiny audio files", () => { expect(result.outputs).toHaveLength(0); expect(result.decision.outcome).toBe("failed"); expect(result.decision.attachments).toHaveLength(1); - expect(result.decision.attachments[0]?.attempts).toHaveLength(1); - expect(result.decision.attachments[0]?.attempts[0]?.outcome).toBe("failed"); - expect(result.decision.attachments[0]?.attempts[0]?.reason).toContain("upstream 500"); + const attachment = result.decision.attachments[0]; + expect(attachment).toBeDefined(); + if (!attachment) { + throw new Error("expected failed audio decision attachment"); + } + expect(attachment.attempts).toHaveLength(1); + const attempt = attachment.attempts[0]; + expect(attempt).toBeDefined(); + if (!attempt) { + throw new Error("expected failed audio decision attempt"); + } + expect(attempt.outcome).toBe("failed"); + expect(attempt.reason).toContain("upstream 500"); }, }); }); From 41c3a541c210c2681b71b48c784109b8356e6ec8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:32:55 +0100 Subject: [PATCH 560/806] test: tighten vision skip assertions --- .../runner.vision-skip.test.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 7cf19e91caa..24b27af5c18 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -97,6 +97,26 @@ function setCompatibleActiveMediaUnderstandingRegistry( setActivePluginRegistry(pluginRegistry, cacheKey); } +type CapabilityResult = Awaited>; + +function requireDecisionAttachment(result: CapabilityResult, index: number) { + const attachment = result.decision.attachments[index]; + expect(attachment).toBeDefined(); + if (!attachment) { + throw new Error(`expected media-understanding decision attachment ${index}`); + } + return attachment; +} + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + expect(output).toBeDefined(); + if (!output) { + throw new Error(`expected media-understanding output ${index}`); + } + return output; +} + describe("runCapability image skip", () => { beforeAll(async () => { vi.doMock("../agents/model-catalog.js", async () => { @@ -138,11 +158,15 @@ describe("runCapability image skip", () => { expect(result.outputs).toHaveLength(0); expect(result.decision.outcome).toBe("skipped"); expect(result.decision.attachments).toHaveLength(1); - expect(result.decision.attachments[0]?.attachmentIndex).toBe(0); - expect(result.decision.attachments[0]?.attempts[0]?.outcome).toBe("skipped"); - expect(result.decision.attachments[0]?.attempts[0]?.reason).toBe( - "primary model supports vision natively", - ); + const attachment = requireDecisionAttachment(result, 0); + expect(attachment.attachmentIndex).toBe(0); + const attempt = attachment.attempts[0]; + expect(attempt).toBeDefined(); + if (!attempt) { + throw new Error("expected media-understanding skipped attempt"); + } + expect(attempt.outcome).toBe("skipped"); + expect(attempt.reason).toBe("primary model supports vision natively"); } finally { await cache.cleanup(); } @@ -385,9 +409,10 @@ describe("runCapability image skip", () => { }); expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("openrouter"); - expect(result.outputs[0]?.model).toBe("auto"); - expect(result.outputs[0]?.text).toBe("openrouter ok"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("openrouter"); + expect(output.model).toBe("auto"); + expect(output.text).toBe("openrouter ok"); expect(seenModel).toBe("auto"); }, ); From ffb1cc97cb355b155a38a1548555b33f5d6f8517 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:32:51 +0100 Subject: [PATCH 561/806] test: require messaging async callbacks --- extensions/canvas/src/host/server.test.ts | 5 ++++- extensions/line/src/webhook-node.test.ts | 5 ++++- extensions/signal/src/monitor.tool-result.autostart.test.ts | 5 ++++- .../whatsapp/src/auto-reply/monitor/last-route.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index 530f1923533..af5dec286b7 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -260,7 +260,7 @@ describe("canvas host", () => { const dir = await createCaseDir(); const index = path.join(dir, "index.html"); await fs.writeFile(index, "v1", "utf8"); - let resolveReload!: () => void; + let resolveReload: (() => void) | undefined; const reloadSent = new Promise((resolve) => { resolveReload = resolve; }); @@ -306,6 +306,9 @@ describe("canvas host", () => { send: (message: string) => { ws.sent.push(message); if (message === "reload") { + if (!resolveReload) { + throw new Error("Expected Canvas reload resolver to be initialized"); + } resolveReload(); } }, diff --git a/extensions/line/src/webhook-node.test.ts b/extensions/line/src/webhook-node.test.ts index d229e6a439d..31d31ade96a 100644 --- a/extensions/line/src/webhook-node.test.ts +++ b/extensions/line/src/webhook-node.test.ts @@ -416,7 +416,7 @@ describe("createLineNodeWebhookHandler", () => { it("releases authenticated requests before event processing completes", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); - let releaseAuthenticated!: () => void; + let releaseAuthenticated: (() => void) | undefined; const bot = { handleWebhook: vi.fn( async () => @@ -444,6 +444,9 @@ describe("createLineNodeWebhookHandler", () => { }); expect(res.headersSent).toBe(false); + if (!releaseAuthenticated) { + throw new Error("Expected LINE authenticated request release callback to be initialized"); + } releaseAuthenticated(); await request; diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index be4d5ed4517..a8f74728e5f 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -157,7 +157,7 @@ describe("monitorSignalProvider autostart", () => { setSignalAutoStartConfig(); const abortController = new AbortController(); let exited = false; - let resolveExit!: (value: SignalDaemonExitEvent) => void; + let resolveExit: ((value: SignalDaemonExitEvent) => void) | undefined; const exitedPromise = new Promise((resolve) => { resolveExit = resolve; }); @@ -166,6 +166,9 @@ describe("monitorSignalProvider autostart", () => { return; } exited = true; + if (!resolveExit) { + throw new Error("Expected signal daemon exit resolver to be initialized"); + } resolveExit({ source: "process", code: null, signal: "SIGTERM" }); }); spawnSignalDaemonMock.mockReturnValueOnce( diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts index bd5a46c985d..8f1e5829c8f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts @@ -20,7 +20,7 @@ describe("trackBackgroundTask", () => { it("does not leak unhandled rejections when a tracked task fails", async () => { process.on("unhandledRejection", onUnhandledRejection); const backgroundTasks = new Set>(); - let rejectTask!: (reason?: unknown) => void; + let rejectTask: ((reason?: unknown) => void) | undefined; const task = new Promise((_resolve, reject) => { rejectTask = reject; }); @@ -28,6 +28,9 @@ describe("trackBackgroundTask", () => { trackBackgroundTask(backgroundTasks, task); expect(backgroundTasks.size).toBe(1); + if (!rejectTask) { + throw new Error("Expected tracked task reject callback to be initialized"); + } rejectTask(new Error("boom")); await waitForAsyncCallbacks(); From 64862c7ff58a1ca86533eb4f4158daa4cedc65fa Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:34:09 +0100 Subject: [PATCH 562/806] test: tighten media provider registry assertions --- .../provider-registry.test.ts | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/media-understanding/provider-registry.test.ts b/src/media-understanding/provider-registry.test.ts index 4a920d3660f..167dc32d08f 100644 --- a/src/media-understanding/provider-registry.test.ts +++ b/src/media-understanding/provider-registry.test.ts @@ -18,6 +18,18 @@ function createMediaProvider( return params; } +function requireMediaProvider( + registry: Map, + providerId: string, +): MediaUnderstandingProvider { + const provider = getMediaUnderstandingProvider(providerId, registry); + expect(provider).toBeDefined(); + if (!provider) { + throw new Error(`expected media-understanding provider ${providerId}`); + } + return provider; +} + describe("media-understanding provider registry", () => { beforeEach(() => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -32,8 +44,8 @@ describe("media-understanding provider registry", () => { const registry = buildMediaUnderstandingRegistry(); - expect(getMediaUnderstandingProvider("groq", registry)?.id).toBe("groq"); - expect(getMediaUnderstandingProvider("deepgram", registry)?.id).toBe("deepgram"); + expect(requireMediaProvider(registry, "groq").id).toBe("groq"); + expect(requireMediaProvider(registry, "deepgram").id).toBe("deepgram"); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "mediaUnderstandingProviders", cfg: undefined, @@ -47,7 +59,7 @@ describe("media-understanding provider registry", () => { const registry = buildMediaUnderstandingRegistry(); - expect(getMediaUnderstandingProvider("gemini", registry)?.id).toBe("google"); + expect(requireMediaProvider(registry, "gemini").id).toBe("google"); }); it("auto-registers media-understanding for config providers with image-capable models (#51392)", () => { @@ -96,11 +108,19 @@ describe("media-understanding provider registry", () => { } as never; const registry = buildMediaUnderstandingRegistry(undefined, cfg); - const provider = getMediaUnderstandingProvider("google", registry); + const provider = requireMediaProvider(registry, "google"); - expect(provider?.capabilities).toEqual(["image", "audio", "video"]); - expect(await provider?.describeImage?.({} as never)).toEqual({ text: "plugin image" }); - expect(await provider?.transcribeAudio?.({} as never)).toEqual({ text: "plugin audio" }); + expect(provider.capabilities).toEqual(["image", "audio", "video"]); + expect(provider.describeImage).toBeTypeOf("function"); + if (!provider.describeImage) { + throw new Error("expected google describeImage provider hook"); + } + expect(provider.transcribeAudio).toBeTypeOf("function"); + if (!provider.transcribeAudio) { + throw new Error("expected google transcribeAudio provider hook"); + } + expect(await provider.describeImage({} as never)).toEqual({ text: "plugin image" }); + expect(await provider.transcribeAudio({} as never)).toEqual({ text: "plugin audio" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "mediaUnderstandingProviders", cfg, From 1b15116aa83709103c5c0674c8ec757c81b09024 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:35:24 +0100 Subject: [PATCH 563/806] test: require telegram async callbacks --- .../telegram/src/bot-message-dispatch.test.ts | 10 ++++++++-- .../telegram/src/bot.create-telegram-bot.test.ts | 15 ++++++++++++--- extensions/telegram/src/bot.test.ts | 5 ++++- extensions/telegram/src/monitor.test.ts | 5 ++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b64f1e2bb24..b1cbc798e7c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1304,11 +1304,11 @@ describe("dispatchTelegramMessage draft streaming", () => { }); it("does not supersede the same session for unauthorized abort-looking commands", async () => { - let releaseFirstFinal!: () => void; + let releaseFirstFinal: (() => void) | undefined; const firstFinalGate = new Promise((resolve) => { releaseFirstFinal = resolve; }); - let resolveStreamVisible!: () => void; + let resolveStreamVisible: (() => void) | undefined; const streamVisible = new Promise((resolve) => { resolveStreamVisible = resolve; }); @@ -1317,6 +1317,9 @@ describe("dispatchTelegramMessage draft streaming", () => { messageId: 1001, onUpdate: (text) => { if (text === "Old reply partial") { + if (!resolveStreamVisible) { + throw new Error("Expected Telegram stream-visible resolver to be initialized"); + } resolveStreamVisible(); } }, @@ -1367,6 +1370,9 @@ describe("dispatchTelegramMessage draft streaming", () => { await unauthorizedReplyDelivered; + if (!releaseFirstFinal) { + throw new Error("Expected first Telegram final release callback to be initialized"); + } releaseFirstFinal(); await Promise.all([firstPromise, unauthorizedPromise]); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 3a15b146a08..0884886b941 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -356,7 +356,7 @@ describe("createTelegramBot", () => { }); const events: string[] = []; - let releaseTopicTurn!: () => void; + let releaseTopicTurn: (() => void) | undefined; const topicGate = new Promise((resolve) => { releaseTopicTurn = resolve; }); @@ -395,6 +395,9 @@ describe("createTelegramBot", () => { expect(events).toEqual(["busy:start", "status"]); + if (!releaseTopicTurn) { + throw new Error("Expected Telegram topic turn release callback to be initialized"); + } releaseTopicTurn(); await busyPromise; expect(events).toEqual(["busy:start", "status", "busy:end"]); @@ -413,7 +416,7 @@ describe("createTelegramBot", () => { }); const startedBodies: string[] = []; - let releaseFirstTurn!: () => void; + let releaseFirstTurn: (() => void) | undefined; const firstTurnGate = new Promise((resolve) => { releaseFirstTurn = resolve; }); @@ -470,6 +473,9 @@ describe("createTelegramBot", () => { expect(startedBodies[0]).toContain("first message"); expect(sendMessageSpy).not.toHaveBeenCalled(); + if (!releaseFirstTurn) { + throw new Error("Expected first Telegram turn release callback to be initialized"); + } releaseFirstTurn(); await Promise.all([firstPromise, secondPromise]); @@ -523,7 +529,7 @@ describe("createTelegramBot", () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const startedBodies: string[] = []; - let releaseFirstRun!: () => void; + let releaseFirstRun: (() => void) | undefined; const firstRunGate = new Promise((resolve) => { releaseFirstRun = resolve; }); @@ -617,6 +623,9 @@ describe("createTelegramBot", () => { expect(startedBodies).toHaveLength(1); expect(sendMessageSpy).not.toHaveBeenCalled(); + if (!releaseFirstRun) { + throw new Error("Expected first Telegram run release callback to be initialized"); + } releaseFirstRun(); await Promise.all([firstFlush, secondFlush]); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index cfc7e180bd6..b31f9584c3e 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -50,10 +50,13 @@ const EYES_EMOJI = "\u{1F440}"; const HEART_EMOJI = "\u{2764}\u{FE0F}"; function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Telegram bot signal resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 75ea412935c..0383c56a0d3 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -170,10 +170,13 @@ const makeAbortRunner = (abort: AbortController, beforeAbort?: () => void): Runn makeRunnerStub({ task: createAbortTask(abort, beforeAbort) }); function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Telegram monitor signal resolver to be initialized"); + } return { promise, resolve }; } From 7765b1f91f95836f6adf0585254009686f61818b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:35:56 +0100 Subject: [PATCH 564/806] test: tighten web fetch resolution assertions --- src/web-fetch/runtime.test.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/web-fetch/runtime.test.ts b/src/web-fetch/runtime.test.ts index e413929986f..5f19abc7d48 100644 --- a/src/web-fetch/runtime.test.ts +++ b/src/web-fetch/runtime.test.ts @@ -71,6 +71,20 @@ function createFirecrawlPluginConfig(apiKey: unknown): OpenClawConfig { }; } +type ResolvedWebFetchDefinition = NonNullable< + ReturnType["resolveWebFetchDefinition"]> +>; + +function requireResolvedWebFetch( + resolved: ReturnType["resolveWebFetchDefinition"]>, +): ResolvedWebFetchDefinition { + expect(resolved).toBeDefined(); + if (!resolved) { + throw new Error("expected resolved web fetch definition"); + } + return resolved; +} + describe("web fetch runtime", () => { let resolveWebFetchDefinition: typeof import("./runtime.js").resolveWebFetchDefinition; let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot; @@ -136,9 +150,10 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("firecrawl"); + const webFetch = requireResolvedWebFetch(resolved); + expect(webFetch.provider.id).toBe("firecrawl"); await expect( - resolved?.definition.execute({ + webFetch.definition.execute({ url: "https://example.com", extractMode: "markdown", maxChars: 1000, @@ -160,7 +175,7 @@ describe("web fetch runtime", () => { config: {}, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("falls back to auto-detect when the configured provider is invalid", () => { @@ -181,7 +196,7 @@ describe("web fetch runtime", () => { } as OpenClawConfig, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("keeps sandboxed web fetch on bundled providers even when runtime providers are preferred", () => { @@ -198,7 +213,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("uses runtime providers for non-sandboxed web fetch when runtime providers are preferred", () => { @@ -215,7 +230,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); it("resolves an explicitly configured non-bundled provider from plugin providers", () => { @@ -233,7 +248,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: false, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); it("prefers an explicitly configured non-bundled provider over runtime metadata", () => { @@ -257,6 +272,6 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); }); From 4239c150854fb41a4c37e11c4ec83bf3f9037305 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:37:00 +0100 Subject: [PATCH 565/806] test: tighten secrets plan assertions --- src/secrets/plan.test.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index d74fca86bec..81a94820e27 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -10,6 +10,18 @@ import { } from "../test-utils/talk-test-provider.js"; import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js"; +type ValidatedPlanTarget = NonNullable>; + +function requireValidatedPlanTarget( + resolved: ReturnType, +): ValidatedPlanTarget { + expect(resolved).not.toBeNull(); + if (!resolved) { + throw new Error("expected validated secrets plan target"); + } + return resolved; +} + describe("secrets plan validation", () => { it("accepts legacy provider target types", () => { const resolved = resolveValidatedPlanTarget({ @@ -18,7 +30,12 @@ describe("secrets plan validation", () => { pathSegments: ["models", "providers", "openai", "apiKey"], providerId: "openai", }); - expect(resolved?.pathSegments).toEqual(["models", "providers", "openai", "apiKey"]); + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ + "models", + "providers", + "openai", + "apiKey", + ]); }); it("accepts expanded target types beyond legacy surface", () => { @@ -27,7 +44,11 @@ describe("secrets plan validation", () => { path: "channels.telegram.botToken", pathSegments: ["channels", "telegram", "botToken"], }); - expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ + "channels", + "telegram", + "botToken", + ]); }); it("accepts model provider header targets with wildcard-backed paths", () => { @@ -37,7 +58,7 @@ describe("secrets plan validation", () => { pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], providerId: "openai", }); - expect(resolved?.pathSegments).toEqual([ + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ "models", "providers", "openai", From beff4dfb5842a35a442f91cd7709778dbae36483 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:37:24 +0100 Subject: [PATCH 566/806] test: require qqbot queue callbacks --- .../qqbot/src/engine/gateway/message-queue.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts index 77277430ae2..601f449821c 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.test.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.test.ts @@ -178,7 +178,7 @@ describe("engine/gateway/message-queue", () => { it("group overflow drops bot messages first (via processor)", async () => { const seen: QueuedMessage[] = []; - let gate!: (value?: unknown) => void; + let gate: ((value?: unknown) => void) | undefined; const blocker = new Promise((res) => { gate = res; }); @@ -205,6 +205,9 @@ describe("engine/gateway/message-queue", () => { const peerQueueIds = q.getSnapshot("group:G1"); expect(peerQueueIds.senderPending).toBe(3); // Release the processor and drain. + if (!gate) { + throw new Error("Expected QQBot queue gate callback to be initialized"); + } gate(); await new Promise((res) => setTimeout(res, 0)); const seenIds = seen.map((m) => m.messageId); @@ -226,7 +229,7 @@ describe("engine/gateway/message-queue", () => { // Use a processor that never resolves so enqueued messages stay // buffered behind a single active worker — then clearUserQueue // should drop the rest. - let release!: () => void; + let release: (() => void) | undefined; const blocker = new Promise((res) => { release = res; }); @@ -241,6 +244,9 @@ describe("engine/gateway/message-queue", () => { expect(q.getSnapshot("group:G1").senderPending).toBeGreaterThanOrEqual(0); const dropped = q.clearUserQueue("group:G1"); expect(dropped).toBeGreaterThanOrEqual(0); + if (!release) { + throw new Error("Expected QQBot queue release callback to be initialized"); + } release(); }); }); From d213397b1de70afe21d3d962e29f2ae4e5257010 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:38:09 +0100 Subject: [PATCH 567/806] test: tighten channel secret contract assertions --- .../channel-contract-api.external.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 1f58fe08091..ffe48c21bfc 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -33,6 +33,18 @@ vi.mock("../plugins/hardlink-policy.js", () => ({ import { loadChannelSecretContractApi } from "./channel-contract-api.js"; +type ChannelSecretContractApi = NonNullable>; + +function requireChannelSecretContractApi( + api: ReturnType, +): ChannelSecretContractApi { + expect(api).toBeDefined(); + if (!api) { + throw new Error("expected channel secret contract API"); + } + return api; +} + function channelSecretContractModuleSource(channelId: string) { return ` module.exports = { @@ -102,14 +114,15 @@ describe("external channel secret contract api", () => { loadablePluginOrigins: new Map([["discord", "global"]]), }); - expect(api?.secretTargetRegistryEntries).toEqual( + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "channels.discord.token", }), ]), ); - expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + expect(contractApi.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); it("loads dist/ secret-contract-api sidecars for compiled npm-published external channel plugins", () => { @@ -138,14 +151,15 @@ describe("external channel secret contract api", () => { loadablePluginOrigins: new Map([["discord", "global"]]), }); - expect(api?.secretTargetRegistryEntries).toEqual( + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "channels.discord.token", }), ]), ); - expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + expect(contractApi.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); it.runIf(process.platform !== "win32")( @@ -185,7 +199,8 @@ describe("external channel secret contract api", () => { rootDir, env, }); - expect(api?.secretTargetRegistryEntries).toEqual( + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "channels.discord.token", From 7e8ac5e6fb863ed2499279130183ec305c5db6fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:38:58 +0100 Subject: [PATCH 568/806] test: require discord async callbacks --- extensions/discord/src/channel.test.ts | 17 +++++++++++------ .../src/monitor/message-handler.process.test.ts | 5 ++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b2c0d927193..d08bffa8bc8 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -467,12 +467,14 @@ describe("discordPlugin outbound", () => { }); it("does not block Discord monitor startup on the startup probe", async () => { - let resolveProbe!: (value: { - ok: true; - bot: { username: string }; - application: { intents: { messageContent: "limited" } }; - elapsedMs: number; - }) => void; + let resolveProbe: + | ((value: { + ok: true; + bot: { username: string }; + application: { intents: { messageContent: "limited" } }; + elapsedMs: number; + }) => void) + | undefined; probeDiscordMock.mockReturnValue( new Promise((resolve) => { resolveProbe = resolve; @@ -503,6 +505,9 @@ describe("discordPlugin outbound", () => { ); expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]); + if (!resolveProbe) { + throw new Error("Expected Discord startup probe resolver to be initialized"); + } resolveProbe({ ok: true, bot: { username: "AsyncBob" }, diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 9e0ead5762d..a1779f93f46 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -716,7 +716,7 @@ describe("processDiscordMessage ack reactions", () => { it("shows stall emojis for long no-progress runs", async () => { vi.useFakeTimers(); - let releaseDispatch!: () => void; + let releaseDispatch: (() => void) | undefined; const dispatchGate = new Promise((resolve) => { releaseDispatch = () => resolve(); }); @@ -729,6 +729,9 @@ describe("processDiscordMessage ack reactions", () => { const runPromise = runProcessDiscordMessage(ctx); await vi.advanceTimersByTimeAsync(30_001); + if (!releaseDispatch) { + throw new Error("Expected Discord dispatch release callback to be initialized"); + } releaseDispatch(); await vi.runAllTimersAsync(); From a9e322c4c142424e4755e91c0a90df278748325a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:39:10 +0100 Subject: [PATCH 569/806] test: tighten secret target registry assertion --- src/secrets/target-registry.fast-path.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index d9ac40bfec0..21d338eb75d 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -53,8 +53,12 @@ describe("secret target registry fast path", () => { it("resolves bundled channel targets by explicit channel id without manifest scans", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); - expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + expect(target).toBeDefined(); + if (!target) { + throw new Error("expected googlechat service account target"); + } + expect(target.entry.id).toBe("channels.googlechat.serviceAccount"); + expect(target.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "googlechat", artifactBasename: "secret-contract-api.js", From 950cdfdaf4f50a0ac5412dc5a247c98e6060deae Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:40:30 +0100 Subject: [PATCH 570/806] test: tighten runtime web tools assertions --- src/secrets/runtime-web-tools-state.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/secrets/runtime-web-tools-state.test.ts b/src/secrets/runtime-web-tools-state.test.ts index 6cd11c946c9..6cfe2f41875 100644 --- a/src/secrets/runtime-web-tools-state.test.ts +++ b/src/secrets/runtime-web-tools-state.test.ts @@ -27,17 +27,20 @@ describe("runtime web tools state", () => { }); const first = getActiveRuntimeWebToolsMetadata(); - expect(first?.search.providerConfigured).toBe("gemini"); - expect(first?.search.selectedProvider).toBe("gemini"); - expect(first?.search.selectedProviderKeySource).toBe("secretRef"); if (!first) { throw new Error("missing runtime web tools metadata"); } + expect(first.search.providerConfigured).toBe("gemini"); + expect(first.search.selectedProvider).toBe("gemini"); + expect(first.search.selectedProviderKeySource).toBe("secretRef"); first.search.providerConfigured = "brave"; first.search.selectedProvider = "brave"; const second = getActiveRuntimeWebToolsMetadata(); - expect(second?.search.providerConfigured).toBe("gemini"); - expect(second?.search.selectedProvider).toBe("gemini"); + if (!second) { + throw new Error("missing cloned runtime web tools metadata"); + } + expect(second.search.providerConfigured).toBe("gemini"); + expect(second.search.selectedProvider).toBe("gemini"); }); }); From ae2338b744b82b1853d0cb7f66f67ab29d0356fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:41:49 +0100 Subject: [PATCH 571/806] test: require core ui async callbacks --- src/config/runtime-snapshot.test.ts | 5 ++++- src/cron/isolated-agent/run.skill-filter.test.ts | 5 ++++- src/infra/skills-remote.test.ts | 5 ++++- ui/src/ui/app-render.helpers.node.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/config/runtime-snapshot.test.ts b/src/config/runtime-snapshot.test.ts index 02cb30f0723..d670f428bfe 100644 --- a/src/config/runtime-snapshot.test.ts +++ b/src/config/runtime-snapshot.test.ts @@ -225,7 +225,7 @@ describe("runtime snapshot state", () => { const loadFreshConfig = vi.fn<() => OpenClawConfig>(() => ({ gateway: { auth: { mode: "token" } }, })); - let releaseRefresh!: () => void; + let releaseRefresh: (() => void) | undefined; const refreshPending = new Promise((resolve) => { releaseRefresh = () => resolve(true); }); @@ -287,6 +287,9 @@ describe("runtime snapshot state", () => { expect(getRuntimeConfigSnapshot()?.gateway?.auth).toBeUndefined(); expect(loadFreshConfig).not.toHaveBeenCalled(); + if (!releaseRefresh) { + throw new Error("Expected runtime snapshot refresh release callback to be initialized"); + } releaseRefresh(); await writePromise; diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 10f515c8530..188da219087 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -267,7 +267,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { describe("CLI session handoff (issue #29774)", () => { it("passes the cron abort signal to CLI runs and drops late CLI results", async () => { const abortController = new AbortController(); - let markCliStarted!: () => void; + let markCliStarted: (() => void) | undefined; const cliStarted = new Promise((resolve) => { markCliStarted = resolve; }); @@ -275,6 +275,9 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isCliProviderMock.mockReturnValue(true); runCliAgentMock.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { expect(params.abortSignal).toBe(abortController.signal); + if (!markCliStarted) { + throw new Error("Expected CLI start marker callback to be initialized"); + } markCliStarted(); await new Promise((resolve) => { params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); diff --git a/src/infra/skills-remote.test.ts b/src/infra/skills-remote.test.ts index 11acd3f62e8..25dc56b63f6 100644 --- a/src/infra/skills-remote.test.ts +++ b/src/infra/skills-remote.test.ts @@ -225,7 +225,7 @@ describe("skills-remote", () => { const nodeId = `node-${randomUUID()}`; const bin = `bin-${randomUUID()}`; let invokeCount = 0; - let releaseProbe!: () => void; + let releaseProbe: (() => void) | undefined; const probeStarted = new Promise((resolve) => { setSkillsRemoteRegistry({ listConnected: () => [], @@ -286,6 +286,9 @@ describe("skills-remote", () => { cfg, timeoutMs: 10, }); + if (!releaseProbe) { + throw new Error("Expected remote skill probe release callback to be initialized"); + } releaseProbe(); await Promise.all([first, second]); diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 8a04237c500..89efb30e61c 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -661,7 +661,7 @@ describe("handleChatManualRefresh", () => { }), }); try { - let resolveRefresh!: () => void; + let resolveRefresh: (() => void) | undefined; refreshChatMock.mockReturnValueOnce( new Promise((resolve) => { resolveRefresh = resolve; @@ -679,6 +679,9 @@ describe("handleChatManualRefresh", () => { await Promise.resolve(); expect(state.scrollToBottom).not.toHaveBeenCalled(); + if (!resolveRefresh) { + throw new Error("Expected chat refresh resolver to be initialized"); + } resolveRefresh(); await run; From 7a877750b4532ef5d602ad4c0d3ab062620cd289 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:42:11 +0100 Subject: [PATCH 572/806] test: tighten plugin config collector assertions --- .../runtime-config-collectors-plugins.test.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index 34a6d29d5b3..a6c582e3e6b 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -40,6 +40,17 @@ function loadablePluginOrigins(entries: Array<[string, PluginOrigin]>) { return new Map(entries); } +type RuntimeConfigAssignment = ResolverContext["assignments"][number]; + +function requireAssignment(context: ResolverContext, index: number): RuntimeConfigAssignment { + const assignment = context.assignments[index]; + expect(assignment).toBeDefined(); + if (!assignment) { + throw new Error(`expected runtime config assignment ${index}`); + } + return assignment; +} + function createAcpxMcpSecretConfig(params: { plugins?: Record; entry?: Record; @@ -141,10 +152,9 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(1); - expect(context.assignments[0]?.path).toBe( - "plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN", - ); - expect(context.assignments[0]?.expected).toBe("string"); + const assignment = requireAssignment(context, 0); + expect(assignment.path).toBe("plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN"); + expect(assignment.expected).toBe("string"); }); it("resolves assignments via apply callback", () => { @@ -177,7 +187,7 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(1); - context.assignments[0]?.apply("resolved-key-value"); + requireAssignment(context, 0).apply("resolved-key-value"); const entries = config.plugins?.entries as Record>; const mcpServers = (entries?.acpx?.config as Record)?.mcpServers as Record< @@ -185,7 +195,11 @@ describe("collectPluginConfigAssignments", () => { Record >; const env = mcpServers?.mcp1?.env as Record; - expect(env?.API_KEY).toBe("resolved-key-value"); + expect(env).toBeDefined(); + if (!env) { + throw new Error("expected acpx mcp env config"); + } + expect(env.API_KEY).toBe("resolved-key-value"); }); it("collects across multiple acpx servers only", () => { @@ -382,10 +396,10 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(2); - expect(context.assignments[0]?.path).toBe( + expect(requireAssignment(context, 0).path).toBe( "plugins.entries.acpx.config.mcpServers.s1.env.INLINE", ); - expect(context.assignments[1]?.path).toBe( + expect(requireAssignment(context, 1).path).toBe( "plugins.entries.acpx.config.mcpServers.s1.env.SECOND", ); }); From 1eb876ff8fcae55a8d9ab853a37219da513c37dc Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:43:41 +0100 Subject: [PATCH 573/806] test: tighten zalo token runtime assertions --- src/secrets/runtime-zalo-token-activity.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/secrets/runtime-zalo-token-activity.test.ts b/src/secrets/runtime-zalo-token-activity.test.ts index 2658979468c..be53d7f96ff 100644 --- a/src/secrets/runtime-zalo-token-activity.test.ts +++ b/src/secrets/runtime-zalo-token-activity.test.ts @@ -8,6 +8,15 @@ import { const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireZaloConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.zalo; + expect(config).toBeDefined(); + if (!config) { + throw new Error("expected Zalo runtime config"); + } + return config; +} + describe("secrets runtime snapshot zalo token activity", () => { it("treats top-level Zalo botToken refs as active even when tokenFile is configured", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -26,7 +35,7 @@ describe("secrets runtime snapshot zalo token activity", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-token"); + expect(requireZaloConfig(snapshot).botToken).toBe("resolved-zalo-token"); expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( "channels.zalo.botToken", ); @@ -83,7 +92,7 @@ describe("secrets runtime snapshot zalo token activity", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-top-level-token"); + expect(requireZaloConfig(snapshot).botToken).toBe("resolved-zalo-top-level-token"); expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( "channels.zalo.botToken", ); From 7011bbb953916d8461a8ee35610f60e667f8eee2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:43:57 +0100 Subject: [PATCH 574/806] test: require logging async callbacks --- .../diagnostic-stuck-session-recovery.runtime.test.ts | 5 ++++- src/logging/diagnostic.test.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts index bd719460a46..c3d5a241681 100644 --- a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts +++ b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts @@ -324,7 +324,7 @@ describe("stuck session recovery", () => { }); it("coalesces duplicate recovery attempts for the same session", async () => { - let resolveWait!: (value: boolean) => void; + let resolveWait: ((value: boolean) => void) | undefined; const waitPromise = new Promise((resolve) => { resolveWait = resolve; }); @@ -346,6 +346,9 @@ describe("stuck session recovery", () => { }); expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledTimes(1); + if (!resolveWait) { + throw new Error("Expected diagnostic recovery wait resolver to be initialized"); + } resolveWait(true); await first; }); diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 79b371316d4..12b71fbe89b 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -903,7 +903,7 @@ describe("stuck session diagnostics threshold", () => { const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); const events: DiagnosticEventPayload[] = []; const unsubscribe = onDiagnosticEvent((event) => events.push(event)); - let finishPhase!: () => void; + let finishPhase: (() => void) | undefined; const phase = withDiagnosticPhase( "startup.plugins.load", () => @@ -911,6 +911,10 @@ describe("stuck session diagnostics threshold", () => { finishPhase = resolve; }), ); + if (!finishPhase) { + throw new Error("Expected diagnostic phase finish callback to be initialized"); + } + const completePhase = finishPhase; try { startDiagnosticHeartbeat( @@ -933,7 +937,7 @@ describe("stuck session diagnostics threshold", () => { logMessageQueued({ sessionId: "s1", sessionKey: "main", source: "telegram" }); vi.advanceTimersByTime(30_000); } finally { - finishPhase(); + completePhase(); await phase; unsubscribe(); } From b7359a74a75f9e06c43100af4907ee08307ee440 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:44:50 +0100 Subject: [PATCH 575/806] test: tighten telegram inactive runtime assertion --- .../runtime-inactive-telegram-surfaces.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/secrets/runtime-inactive-telegram-surfaces.test.ts b/src/secrets/runtime-inactive-telegram-surfaces.test.ts index 97139b50c1e..fb5b76003d4 100644 --- a/src/secrets/runtime-inactive-telegram-surfaces.test.ts +++ b/src/secrets/runtime-inactive-telegram-surfaces.test.ts @@ -4,6 +4,17 @@ import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-s const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireTelegramConfig( + snapshot: Awaited>, +) { + const config = snapshot.config.channels?.telegram; + expect(config).toBeDefined(); + if (!config) { + throw new Error("expected Telegram runtime config"); + } + return config; +} + describe("secrets runtime snapshot inactive telegram surfaces", () => { it("skips inactive Telegram refs and emits diagnostics", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -29,7 +40,7 @@ describe("secrets runtime snapshot inactive telegram surfaces", () => { loadablePluginOrigins: new Map(), }); - expect(snapshot.config.channels?.telegram?.botToken).toEqual({ + expect(requireTelegramConfig(snapshot).botToken).toEqual({ source: "env", provider: "default", id: "DISABLED_TELEGRAM_BASE_TOKEN", From 1359d09e0581d451e10e3a62d9f74f8100eb2902 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:46:03 +0100 Subject: [PATCH 576/806] test: tighten matrix shadowing assertion --- src/secrets/runtime-matrix-shadowing.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/secrets/runtime-matrix-shadowing.test.ts b/src/secrets/runtime-matrix-shadowing.test.ts index b1944a69649..6dc68380b2e 100644 --- a/src/secrets/runtime-matrix-shadowing.test.ts +++ b/src/secrets/runtime-matrix-shadowing.test.ts @@ -8,6 +8,15 @@ import { const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireMatrixConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.matrix; + expect(config).toBeDefined(); + if (!config) { + throw new Error("expected Matrix runtime config"); + } + return config; +} + describe("secrets runtime snapshot matrix shadowing", () => { it("ignores Matrix password refs that are shadowed by scoped env access tokens", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -121,7 +130,7 @@ describe("secrets runtime snapshot matrix shadowing", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.matrix?.password).toEqual({ + expect(requireMatrixConfig(snapshot).password).toEqual({ source: "env", provider: "default", id: "MATRIX_PASSWORD", From e8023c85a7937b027b20de480d84dd091e0d1c28 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:48:02 +0100 Subject: [PATCH 577/806] test: tighten secrets fast path assertion --- src/secrets/runtime.fast-path.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index b8fae79cc30..577f5b16e7d 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -38,6 +38,17 @@ function emptyAuthStore(): AuthProfileStore { return { version: 1, profiles: {} }; } +function requireGatewayAuth( + snapshot: Awaited>, +) { + const auth = snapshot.config.gateway?.auth; + expect(auth).toBeDefined(); + if (!auth) { + throw new Error("expected gateway auth config"); + } + return auth; +} + describe("secrets runtime fast path", () => { afterEach(() => { runtimePrepareImportMock.mockClear(); @@ -67,7 +78,7 @@ describe("secrets runtime fast path", () => { }); expect(runtimePrepareImportMock).not.toHaveBeenCalled(); - expect(snapshot.config.gateway?.auth?.token).toBe("plain-startup-token"); + expect(requireGatewayAuth(snapshot).token).toBe("plain-startup-token"); expect(snapshot.authStores).toEqual([ { agentDir: "/tmp/openclaw-agent-main", From 8f52e77ca1d1fa26e6a80024be87150af809384e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:46:10 +0100 Subject: [PATCH 578/806] test: require gateway async callbacks --- src/gateway/auth.test.ts | 5 +++- src/gateway/call.test.ts | 29 ++++++++++++++------- src/gateway/server-runtime-services.test.ts | 7 ++++- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index d3bd2966a8d..e53f8a61721 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -384,7 +384,7 @@ describe("gateway auth", () => { lockoutMs: 60_000, exemptLoopback: false, }); - let releaseWhois!: () => void; + let releaseWhois: (() => void) | undefined; const whoisGate = new Promise((resolve) => { releaseWhois = resolve; }); @@ -408,6 +408,9 @@ describe("gateway auth", () => { const first = authorizeGatewayConnect(baseParams); const second = authorizeGatewayConnect(baseParams); + if (!releaseWhois) { + throw new Error("Expected Tailscale whois release callback to be initialized"); + } releaseWhois(); const [firstResult, secondResult] = await Promise.all([first, second]); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index e0cc2b8029c..bb6922887b9 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -614,13 +614,15 @@ describe("callGateway url resolution", () => { it("waits for event-loop readiness before starting CLI pairing requests", async () => { setLocalLoopbackGatewayConfig(); - let resolveReady!: (result: { - ready: boolean; - elapsedMs: number; - maxDriftMs: number; - checks: number; - aborted: boolean; - }) => void; + let resolveReady: + | ((result: { + ready: boolean; + elapsedMs: number; + maxDriftMs: number; + checks: number; + aborted: boolean; + }) => void) + | undefined; eventLoopReadyState.promise = new Promise((resolve) => { resolveReady = resolve; }); @@ -638,6 +640,9 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.CLI); expect(startCalls).toBe(0); + if (!resolveReady) { + throw new Error("Expected gateway event-loop readiness resolver to be initialized"); + } resolveReady({ ready: true, elapsedMs: 0, maxDriftMs: 0, checks: 2, aborted: false }); await promise; @@ -1064,7 +1069,7 @@ describe("callGateway error details", () => { it("waits for gateway client teardown before resolving", async () => { setLocalLoopbackGatewayConfig(); - let releaseStop!: () => void; + let releaseStop: (() => void) | undefined; let stopStarted = false; let stopFinished = false; let callResolved = false; @@ -1116,6 +1121,9 @@ describe("callGateway error details", () => { }); expect(callResolved).toBe(false); + if (!releaseStop) { + throw new Error("Expected gateway stop release callback to be initialized"); + } releaseStop(); await promise; @@ -1127,7 +1135,7 @@ describe("callGateway error details", () => { setLocalLoopbackGatewayConfig(); vi.useFakeTimers(); - let releaseStop!: () => void; + let releaseStop: (() => void) | undefined; let stopStarted = false; __testing.setDepsForTests({ @@ -1173,6 +1181,9 @@ describe("callGateway error details", () => { await vi.advanceTimersByTimeAsync(5); + if (!releaseStop) { + throw new Error("Expected gateway stop release callback to be initialized"); + } releaseStop(); await expect(promise).resolves.toEqual({ ok: true }); diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 72dca20d540..52fceb8d9df 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -268,7 +268,9 @@ describe("server-runtime-services", () => { it("clears delayed maintenance handles when close starts during maintenance startup", async () => { vi.useFakeTimers(); let closing = false; - let resolveMaintenance!: (maintenance: ReturnType) => void; + let resolveMaintenance: + | ((maintenance: ReturnType) => void) + | undefined; const startMaintenance = vi.fn( () => new Promise>((resolve) => { @@ -294,6 +296,9 @@ describe("server-runtime-services", () => { expect(startMaintenance).toHaveBeenCalledTimes(1); closing = true; + if (!resolveMaintenance) { + throw new Error("Expected gateway maintenance resolver to be initialized"); + } resolveMaintenance(createMaintenanceHandles()); await Promise.resolve(); await Promise.resolve(); From 83fa0cda3b0fc69cb53db310c68710ba9529c71a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:49:20 +0100 Subject: [PATCH 579/806] test: tighten external channel runtime assertion --- .../runtime-external-channel-origin-discovery.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/secrets/runtime-external-channel-origin-discovery.test.ts b/src/secrets/runtime-external-channel-origin-discovery.test.ts index dd5eda45c3e..27cf07f3a8f 100644 --- a/src/secrets/runtime-external-channel-origin-discovery.test.ts +++ b/src/secrets/runtime-external-channel-origin-discovery.test.ts @@ -20,6 +20,15 @@ import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-s const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireDiscordConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.discord; + expect(config).toBeDefined(); + if (!config) { + throw new Error("expected Discord runtime config"); + } + return config; +} + describe("secrets runtime external channel origin discovery", () => { it("discovers loadable plugins for channel SecretRefs when plugins.entries is absent", async () => { loadPluginMetadataSnapshotMock.mockReturnValue({ @@ -68,7 +77,7 @@ describe("secrets runtime external channel origin discovery", () => { includeAuthStoreRefs: false, }); - expect(snapshot.config.channels?.discord?.token).toBe("resolved-discord-token"); + expect(requireDiscordConfig(snapshot).token).toBe("resolved-discord-token"); expect(loadPluginMetadataSnapshotMock).toHaveBeenCalled(); expect(loadChannelSecretContractApiMock).toHaveBeenCalledWith( expect.objectContaining({ From b3aea2eab8c4ec9363a29adb0b76b88435bf1e18 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:50:41 +0100 Subject: [PATCH 580/806] test: tighten provider env metadata assertion --- src/secrets/provider-env-vars.dynamic.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/secrets/provider-env-vars.dynamic.test.ts b/src/secrets/provider-env-vars.dynamic.test.ts index 3454625dd0b..88fcc4c4aaa 100644 --- a/src/secrets/provider-env-vars.dynamic.test.ts +++ b/src/secrets/provider-env-vars.dynamic.test.ts @@ -63,6 +63,15 @@ const pluginRegistryMocks = vi.hoisted(() => { }; }); +function requireLastMetadataSnapshotCall(): unknown[] { + const call = pluginRegistryMocks.loadPluginMetadataSnapshot.mock.calls.at(-1); + expect(call).toBeDefined(); + if (!call) { + throw new Error("expected plugin metadata snapshot call"); + } + return call; +} + vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: pluginRegistryMocks.getCurrentPluginMetadataSnapshot, })); @@ -180,7 +189,7 @@ describe("provider env vars dynamic manifest metadata", () => { source: "external cloud credentials", }, ]); - expect(pluginRegistryMocks.loadPluginMetadataSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ + expect(requireLastMetadataSnapshotCall()[0]).toMatchObject({ preferPersisted: false, }); }); From ae2ae469c2f630d73b3821858a3bba0c516786a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:50:27 +0100 Subject: [PATCH 581/806] test: require auto reply prep callbacks --- src/auto-reply/reply/get-reply-run.media-only.test.ts | 10 ++++++++-- src/auto-reply/reply/queue.drain-restart.test.ts | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index ae23f0160a1..a534bdd7a5f 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -931,7 +931,7 @@ describe("runPreparedReply media-only handling", () => { await import("../../agents/auth-profiles/session-override.js"); const queueSettings = await import("./queue/settings-runtime.js"); - let resolveAuth!: () => void; + let resolveAuth: (() => void) | undefined; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); @@ -957,6 +957,9 @@ describe("runPreparedReply media-only handling", () => { resetTriggered: false, }); intruderRun.setPhase("running"); + if (!resolveAuth) { + throw new Error("Expected auth profile resolver to be initialized"); + } resolveAuth(); await Promise.resolve(); @@ -1019,7 +1022,7 @@ describe("runPreparedReply media-only handling", () => { await import("../../agents/auth-profiles/session-override.js"); const queueSettings = await import("./queue/settings-runtime.js"); - let resolveAuth!: () => void; + let resolveAuth: (() => void) | undefined; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); @@ -1060,6 +1063,9 @@ describe("runPreparedReply media-only handling", () => { }; rotatedRun.updateSessionId("session-after-rotation"); + if (!resolveAuth) { + throw new Error("Expected auth profile resolver to be initialized"); + } resolveAuth(); await Promise.resolve(); diff --git a/src/auto-reply/reply/queue.drain-restart.test.ts b/src/auto-reply/reply/queue.drain-restart.test.ts index 588652db2dc..a68a6de6e28 100644 --- a/src/auto-reply/reply/queue.drain-restart.test.ts +++ b/src/auto-reply/reply/queue.drain-restart.test.ts @@ -210,7 +210,7 @@ describe("followup queue drain restart after idle window", () => { const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 }; const allProcessed = createDeferred(); - let runFollowupResolve!: () => void; + let runFollowupResolve: (() => void) | undefined; const runFollowupGate = new Promise((res) => { runFollowupResolve = res; }); @@ -225,6 +225,9 @@ describe("followup queue drain restart after idle window", () => { enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); scheduleFollowupDrain(key, runFollowup); enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); + if (!runFollowupResolve) { + throw new Error("Expected followup run release callback to be initialized"); + } runFollowupResolve(); await allProcessed.promise; From 0a4b6695c7d5b98b032ebf10125d465e9ca1e82e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:53:21 +0100 Subject: [PATCH 582/806] test: require inbound debounce callbacks --- src/auto-reply/inbound.test.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index beeef5de487..5dfb942cada 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -363,7 +363,7 @@ describe("createInboundDebouncer", () => { it("keeps later same-key work behind a timer-backed flush that already started", async () => { const started: string[] = []; const finished: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -404,6 +404,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1"]); expect(finished).toEqual([]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([firstFlush, secondEnqueue]); @@ -417,7 +420,7 @@ describe("createInboundDebouncer", () => { it("keeps fire-and-forget keyed work ahead of a later buffered item", async () => { const started: string[] = []; const finished: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -472,6 +475,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1"]); expect(finished).toEqual([]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([firstFlush, secondEnqueue, thirdFlush, thirdEnqueue]); @@ -484,7 +490,7 @@ describe("createInboundDebouncer", () => { it("does not serialize keyed turns when debounce is disabled and no keyed chain exists", async () => { const started: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -508,6 +514,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1", "2"]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([first, second]); }); @@ -582,7 +591,7 @@ describe("createInboundDebouncer", () => { it("keeps same-key overflow work ordered after falling back to immediate flushes", async () => { const started: string[] = []; const finished: string[] = []; - let releaseOverflow!: () => void; + let releaseOverflow: (() => void) | undefined; const overflowGate = new Promise((resolve) => { releaseOverflow = resolve; }); @@ -632,6 +641,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["2"]); expect(finished).toEqual([]); + if (!releaseOverflow) { + throw new Error("Expected inbound overflow release callback to be initialized"); + } releaseOverflow(); await Promise.all([overflowEnqueue, bufferedEnqueue, bufferedFlush]); @@ -645,7 +657,7 @@ describe("createInboundDebouncer", () => { it("counts tracked debounce keys by union of buffers and active chains", async () => { const started: string[] = []; const finished: string[] = []; - let releaseChainOnly!: () => void; + let releaseChainOnly: (() => void) | undefined; const chainOnlyGate = new Promise((resolve) => { releaseChainOnly = resolve; }); @@ -707,6 +719,9 @@ describe("createInboundDebouncer", () => { expect(finished).toEqual(["4"]); }); + if (!releaseChainOnly) { + throw new Error("Expected inbound chain-only release callback to be initialized"); + } releaseChainOnly(); await Promise.all([secondFlush, overflowEnqueue]); expect(finished).toEqual(["4", "2"]); From 16a2773d4ecf876c2f1fc7cd1bb35f82300c3760 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:53:47 +0100 Subject: [PATCH 583/806] test: tighten microsoft foundry auth assertions --- extensions/microsoft-foundry/index.test.ts | 64 ++++++++++++++++------ 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 12aa64f9f33..fe4c906cef6 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -53,7 +53,33 @@ function registerProvider() { }), ); expect(registerProviderMock).toHaveBeenCalledTimes(1); - return registerProviderMock.mock.calls[0]?.[0]; + const firstCall = registerProviderMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("expected Microsoft Foundry provider registration"); + } + return firstCall[0]; +} + +type FoundryProvider = ReturnType; + +function requirePrepareRuntimeAuth( + provider: FoundryProvider, +): NonNullable { + const prepareRuntimeAuth = provider.prepareRuntimeAuth; + expect(prepareRuntimeAuth).toBeTypeOf("function"); + if (!prepareRuntimeAuth) { + throw new Error("expected Microsoft Foundry runtime auth hook"); + } + return prepareRuntimeAuth; +} + +function requireRuntimeAuthResult(result: { apiKey?: string; baseUrl?: string } | undefined) { + expect(result).toBeDefined(); + if (!result) { + throw new Error("expected Microsoft Foundry runtime auth result"); + } + return result; } const defaultFoundryBaseUrl = "https://example.services.ai.azure.com/openai/v1"; @@ -276,27 +302,29 @@ describe("microsoft-foundry plugin", () => { it("preserves the model-derived base URL for Entra runtime auth refresh", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliToken({ accessToken: "test-token", expiresInMs: 60_000 }); ensureAuthProfileStoreMock.mockReturnValueOnce(buildEntraProfileStore()); - const prepared = await provider.prepareRuntimeAuth?.(buildFoundryRuntimeAuthContext()); + const prepared = requireRuntimeAuthResult( + await prepareRuntimeAuth(buildFoundryRuntimeAuthContext()), + ); - expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); + expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); }); it("retries Entra token refresh after a failed attempt", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliLoginFailure(); mockAzureCliToken({ accessToken: "retry-token", expiresInMs: 10 * 60_000 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); const runtimeContext = buildFoundryRuntimeAuthContext(); - await expect(provider.prepareRuntimeAuth?.(runtimeContext)).rejects.toThrow( - "Azure CLI is not logged in", - ); + await expect(prepareRuntimeAuth(runtimeContext)).rejects.toThrow("Azure CLI is not logged in"); - await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({ + await expect(prepareRuntimeAuth(runtimeContext)).resolves.toMatchObject({ apiKey: "retry-token", }); expect(execFileMock).toHaveBeenCalledTimes(2); @@ -304,23 +332,25 @@ describe("microsoft-foundry plugin", () => { it("dedupes concurrent Entra token refreshes for the same profile", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliToken({ accessToken: "deduped-token", expiresInMs: 60_000, delayMs: 10 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); const runtimeContext = buildFoundryRuntimeAuthContext(); const [first, second] = await Promise.all([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); expect(execFileMock).toHaveBeenCalledTimes(1); - expect(first?.apiKey).toBe("deduped-token"); - expect(second?.apiKey).toBe("deduped-token"); + expect(requireRuntimeAuthResult(first).apiKey).toBe("deduped-token"); + expect(requireRuntimeAuthResult(second).apiKey).toBe("deduped-token"); }); it("clears failed refresh state so later concurrent retries succeed", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliLoginFailure(10); mockAzureCliToken({ accessToken: "recovered-token", expiresInMs: 10 * 60_000, delayMs: 10 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); @@ -328,19 +358,19 @@ describe("microsoft-foundry plugin", () => { const runtimeContext = buildFoundryRuntimeAuthContext(); const failed = await Promise.allSettled([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); expect(failed.filter((result) => result.status !== "rejected")).toEqual([]); expect(execFileMock).toHaveBeenCalledTimes(1); const [first, second] = await Promise.all([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); expect(execFileMock).toHaveBeenCalledTimes(2); - expect(first?.apiKey).toBe("recovered-token"); - expect(second?.apiKey).toBe("recovered-token"); + expect(requireRuntimeAuthResult(first).apiKey).toBe("recovered-token"); + expect(requireRuntimeAuthResult(second).apiKey).toBe("recovered-token"); }); it("refreshes again when a cached token is too close to expiry", async () => { From 0895cf6989b994858a1c5f387a9494d6736aa953 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:54:39 +0100 Subject: [PATCH 584/806] test: tighten boundary config assertions --- test/vitest-boundary-config.test.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/vitest-boundary-config.test.ts b/test/vitest-boundary-config.test.ts index 149bb04ef55..ec36ceeb67b 100644 --- a/test/vitest-boundary-config.test.ts +++ b/test/vitest-boundary-config.test.ts @@ -6,6 +6,14 @@ import { } from "./vitest/vitest.boundary.config.ts"; import { boundaryTestFiles } from "./vitest/vitest.unit-paths.mjs"; +function requireTestConfig(config: ReturnType) { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected boundary vitest test config"); + } + return config.test; +} + describe("loadBoundaryIncludePatternsFromEnv", () => { it("returns null when no include file is configured", () => { expect(loadBoundaryIncludePatternsFromEnv({})).toBeNull(); @@ -15,11 +23,12 @@ describe("loadBoundaryIncludePatternsFromEnv", () => { describe("boundary vitest config", () => { it("keeps boundary suites on the non-isolated runner with shared test bootstrap", () => { const config = createBoundaryVitestConfig({}); + const testConfig = requireTestConfig(config); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); - expect(config.test?.include).toEqual(boundaryTestFiles); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual(["test/setup.ts"]); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + expect(testConfig.include).toEqual(boundaryTestFiles); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); it("narrows boundary includes to matching CLI file filters", () => { @@ -29,8 +38,9 @@ describe("boundary vitest config", () => { "run", "src/infra/openclaw-root.test.ts", ]); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["src/infra/openclaw-root.test.ts"]); - expect(config.test?.passWithNoTests).toBe(true); + expect(testConfig.include).toEqual(["src/infra/openclaw-root.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); }); From ff053eda4159b84dace8011a5503f6abce75e996 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:55:07 +0100 Subject: [PATCH 585/806] test: tighten ui package config assertions --- test/vitest-ui-package-config.test.ts | 33 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index 0051a3f3312..0922923709f 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -2,22 +2,35 @@ import { describe, expect, it } from "vitest"; import uiConfig from "../ui/vitest.config.ts"; import uiNodeConfig from "../ui/vitest.node.config.ts"; +function requireTestConfig(config: T): NonNullable { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected ui package vitest test config"); + } + return config.test as NonNullable; +} + describe("ui package vitest config", () => { it("keeps the standalone ui package on thread workers without isolation", () => { - expect(uiConfig.test?.pool).toBe("threads"); - expect(uiConfig.test?.isolate).toBe(false); - expect(uiConfig.test?.projects).toHaveLength(3); + const testConfig = requireTestConfig(uiConfig); - for (const project of uiConfig.test?.projects ?? []) { - expect(project.test?.pool).toBe("threads"); - expect(project.test?.isolate).toBe(false); - expect(project.test?.runner).toBeUndefined(); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.projects).toHaveLength(3); + + for (const project of testConfig.projects) { + const projectTestConfig = requireTestConfig(project); + expect(projectTestConfig.pool).toBe("threads"); + expect(projectTestConfig.isolate).toBe(false); + expect(projectTestConfig.runner).toBeUndefined(); } }); it("keeps the standalone ui node config on thread workers without isolation", () => { - expect(uiNodeConfig.test?.pool).toBe("threads"); - expect(uiNodeConfig.test?.isolate).toBe(false); - expect(uiNodeConfig.test?.runner).toBeUndefined(); + const testConfig = requireTestConfig(uiNodeConfig); + + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.runner).toBeUndefined(); }); }); From f956c21c1e03bef0a157ddde035e534bdceb6423 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:55:01 +0100 Subject: [PATCH 586/806] test: require gateway startup callbacks --- .../server-startup-post-attach.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 7c83a763222..9da795dd486 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -396,7 +396,7 @@ describe("startGatewayPostAttachRuntime", () => { it("waits for deferred startup plugin attachment before channel sidecars", async () => { const events: string[] = []; - let finishAttachment!: () => void; + let finishAttachment: (() => void) | undefined; const attachmentFinished = new Promise((resolve) => { finishAttachment = () => { events.push("startup-loaded-end"); @@ -439,6 +439,9 @@ describe("startGatewayPostAttachRuntime", () => { }); expect(startGatewaySidecars).not.toHaveBeenCalled(); + if (!finishAttachment) { + throw new Error("Expected startup plugin attachment release callback to be initialized"); + } finishAttachment(); await runtimePromise; @@ -517,7 +520,7 @@ describe("startGatewayPostAttachRuntime", () => { }); it("waits for sidecars by default before returning", async () => { - let resumeSidecars!: () => void; + let resumeSidecars: (() => void) | undefined; const sidecarsReady = new Promise<{ pluginServices: null }>((resolve) => { resumeSidecars = () => resolve({ pluginServices: null }); }); @@ -539,6 +542,9 @@ describe("startGatewayPostAttachRuntime", () => { await Promise.resolve(); expect(returned).toBe(false); + if (!resumeSidecars) { + throw new Error("Expected gateway sidecar resume callback to be initialized"); + } resumeSidecars(); await runtimePromise; expect(returned).toBe(true); @@ -604,7 +610,7 @@ describe("startGatewayPostAttachRuntime", () => { await withEnvAsync( { OPENCLAW_SKIP_CHANNELS: undefined, OPENCLAW_SKIP_PROVIDERS: undefined }, async () => { - let resolvePrewarm!: () => void; + let resolvePrewarm: (() => void) | undefined; const prewarmPrimaryModel = vi.fn( async () => await new Promise((resolve) => { @@ -649,6 +655,9 @@ describe("startGatewayPostAttachRuntime", () => { ); await sidecarsPromise; + if (!resolvePrewarm) { + throw new Error("Expected primary model prewarm resolver to be initialized"); + } resolvePrewarm(); await Promise.resolve(); }, @@ -656,7 +665,7 @@ describe("startGatewayPostAttachRuntime", () => { }); it("keeps startup-gated methods unavailable while sidecars are still resuming", async () => { - let resumeSidecars!: () => void; + let resumeSidecars: (() => void) | undefined; const sidecarsReady = new Promise<{ pluginServices: null }>((resolve) => { resumeSidecars = () => resolve({ pluginServices: null }); }); @@ -684,6 +693,9 @@ describe("startGatewayPostAttachRuntime", () => { expect([...unavailableGatewayMethods]).toEqual([...STARTUP_UNAVAILABLE_GATEWAY_METHODS]); expect(hoisted.startPluginServices).not.toHaveBeenCalled(); + if (!resumeSidecars) { + throw new Error("Expected gateway sidecar resume callback to be initialized"); + } resumeSidecars(); await vi.waitFor(() => { expect([...unavailableGatewayMethods]).toEqual([]); From b1f4788e15dc1f87080eefe66cec5f2bb28e33ec Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:55:54 +0100 Subject: [PATCH 587/806] test: tighten vitest project config assertions --- test/vitest-projects-config.test.ts | 58 ++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index c27138761a1..f78fbe2af7c 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -32,13 +32,32 @@ import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts"; const patternFiles = createPatternFileHelper("openclaw-vitest-projects-config-"); +function requireTestConfig(config: T): NonNullable { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected vitest test config"); + } + return config.test as NonNullable; +} + +function requireWebOptimizer(testConfig: { + deps?: { optimizer?: { web?: { enabled?: boolean } } }; +}) { + const webOptimizer = testConfig.deps?.optimizer?.web; + expect(webOptimizer).toBeDefined(); + if (!webOptimizer) { + throw new Error("expected vitest web optimizer config"); + } + return webOptimizer; +} + afterEach(() => { patternFiles.cleanup(); }); describe("projects vitest config", () => { it("defines the native root project list for all non-live Vitest lanes", () => { - expect(baseConfig.test?.projects).toEqual([...rootVitestProjects]); + expect(requireTestConfig(baseConfig).projects).toEqual([...rootVitestProjects]); }); it("disables vite env-file loading for vitest lanes", () => { @@ -102,11 +121,11 @@ describe("projects vitest config", () => { it("gives contract project configs unique names", () => { expect([ - contractChannelSurfaceConfig.test?.name, - contractChannelConfigConfig.test?.name, - contractChannelRegistryConfig.test?.name, - contractChannelSessionConfig.test?.name, - contractPluginConfig.test?.name, + requireTestConfig(contractChannelSurfaceConfig).name, + requireTestConfig(contractChannelConfigConfig).name, + requireTestConfig(contractChannelRegistryConfig).name, + requireTestConfig(contractChannelSessionConfig).name, + requireTestConfig(contractPluginConfig).name, ]).toEqual([ "contracts-channel-surface", "contracts-channel-config", @@ -150,21 +169,23 @@ describe("projects vitest config", () => { it("keeps the root ui lane aligned with the shared jsdom setup", () => { const config = createUiVitestConfig(); - expect(config.test.environment).toBe("jsdom"); - expect(config.test.isolate).toBe(false); - expect(normalizeConfigPath(config.test.runner)).toBe("test/non-isolated-runner.ts"); - const setupFiles = normalizeConfigPaths(config.test.setupFiles); + const testConfig = requireTestConfig(config); + expect(testConfig.environment).toBe("jsdom"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + const setupFiles = normalizeConfigPaths(testConfig.setupFiles); expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts"); expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts"); - expect(config.test.deps?.optimizer?.web?.enabled).toBe(true); + expect(requireWebOptimizer(testConfig).enabled).toBe(true); }); it("keeps the unit-ui shard aligned with the shared jsdom setup", () => { - expect(unitUiConfig.test?.environment).toBe("jsdom"); - expect(unitUiConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(unitUiConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(unitUiConfig); + expect(testConfig.environment).toBe("jsdom"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); expect(unitUiIncludePatterns).toContain("ui/src/ui/views/dreaming.test.ts"); - const setupFiles = normalizeConfigPaths(unitUiConfig.test?.setupFiles); + const setupFiles = normalizeConfigPaths(testConfig.setupFiles); expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts"); expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts"); }); @@ -182,8 +203,9 @@ describe("projects vitest config", () => { }); it("keeps the bundled lane on thread workers with the non-isolated runner", () => { - expect(bundledConfig.test?.pool).toBe("threads"); - expect(bundledConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(bundledConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(bundledConfig); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); }); }); From 438802d1bc6a7f30bceb06a49312fa00df84dc02 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:56:55 +0100 Subject: [PATCH 588/806] test: tighten scoped config setup assertions --- test/vitest-scoped-config.test.ts | 45 ++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index b1e122852ca..ea1bb210ced 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -85,6 +85,14 @@ function matchingExcludePatterns(patterns: string[], file: string): string[] { return patterns.filter((pattern) => path.matchesGlob(file, pattern)); } +function requireTestConfig(config: T): NonNullable { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected scoped vitest test config"); + } + return config.test as NonNullable; +} + describe("resolveVitestIsolation", () => { it("aliases private QA plugin SDK subpaths for source tests only", () => { expect(sharedVitestConfig.resolve.alias).toEqual( @@ -120,19 +128,21 @@ describe("resolveVitestIsolation", () => { it("resolves scoped discovery dirs from the repo root after config relocation", () => { const config = createExtensionMatrixVitestConfig({}); + const testConfig = requireTestConfig(config); expect(config.root).toBe(process.cwd()); - expect(config.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(config.test?.include).toContain("matrix/**/*.test.ts"); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toContain("matrix/**/*.test.ts"); }); }); describe("createScopedVitestConfig", () => { it("applies the non-isolated runner by default", () => { const config = createScopedVitestConfig(["src/example.test.ts"], { env: {} }); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(config); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); @@ -143,8 +153,9 @@ describe("createScopedVitestConfig", () => { dir: "src", env: {}, }); - expect(config.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(config.test?.include).toEqual(["example.test.ts"]); + const testConfig = requireTestConfig(config); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["example.test.ts"]); }); it("keeps scoped cli directory filters aligned with repo-root include patterns", () => { @@ -155,7 +166,7 @@ describe("createScopedVitestConfig", () => { passWithNoTests: true, }); - expect(config.test?.include).toEqual(["slack/**/*.test.*"]); + expect(requireTestConfig(config).include).toEqual(["slack/**/*.test.*"]); }); it("keeps broad scoped cli directory filters aligned with repo-root include patterns", () => { @@ -166,7 +177,7 @@ describe("createScopedVitestConfig", () => { passWithNoTests: true, }); - expect(config.test?.include).toEqual(["speech-core/**/*.test.*"]); + expect(requireTestConfig(config).include).toEqual(["speech-core/**/*.test.*"]); }); it("relativizes scoped include and exclude patterns to the configured dir", () => { @@ -175,9 +186,10 @@ describe("createScopedVitestConfig", () => { env: {}, exclude: [EXTENSIONS_CHANNEL_GLOB, "dist/**"], }); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["**/*.test.ts"]); - expect(config.test?.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"])); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"])); }); it("narrows scoped includes to matching CLI file filters", () => { @@ -186,9 +198,10 @@ describe("createScopedVitestConfig", () => { dir: "extensions", env: {}, }); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["browser/index.test.ts"]); - expect(config.test?.passWithNoTests).toBe(true); + expect(testConfig.include).toEqual(["browser/index.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); it("loads scoped include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => { @@ -204,7 +217,7 @@ describe("createScopedVitestConfig", () => { }, }); - expect(config.test?.include).toEqual(["utils/utils-misc.test.ts"]); + expect(requireTestConfig(config).include).toEqual(["utils/utils-misc.test.ts"]); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -216,7 +229,7 @@ describe("createScopedVitestConfig", () => { setupFiles: ["test/setup.extensions.ts"], }); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(config).setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", @@ -224,7 +237,7 @@ describe("createScopedVitestConfig", () => { }); it("keeps bundled unit test includes out of the bundled exclude list", () => { - const excludePatterns = bundledVitestConfig.test?.exclude ?? []; + const excludePatterns = requireTestConfig(bundledVitestConfig).exclude ?? []; for (const file of bundledPluginDependentUnitTestFiles) { expect( excludePatterns.some((pattern) => bundledExcludePatternCouldMatchFile(pattern, file)), From 7460954c539965084a93e1e07b419bcf80338475 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:57:34 +0100 Subject: [PATCH 589/806] test: require embedded runner deferred callbacks --- ...-embedded-runner.guard.waitforidle-before-flush.test.ts | 5 ++++- src/agents/pi-embedded-runner/compact.hooks.test.ts | 5 ++++- src/agents/pi-embedded-runner/run/auth-controller.test.ts | 7 +++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index 3cb2e8c79aa..a4e189d6b49 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -22,10 +22,13 @@ function toolResult(id: string, text: string): AgentMessage { } function deferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected wait-for-idle deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 55510fbb78d..2bfca6940ea 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -54,10 +54,13 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((promiseResolve) => { resolve = promiseResolve; }); + if (!resolve) { + throw new Error("Expected compaction deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/pi-embedded-runner/run/auth-controller.test.ts index ad3e1db8cd0..e59a0c28034 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.test.ts @@ -29,12 +29,15 @@ vi.mock("../../model-auth.js", async () => { import { createEmbeddedRunAuthController } from "./auth-controller.js"; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected auth controller deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } From f4489aec9660cc600e2300c816c673f015f0477d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:58:01 +0100 Subject: [PATCH 590/806] test: tighten scoped lane assertions --- test/vitest-scoped-config.test.ts | 71 ++++++++++++++++--------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index ea1bb210ced..502e92df96f 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -93,6 +93,15 @@ function requireTestConfig(config: T): NonNullable return config.test as NonNullable; } +function expectThreadedNonIsolatedRunner(config: { + test?: { pool?: unknown; isolate?: unknown; runner?: unknown }; +}) { + const testConfig = requireTestConfig(config); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); +} + describe("resolveVitestIsolation", () => { it("aliases private QA plugin SDK subpaths for source tests only", () => { expect(sharedVitestConfig.resolve.alias).toEqual( @@ -325,73 +334,65 @@ describe("scoped vitest configs", () => { defaultAutoReplyReplyConfig, defaultToolingConfig, ]) { - expect(config.test?.pool).toBe("threads"); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(config); } for (const config of [defaultGatewayConfig, defaultAgentsConfig]) { - expect(config.test?.pool).toBe("threads"); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(config); } - expect(defaultCommandsConfig.test?.pool).toBe("threads"); - expect(defaultCommandsConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(defaultCommandsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultCommandsConfig); - expect(defaultUiConfig.test?.pool).toBe("threads"); - expect(defaultUiConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(defaultUiConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(defaultUiConfig); }); it("keeps the process lane off the openclaw runtime setup", () => { - expect(normalizeConfigPaths(defaultProcessConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); - expect(normalizeConfigPaths(defaultRuntimeConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); - expect(normalizeConfigPaths(defaultPluginSdkConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(defaultProcessConfig).setupFiles)).toEqual([ + "test/setup.ts", + ]); + expect(normalizeConfigPaths(requireTestConfig(defaultRuntimeConfig).setupFiles)).toEqual([ + "test/setup.ts", + ]); + expect(normalizeConfigPaths(requireTestConfig(defaultPluginSdkConfig).setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); }); it("splits auto-reply into narrower scoped buckets", () => { - expect(defaultAutoReplyCoreConfig.test?.include).toEqual(["*.test.ts"]); - expect(defaultAutoReplyCoreConfig.test?.exclude).toEqual( - expect.arrayContaining(["reply*.test.ts"]), - ); - expect(defaultAutoReplyTopLevelConfig.test?.include).toEqual(["reply*.test.ts"]); - expect(defaultAutoReplyReplyConfig.test?.include).toEqual(["reply/**/*.test.ts"]); + const coreTestConfig = requireTestConfig(defaultAutoReplyCoreConfig); + expect(coreTestConfig.include).toEqual(["*.test.ts"]); + expect(coreTestConfig.exclude).toEqual(expect.arrayContaining(["reply*.test.ts"])); + expect(requireTestConfig(defaultAutoReplyTopLevelConfig).include).toEqual(["reply*.test.ts"]); + expect(requireTestConfig(defaultAutoReplyReplyConfig).include).toEqual(["reply/**/*.test.ts"]); }); it("keeps the broad agents lane on shared file parallelism", () => { - expect(defaultAgentsConfig.test?.fileParallelism).toBe(sharedVitestConfig.test.fileParallelism); + expect(requireTestConfig(defaultAgentsConfig).fileParallelism).toBe( + sharedVitestConfig.test.fileParallelism, + ); }); it("keeps selected plugin-sdk and commands light lanes off the openclaw runtime setup", () => { - expect(normalizeConfigPaths(defaultPluginSdkLightConfig.test?.setupFiles)).toEqual([ - "test/setup.ts", - ]); - expect(normalizeConfigPaths(defaultCommandsLightConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(defaultPluginSdkLightConfig).setupFiles)).toEqual( + ["test/setup.ts"], + ); + expect(normalizeConfigPaths(requireTestConfig(defaultCommandsLightConfig).setupFiles)).toEqual([ "test/setup.ts", ]); }); it("keeps the ui lane off both the openclaw runtime setup and unit-fast excludes", () => { - expect(normalizeConfigPaths(defaultUiConfig.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(defaultUiConfig); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "ui/src/test-helpers/lit-warnings.setup.ts", ]); - expect(defaultUiConfig.test?.exclude).not.toContain("chat/slash-command-executor.node.test.ts"); + expect(testConfig.exclude).not.toContain("chat/slash-command-executor.node.test.ts"); }); it("defaults channel tests to threads with the non-isolated runner", () => { - expect(defaultChannelsConfig.test?.isolate).toBe(false); - expect(defaultChannelsConfig.test?.pool).toBe("threads"); - expect(normalizeConfigPath(defaultChannelsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultChannelsConfig); }); it("keeps the core channel lane limited to non-extension roots", () => { From 174d3314551b9ab89b8e2f6ee7eabfee6fdda35c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 19:59:34 +0100 Subject: [PATCH 591/806] test: tighten unit fast config assertions --- test/vitest-unit-fast-config.test.ts | 94 +++++++++++++++------------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 481c9a0f09b..134b56ca45a 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -13,49 +13,54 @@ import { } from "./vitest/vitest.unit-fast-paths.mjs"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; +function requireTestConfig(config: T): NonNullable { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected unit-fast vitest test config"); + } + return config.test as NonNullable; +} + describe("unit-fast vitest lane", () => { it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { const config = createUnitFastVitestConfig({}); + const testConfig = requireTestConfig(config); - expect(config.test?.isolate).toBe(false); - expect(config.test?.runner).toBeUndefined(); - expect(config.test?.setupFiles).toEqual([]); - expect(config.test?.include).toContain( - "src/agents/pi-tools.deferred-followup-guidance.test.ts", - ); - expect(config.test?.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); - expect(config.test?.include).toContain("src/acp/runtime/registry.test.ts"); - expect(config.test?.include).toContain("src/commands/status-overview-values.test.ts"); - expect(config.test?.include).toContain("src/entry.respawn.test.ts"); - expect(config.test?.include).toContain("src/entry.version-fast-path.test.ts"); - expect(config.test?.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts"); - expect(config.test?.include).toContain("src/crestodian/rescue-policy.test.ts"); - expect(config.test?.include).toContain("src/crestodian/assistant.configured.test.ts"); - expect(config.test?.include).toContain("src/flows/search-setup.test.ts"); - expect(config.test?.include).toContain("src/memory-host-sdk/host/backend-config.test.ts"); - expect(config.test?.include).toContain("src/plugins/config-policy.test.ts"); - expect(config.test?.include).toContain("src/proxy-capture/proxy-server.test.ts"); - expect(config.test?.include).toContain("src/talk/agent-consult-tool.test.ts"); - expect(config.test?.include).toContain("src/sessions/session-lifecycle-events.test.ts"); - expect(config.test?.include).toContain("src/sessions/transcript-events.test.ts"); - expect(config.test?.include).toContain( - "src/security/audit-channel-source-config-slack.test.ts", - ); - expect(config.test?.include).toContain("src/security/audit-config-symlink.test.ts"); - expect(config.test?.include).toContain("src/security/audit-exec-sandbox-host.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-auth-selection.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-http-auth.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-tools-http.test.ts"); - expect(config.test?.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); - expect(config.test?.include).toContain("src/security/audit-loopback-logging.test.ts"); - expect(config.test?.include).toContain("src/security/audit-sandbox-browser.test.ts"); - expect(config.test?.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts"); - expect(config.test?.include).toContain("src/video-generation/provider-registry.test.ts"); - expect(config.test?.include).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(config.test?.include).toContain("src/security/dangerous-config-flags.test.ts"); - expect(config.test?.include).toContain("src/security/context-visibility.test.ts"); - expect(config.test?.include).toContain("src/security/safe-regex.test.ts"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.runner).toBeUndefined(); + expect(testConfig.setupFiles).toEqual([]); + expect(testConfig.include).toContain("src/agents/pi-tools.deferred-followup-guidance.test.ts"); + expect(testConfig.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); + expect(testConfig.include).toContain("src/acp/runtime/registry.test.ts"); + expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); + expect(testConfig.include).toContain("src/entry.respawn.test.ts"); + expect(testConfig.include).toContain("src/entry.version-fast-path.test.ts"); + expect(testConfig.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts"); + expect(testConfig.include).toContain("src/crestodian/rescue-policy.test.ts"); + expect(testConfig.include).toContain("src/crestodian/assistant.configured.test.ts"); + expect(testConfig.include).toContain("src/flows/search-setup.test.ts"); + expect(testConfig.include).toContain("src/memory-host-sdk/host/backend-config.test.ts"); + expect(testConfig.include).toContain("src/plugins/config-policy.test.ts"); + expect(testConfig.include).toContain("src/proxy-capture/proxy-server.test.ts"); + expect(testConfig.include).toContain("src/talk/agent-consult-tool.test.ts"); + expect(testConfig.include).toContain("src/sessions/session-lifecycle-events.test.ts"); + expect(testConfig.include).toContain("src/sessions/transcript-events.test.ts"); + expect(testConfig.include).toContain("src/security/audit-channel-source-config-slack.test.ts"); + expect(testConfig.include).toContain("src/security/audit-config-symlink.test.ts"); + expect(testConfig.include).toContain("src/security/audit-exec-sandbox-host.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-auth-selection.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-http-auth.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts"); + expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); + expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts"); + expect(testConfig.include).toContain("src/security/audit-sandbox-browser.test.ts"); + expect(testConfig.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts"); + expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts"); + expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts"); + expect(testConfig.include).toContain("src/security/context-visibility.test.ts"); + expect(testConfig.include).toContain("src/security/safe-regex.test.ts"); }); it("does not treat moved config paths as CLI include filters", () => { @@ -66,8 +71,9 @@ describe("unit-fast vitest lane", () => { }, ); - expect(config.test?.include).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(config.test?.include).toContain("src/commands/status-overview-values.test.ts"); + const testConfig = requireTestConfig(config); + expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); }); it("keeps obvious stateful files out of the unit-fast lane", () => { @@ -128,7 +134,9 @@ describe("unit-fast vitest lane", () => { const unitFastTestFiles = getUnitFastTestFiles(); expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(pluginSdkLight.test?.exclude).toContain("plugin-sdk/provider-entry.test.ts"); - expect(commandsLight.test?.exclude).toContain("status-overview-values.test.ts"); + expect(requireTestConfig(pluginSdkLight).exclude).toContain( + "plugin-sdk/provider-entry.test.ts", + ); + expect(requireTestConfig(commandsLight).exclude).toContain("status-overview-values.test.ts"); }); }); From 11d7f545559ef06446c94e78ac3939cafbc424ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:59:29 +0100 Subject: [PATCH 592/806] test: require agent async callbacks --- src/agents/pi-bundle-mcp-runtime.test.ts | 5 ++++- src/agents/pi-embedded-runner/run/assistant-failover.test.ts | 5 ++++- src/agents/subagent-registry.steer-restart.test.ts | 5 ++++- src/agents/tools/video-generate-background.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 798d8d144bf..01f4de4c51b 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -327,7 +327,7 @@ describe("session MCP runtime", () => { }); it("disposes catalog startup in-flight without leaving cached runtimes", async () => { - let notifyCatalogStarted!: () => void; + let notifyCatalogStarted: (() => void) | undefined; const catalogStarted = new Promise((resolve) => { notifyCatalogStarted = resolve; }); @@ -339,6 +339,9 @@ describe("session MCP runtime", () => { workspaceDir: params.workspaceDir, configFingerprint: params.configFingerprint ?? "fingerprint", getCatalog: async () => { + if (!notifyCatalogStarted) { + throw new Error("Expected bundle MCP catalog start callback to be initialized"); + } notifyCatalogStarted(); return await new Promise((_, reject) => { rejectCatalog = reject; diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts index 6e529c8328a..56ff2801d39 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts @@ -60,7 +60,7 @@ describe("handleAssistantFailover", () => { describe("rotate_profile branch", () => { it("rotates before waiting on auth profile failure marking", async () => { const events: string[] = []; - let releaseMark!: () => void; + let releaseMark: (() => void) | undefined; const markFinished = new Promise((resolve) => { releaseMark = resolve; }); @@ -91,6 +91,9 @@ describe("handleAssistantFailover", () => { expect(outcome.action).toBe("retry"); expect(events).toEqual(["advance", "mark-start"]); + if (!releaseMark) { + throw new Error("Expected auth profile failure mark release callback to be initialized"); + } releaseMark(); await markSettled; await vi.waitFor(() => expect(events).toEqual(["advance", "mark-start", "mark-finish"])); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 0e43283b139..17e14bf7acd 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -136,7 +136,7 @@ describe("subagent registry steer restarts", () => { }; const createDeferredAnnounceResolver = (): ((value: boolean) => void) => { - let resolveAnnounce!: (value: boolean) => void; + let resolveAnnounce: ((value: boolean) => void) | undefined; announceSpy.mockImplementationOnce( () => new Promise((resolve) => { @@ -144,6 +144,9 @@ describe("subagent registry steer restarts", () => { }), ); return (value: boolean) => { + if (!resolveAnnounce) { + throw new Error("Expected subagent announcement resolver to be initialized"); + } resolveAnnounce(value); }; }; diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index 89533e29af3..dace77ea1a7 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -122,7 +122,7 @@ describe("video generate background helpers", () => { it("keeps long-running media tasks fresh while provider work is pending", async () => { vi.useFakeTimers(); - let resolveRun!: (value: string) => void; + let resolveRun: ((value: string) => void) | undefined; const runPromise = new Promise((resolve) => { resolveRun = resolve; }); @@ -145,6 +145,9 @@ describe("video generate background helpers", () => { progressSummary: "Generating video", }); + if (!resolveRun) { + throw new Error("Expected video generation run resolver to be initialized"); + } resolveRun("done"); await expect(task).resolves.toBe("done"); const callsAfterCompletion = taskExecutorMocks.recordTaskRunProgressByRunId.mock.calls.length; From 49878da759aa0d54b1d46b1d735ab1add7dc99a9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:00:26 +0100 Subject: [PATCH 593/806] test: tighten copied env config assertions --- test/test-env.test.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/test/test-env.test.ts b/test/test-env.test.ts index 4b462096e8e..7ce7a8a3653 100644 --- a/test/test-env.test.ts +++ b/test/test-env.test.ts @@ -140,12 +140,31 @@ describe("installTestEnv", () => { }; }; }; - expect(copiedConfig.models?.providers?.custom).toEqual({ baseUrl: "https://example.test/v1" }); - expect(copiedConfig.agents?.defaults?.workspace).toBeUndefined(); - expect(copiedConfig.agents?.defaults?.agentDir).toBeUndefined(); - expect(copiedConfig.agents?.list?.[0]?.workspace).toBeUndefined(); - expect(copiedConfig.agents?.list?.[0]?.agentDir).toBeUndefined(); - expect(copiedConfig.channels?.telegram?.streaming).toEqual({ + const providers = copiedConfig.models?.providers; + expect(providers).toBeDefined(); + if (!providers) { + throw new Error("expected copied model providers config"); + } + expect(providers.custom).toEqual({ baseUrl: "https://example.test/v1" }); + + const agentDefaults = copiedConfig.agents?.defaults; + const agentConfig = copiedConfig.agents?.list?.[0]; + expect(agentDefaults).toBeDefined(); + expect(agentConfig).toBeDefined(); + if (!agentDefaults || !agentConfig) { + throw new Error("expected copied agent config"); + } + expect(agentDefaults.workspace).toBeUndefined(); + expect(agentDefaults.agentDir).toBeUndefined(); + expect(agentConfig.workspace).toBeUndefined(); + expect(agentConfig.agentDir).toBeUndefined(); + + const telegramStreaming = copiedConfig.channels?.telegram?.streaming; + expect(telegramStreaming).toBeDefined(); + if (!telegramStreaming) { + throw new Error("expected copied telegram streaming config"); + } + expect(telegramStreaming).toEqual({ mode: "block", chunkMode: "newline", block: { enabled: true }, From 99af8ec2e10f734c8e9dddb6de00c98b33c578ee Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:01:06 +0100 Subject: [PATCH 594/806] test: tighten memory batch retry assertions --- .../src/host/batch-http.test.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/memory-host-sdk/src/host/batch-http.test.ts b/packages/memory-host-sdk/src/host/batch-http.test.ts index c558a00035c..55da2e608be 100644 --- a/packages/memory-host-sdk/src/host/batch-http.test.ts +++ b/packages/memory-host-sdk/src/host/batch-http.test.ts @@ -4,6 +4,22 @@ vi.mock("./post-json.js", () => ({ postJson: vi.fn(), })); +type RetryOptions = { + attempts: number; + minDelayMs: number; + maxDelayMs: number; + shouldRetry: (err: unknown) => boolean; +}; + +function requireRetryOptions(call: unknown[] | undefined): RetryOptions { + const options = call?.[1] as RetryOptions | undefined; + expect(options).toBeDefined(); + if (!options) { + throw new Error("expected retry options"); + } + return options; +} + describe("postJsonWithRetry", () => { let postJsonMock: ReturnType>; let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry; @@ -44,20 +60,13 @@ describe("postJsonWithRetry", () => { }), ); - const retryOptions = retryAsyncMock.mock.calls[0]?.[1] as - | { - attempts: number; - minDelayMs: number; - maxDelayMs: number; - shouldRetry: (err: unknown) => boolean; - } - | undefined; - expect(retryOptions?.attempts).toBe(3); - expect(retryOptions?.minDelayMs).toBe(300); - expect(retryOptions?.maxDelayMs).toBe(2000); - expect(retryOptions?.shouldRetry({ status: 429 })).toBe(true); - expect(retryOptions?.shouldRetry({ status: 503 })).toBe(true); - expect(retryOptions?.shouldRetry({ status: 400 })).toBe(false); + const retryOptions = requireRetryOptions(retryAsyncMock.mock.calls[0]); + expect(retryOptions.attempts).toBe(3); + expect(retryOptions.minDelayMs).toBe(300); + expect(retryOptions.maxDelayMs).toBe(2000); + expect(retryOptions.shouldRetry({ status: 429 })).toBe(true); + expect(retryOptions.shouldRetry({ status: 503 })).toBe(true); + expect(retryOptions.shouldRetry({ status: 400 })).toBe(false); }); it("attaches status to non-ok errors", async () => { From 17c57b7ba321653875bbdf40d3e8cd77c5f1002e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:02:00 +0100 Subject: [PATCH 595/806] test: tighten memory multimodal assertions --- .../memory-host-sdk/src/host/internal.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 926632ca2d7..3759d963f87 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -17,6 +17,9 @@ import { } from "./multimodal.js"; type FileEntry = NonNullable>>; +type MultimodalIndexingChunk = NonNullable< + Awaited> +>; let sharedTempRoot = ""; let sharedTempId = 0; @@ -41,12 +44,23 @@ function setupTempDirLifecycle(prefix: string): () => string { } function expectFileEntry(entry: Awaited>): FileEntry { + expect(entry).toBeTruthy(); if (!entry) { throw new Error("Expected file entry to be built"); } return entry; } +function expectMultimodalIndexingChunk( + built: Awaited>, +): MultimodalIndexingChunk { + expect(built).toBeTruthy(); + if (!built) { + throw new Error("Expected multimodal indexing chunk to be built"); + } + return built; +} + const multimodal: MemoryMultimodalSettings = { enabled: true, modalities: ["image", "audio"], @@ -118,8 +132,8 @@ describe("memory host SDK package internals", () => { fsSync.writeFileSync(imagePath, Buffer.from("png")); const entry = expectFileEntry(await buildFileEntry(imagePath, tmpDir, multimodal)); - const built = await buildMultimodalChunkForIndexing(entry); - expect(built?.chunk.embeddingInput?.parts).toEqual([ + const built = expectMultimodalIndexingChunk(await buildMultimodalChunkForIndexing(entry)); + expect(built.chunk.embeddingInput.parts).toEqual([ { type: "text", text: "Image file: diagram.png" }, expect.objectContaining({ type: "inline-data", mimeType: "image/png" }), ]); From 2322c47901ffd2df14e0d3b3909aabd6b8b34876 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:02:50 +0100 Subject: [PATCH 596/806] test: tighten plugin runtime build assertions --- test/plugin-npm-runtime-build.test.ts | 43 ++++++++++++++++++--------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 768fd2d3115..5b6e6be5612 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -7,10 +7,22 @@ import { const repoRoot = path.resolve(import.meta.dirname, ".."); +type PluginNpmRuntimeBuildPlan = NonNullable>; + function expectDistRelativePaths(paths: string[]) { expect(paths.filter((entry) => !entry.startsWith("./dist/"))).toEqual([]); } +function expectPluginNpmRuntimeBuildPlan( + plan: ReturnType, +): PluginNpmRuntimeBuildPlan { + expect(plan).toBeTruthy(); + if (!plan) { + throw new Error("expected plugin npm runtime build plan"); + } + return plan; +} + describe("plugin npm runtime build planning", () => { it("plans package-local runtime entries for every publishable plugin package", () => { const packageDirs = listPublishablePluginPackageDirs({ repoRoot }); @@ -22,18 +34,19 @@ describe("plugin npm runtime build planning", () => { packageDir, }), ); - expect(plans.filter(Boolean).map((plan) => plan?.pluginDir)).toEqual( + const resolvedPlans = plans.map(expectPluginNpmRuntimeBuildPlan); + expect(resolvedPlans.map((plan) => plan.pluginDir)).toEqual( packageDirs.map((packageDir) => path.basename(packageDir)), ); - for (const plan of plans) { - expect(plan?.outDir).toBe(path.join(plan?.packageDir ?? "", "dist")); - expectDistRelativePaths(plan?.runtimeExtensions ?? []); - expectDistRelativePaths(plan?.runtimeBuildOutputs ?? []); - expect(plan?.packageFiles).toContain("dist/**"); - expect(plan?.packagePeerMetadata.peerDependencies.openclaw).toBe( - plan?.packageJson.openclaw.compat.pluginApi, + for (const plan of resolvedPlans) { + expect(plan.outDir).toBe(path.join(plan.packageDir, "dist")); + expectDistRelativePaths(plan.runtimeExtensions); + expectDistRelativePaths(plan.runtimeBuildOutputs); + expect(plan.packageFiles).toContain("dist/**"); + expect(plan.packagePeerMetadata.peerDependencies.openclaw).toBe( + plan.packageJson.openclaw.compat.pluginApi, ); - expect(plan?.packagePeerMetadata.peerDependenciesMeta.openclaw.optional).toBe(true); + expect(plan.packagePeerMetadata.peerDependenciesMeta.openclaw.optional).toBe(true); } }); @@ -42,28 +55,30 @@ describe("plugin npm runtime build planning", () => { repoRoot, packageDir: path.join(repoRoot, "extensions", "qqbot"), }); - expect(qqbotPlan?.entry).toEqual( + const qqbotRuntimePlan = expectPluginNpmRuntimeBuildPlan(qqbotPlan); + expect(qqbotRuntimePlan.entry).toEqual( expect.objectContaining({ index: path.join(repoRoot, "extensions", "qqbot", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"), "setup-entry": path.join(repoRoot, "extensions", "qqbot", "setup-entry.ts"), }), ); - expect(qqbotPlan?.runtimeExtensions).toEqual(["./dist/index.js"]); - expect(qqbotPlan?.runtimeSetupEntry).toBe("./dist/setup-entry.js"); + expect(qqbotRuntimePlan.runtimeExtensions).toEqual(["./dist/index.js"]); + expect(qqbotRuntimePlan.runtimeSetupEntry).toBe("./dist/setup-entry.js"); const diffsPlan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "diffs"), }); - expect(diffsPlan?.entry).toEqual( + const diffsRuntimePlan = expectPluginNpmRuntimeBuildPlan(diffsPlan); + expect(diffsRuntimePlan.entry).toEqual( expect.objectContaining({ api: path.join(repoRoot, "extensions", "diffs", "api.ts"), index: path.join(repoRoot, "extensions", "diffs", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "diffs", "runtime-api.ts"), }), ); - expect(diffsPlan?.packageFiles).toEqual([ + expect(diffsRuntimePlan.packageFiles).toEqual([ "dist/**", "openclaw.plugin.json", "README.md", From 848ffe90e6551a7726ecfc10d4eaf4a18837efb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:02:43 +0100 Subject: [PATCH 597/806] test: tighten plugin contract async callbacks --- src/agents/model-auth.profiles.test.ts | 6 ++---- src/plugins/contracts/host-hooks.contract.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 1d35c9cd0f9..0f9a1b46cdc 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -372,15 +372,13 @@ async function resolveDemoLocalApiKey(params: { storedKeys: string[]; configuredApiKey: string; }) { - let resolved!: Awaited>; - await withEnvAsync({ DEMO_LOCAL_API_KEY: params.envApiKey }, async () => { - resolved = await resolveApiKeyForProvider({ + return await withEnvAsync({ DEMO_LOCAL_API_KEY: params.envApiKey }, async () => { + return await resolveApiKeyForProvider({ provider: "demo-local", store: buildDemoLocalStore(params.storedKeys), cfg: buildDemoLocalProviderCfg(params.configuredApiKey), }); }); - return resolved; } describe("getApiKeyForModel", () => { diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index cbb6e60693d..4b5cbdb21f3 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -1976,7 +1976,7 @@ describe("host-hook fixture plugin contract", () => { it("does not let stale scheduler cleanup delete a newer job generation", async () => { let releaseCleanup: (() => void) | undefined; - let markCleanupStarted!: () => void; + let markCleanupStarted: (() => void) | undefined; const cleanupStartedPromise = new Promise((resolve) => { markCleanupStarted = resolve; }); @@ -1994,6 +1994,9 @@ describe("host-hook fixture plugin contract", () => { sessionKey: "agent:main:main", kind: "monitor", cleanup: async () => { + if (!markCleanupStarted) { + throw new Error("Expected scheduler cleanup start callback to be initialized"); + } markCleanupStarted(); await new Promise((resolve) => { releaseCleanup = resolve; @@ -2027,7 +2030,10 @@ describe("host-hook fixture plugin contract", () => { }, }); - releaseCleanup?.(); + if (!releaseCleanup) { + throw new Error("Expected scheduler cleanup release callback to be initialized"); + } + releaseCleanup(); await expect(cleanupPromise).resolves.toMatchObject({ failures: [] }); expect(listPluginSessionSchedulerJobs("scheduler-race")).toEqual([ { From c51b5b52ca87813042501cf0ad3b878969158e7e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:03:56 +0100 Subject: [PATCH 598/806] test: tighten channel catalog install assertions --- test/official-channel-catalog.test.ts | 34 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index 1ea91a050a4..9857bddb165 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -12,6 +12,13 @@ import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp const tempDirs: string[] = []; +type OfficialChannelCatalogEntry = ReturnType< + typeof buildOfficialChannelCatalog +>["entries"][number]; +type OfficialChannelInstall = NonNullable< + NonNullable["install"] +>; + function makeRepoRoot(prefix: string): string { return makeTempRepoRoot(tempDirs, prefix); } @@ -20,6 +27,23 @@ function writeJson(filePath: string, value: unknown): void { writeJsonFile(filePath, value); } +function requireInstall(entry: OfficialChannelCatalogEntry | undefined): OfficialChannelInstall { + const install = entry?.openclaw?.install; + expect(install).toBeDefined(); + if (!install) { + throw new Error("expected official channel install config"); + } + return install; +} + +function requireNpmInstallSource(source: ReturnType) { + expect(source.npm).toBeDefined(); + if (!source.npm) { + throw new Error("expected npm install source"); + } + return source.npm; +} + afterEach(() => { cleanupTempDirs(tempDirs); }); @@ -139,9 +163,9 @@ describe("buildOfficialChannelCatalog", () => { expect(entries.length).toBeGreaterThan(0); for (const entry of entries) { - const installSource = describePluginInstallSource(entry.openclaw?.install ?? {}); + const installSource = describePluginInstallSource(requireInstall(entry)); expect(installSource.warnings).toEqual([]); - expect(installSource.npm?.pinState).toBe("exact-with-integrity"); + expect(requireNpmInstallSource(installSource).pinState).toBe("exact-with-integrity"); } }); @@ -163,8 +187,8 @@ describe("buildOfficialChannelCatalog", () => { }), }), ); - const installSource = describePluginInstallSource(twitch?.openclaw?.install ?? {}); - expect(installSource.npm?.pinState).toBe("floating-without-integrity"); + const installSource = describePluginInstallSource(requireInstall(twitch)); + expect(requireNpmInstallSource(installSource).pinState).toBe("floating-without-integrity"); expect(installSource.warnings).toEqual(["npm-spec-floating", "npm-spec-missing-integrity"]); }); @@ -195,7 +219,7 @@ describe("buildOfficialChannelCatalog", () => { (candidate) => candidate.openclaw?.channel?.id === "storepack-chat", ); - expect(entry?.openclaw?.install).toEqual({ + expect(requireInstall(entry)).toEqual({ clawhubSpec: "clawhub:@openclaw/storepack-chat", npmSpec: "@openclaw/storepack-chat", defaultChoice: "clawhub", From ea20c03988516a19e9a84663e7fd35a2710bfcbe Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:04:34 +0100 Subject: [PATCH 599/806] test: tighten media fetch guard assertion --- src/media-understanding/shared.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index a19023b69fb..a5bf0cc30b7 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -48,6 +48,7 @@ afterEach(() => { function getFirstGuardedFetchCall() { const call = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; + expect(call).toBeTruthy(); if (!call) { throw new Error("Expected fetchWithSsrFGuard to be called"); } From c747b46c794437872fe9f546887a78e497bf3194 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:05:15 +0100 Subject: [PATCH 600/806] test: tighten package manager warning assertion --- .../preinstall-package-manager-warning.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index afadea22b27..4263e61375d 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -5,6 +5,15 @@ import { warnIfNonPnpmLifecycle, } from "../../scripts/preinstall-package-manager-warning.mjs"; +function requireFirstWarning(warn: ReturnType): unknown { + const message = warn.mock.calls[0]?.[0]; + expect(message).toBeDefined(); + if (message === undefined) { + throw new Error("expected package manager warning"); + } + return message; +} + describe("detectLifecyclePackageManager", () => { it("prefers npm_config_user_agent when present", () => { expect( @@ -54,7 +63,7 @@ describe("warnIfNonPnpmLifecycle", () => { ), ).toBe(true); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("detected npm"); + expect(requireFirstWarning(warn)).toContain("detected npm"); }); it("stays quiet for pnpm", () => { From 57c82f4ca566a3e9f8be66cb1457590f2b4f631b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:06:00 +0100 Subject: [PATCH 601/806] test: require cli runner async callbacks --- .../memory-host-sdk/src/host/internal.test.ts | 4 ++ src/agents/cli-runner.spawn.test.ts | 62 +++++++++++-------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 3759d963f87..5ae7c473b9d 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -133,6 +133,10 @@ describe("memory host SDK package internals", () => { const entry = expectFileEntry(await buildFileEntry(imagePath, tmpDir, multimodal)); const built = expectMultimodalIndexingChunk(await buildMultimodalChunkForIndexing(entry)); + expect(built.chunk.embeddingInput).toBeDefined(); + if (!built.chunk.embeddingInput) { + throw new Error("Expected multimodal chunk embedding input"); + } expect(built.chunk.embeddingInput.parts).toEqual([ { type: "text", text: "Image file: diagram.png" }, expect.objectContaining({ type: "inline-data", mimeType: "image/png" }), diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index b6ad3f7a2fe..c4048a402be 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -673,23 +673,28 @@ describe("runCliAgent spawn path", () => { it("cancels the managed CLI run when the abort signal fires", async () => { const abortController = new AbortController(); - let resolveWait!: (value: { - reason: - | "manual-cancel" - | "overall-timeout" - | "no-output-timeout" - | "spawn-error" - | "signal" - | "exit"; - exitCode: number | null; - exitSignal: NodeJS.Signals | number | null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: boolean; - noOutputTimedOut: boolean; - }) => void; + let resolveWait: + | ((value: { + reason: + | "manual-cancel" + | "overall-timeout" + | "no-output-timeout" + | "spawn-error" + | "signal" + | "exit"; + exitCode: number | null; + exitSignal: NodeJS.Signals | number | null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: boolean; + noOutputTimedOut: boolean; + }) => void) + | undefined; const cancel = vi.fn((reason?: string) => { + if (!resolveWait) { + throw new Error("Expected managed CLI wait resolver to be initialized"); + } resolveWait({ reason: reason === "manual-cancel" ? "manual-cancel" : "signal", exitCode: null, @@ -1971,16 +1976,18 @@ describe("runCliAgent spawn path", () => { it("does not surface stale stderr after a later Claude live exit", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let stderrListener: ((chunk: string) => void) | undefined; - let resolveExit!: (value: { - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }) => void; + let resolveExit: + | ((value: { + reason: "exit"; + exitCode: number; + exitSignal: null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: false; + noOutputTimedOut: false; + }) => void) + | undefined; const wait = new Promise<{ reason: "exit"; exitCode: number; @@ -2013,6 +2020,9 @@ describe("runCliAgent spawn path", () => { return; } cb?.(); + if (!resolveExit) { + throw new Error("Expected Claude live exit resolver to be initialized"); + } resolveExit({ reason: "exit", exitCode: 1, From 4ae1780805067e3da32caabfef65d726309d87fd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:06:16 +0100 Subject: [PATCH 602/806] test: tighten image provider header assertions --- .../openai-compatible-image-provider.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 90d2a80ba3e..71414476daa 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -60,6 +60,17 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, })); +function requireFirstRequestHeaders(mock: ReturnType): Headers { + const request = mock.mock.calls[0]?.[0] as { headers?: Headers } | undefined; + const headers = request?.headers; + expect(request).toBeDefined(); + expect(headers).toBeInstanceOf(Headers); + if (!headers) { + throw new Error("expected request headers"); + } + return headers; +} + function createProvider(overrides: Partial = {}) { return createOpenAiCompatibleImageGenerationProvider({ id: "sample", @@ -185,7 +196,7 @@ describe("OpenAI-compatible image provider helper", () => { }, }), ); - const headers = postJsonRequestMock.mock.calls[0]?.[0].headers as Headers; + const headers = requireFirstRequestHeaders(postJsonRequestMock); expect(headers.get("Content-Type")).toBe("application/json"); expect(result).toMatchObject({ model: "custom-image", @@ -212,7 +223,7 @@ describe("OpenAI-compatible image provider helper", () => { body: expect.any(FormData), }), ); - const headers = postMultipartRequestMock.mock.calls[0]?.[0].headers as Headers; + const headers = requireFirstRequestHeaders(postMultipartRequestMock); expect(headers.has("Content-Type")).toBe(false); }); From a80b774b32b2f8b973d82358ba1367e9a5947eb6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:07:44 +0100 Subject: [PATCH 603/806] test: tighten extension batch assertion --- test/scripts/test-extension.test.ts | 40 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 6811a9b7d54..50274954a80 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -20,6 +20,13 @@ import { const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); +type RunGroupParams = { + args: string[]; + config: string; + env: Record; + targets: string[]; +}; + function runScript(args: string[], cwd = process.cwd()) { return execFileSync(process.execPath, [scriptPath, ...args], { cwd, @@ -27,6 +34,15 @@ function runScript(args: string[], cwd = process.cwd()) { }); } +function requireFirstMockArg(mock: { mock: { calls: Array<[T, ...unknown[]]> } }): T { + const arg = mock.mock.calls[0]?.[0]; + expect(arg).toBeDefined(); + if (!arg) { + throw new Error("expected first mock call argument"); + } + return arg; +} + function findExtensionWithoutTests() { const extensionId = listAvailableExtensionIds().find( (candidate) => !resolveExtensionTestPlan({ targetArg: candidate, cwd: process.cwd() }).hasTests, @@ -466,19 +482,12 @@ describe("scripts/test-extension.mjs", () => { it("runs extension batch config groups concurrently when requested", async () => { const started: string[] = []; const resolvers: Array<() => void> = []; - const runGroup = vi.fn( - (params: { - args: string[]; - config: string; - env: Record; - targets: string[]; - }) => { - started.push(params.config); - return new Promise((resolve) => { - resolvers.push(() => resolve(0)); - }); - }, - ); + const runGroup = vi.fn((params: RunGroupParams) => { + started.push(params.config); + return new Promise((resolve) => { + resolvers.push(() => resolve(0)); + }); + }); const runPromise = runExtensionBatchPlan( { extensionCount: 3, @@ -527,12 +536,13 @@ describe("scripts/test-extension.mjs", () => { } await expect(runPromise).resolves.toBe(0); expect(runGroup).toHaveBeenCalledTimes(3); - expect(runGroup.mock.calls[0]?.[0]).toMatchObject({ + const firstRunGroupParams = requireFirstMockArg(runGroup); + expect(firstRunGroupParams).toMatchObject({ args: ["--reporter=dot"], config: "heavy", targets: ["extensions/two"], }); - expect(runGroup.mock.calls[0]?.[0].env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toContain( + expect(firstRunGroupParams.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toContain( path.join("node_modules", ".experimental-vitest-cache", "extension-batch", "0-heavy"), ); }); From 98ef659a42cc36d48825e5be80909e3a3e77f142 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:08:22 +0100 Subject: [PATCH 604/806] test: tighten fire and forget log assertion --- src/hooks/fire-and-forget.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/hooks/fire-and-forget.test.ts b/src/hooks/fire-and-forget.test.ts index 2545b2b302e..70656da2aef 100644 --- a/src/hooks/fire-and-forget.test.ts +++ b/src/hooks/fire-and-forget.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { fireAndForgetBoundedHook, fireAndForgetHook } from "./fire-and-forget.js"; +function requireFirstLog(logger: ReturnType): string { + const message = logger.mock.calls[0]?.[0]; + expect(message).toBeDefined(); + if (typeof message !== "string") { + throw new Error("expected string log message"); + } + return message; +} + describe("fireAndForgetHook", () => { it("logs rejection errors as sanitized single-line messages", async () => { const logger = vi.fn(); @@ -11,8 +20,9 @@ describe("fireAndForgetHook", () => { ); await Promise.resolve(); expect(logger).toHaveBeenCalledWith(expect.stringMatching(/^hook failed: boom forged secret/)); - expect(logger.mock.calls[0]?.[0]).not.toContain("\n"); - expect(logger.mock.calls[0]?.[0]).not.toContain("sk-test1234567890"); + const message = requireFirstLog(logger); + expect(message).not.toContain("\n"); + expect(message).not.toContain("sk-test1234567890"); }); it("does not log for resolved tasks", async () => { From 62bafd4e6e848decbeee6e3d96773434ccf70df8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:09:21 +0100 Subject: [PATCH 605/806] test: tighten systemd status assertions --- src/daemon/systemd.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 6e9b0341881..1934025b41b 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -61,6 +61,15 @@ const createWritableStreamMock = () => { }; }; +function requireFirstWrite(write: ReturnType): string { + const value = write.mock.calls[0]?.[0]; + expect(value).toBeDefined(); + if (value === undefined) { + throw new Error("expected systemd status write"); + } + return String(value); +} + function pathLikeToString(pathname: unknown): string { if (typeof pathname === "string") { return pathname; @@ -123,7 +132,7 @@ const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { const { write, stdout } = createWritableStreamMock(); await restartSystemdService({ stdout, env }); expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + expect(requireFirstWrite(write)).toContain("Restarted systemd service"); }; describe("systemd availability", () => { @@ -1188,7 +1197,7 @@ describe("systemd service install and uninstall", () => { await uninstallSystemdService({ env, stdout }); await expect(fs.access(unitPath)).rejects.toMatchObject({ code: "ENOENT" }); - expect(String(write.mock.calls[0]?.[0])).toContain("Removed systemd service"); + expect(requireFirstWrite(write)).toContain("Removed systemd service"); expect(execFileMock).toHaveBeenCalledTimes(2); }); }); @@ -1216,7 +1225,7 @@ describe("systemd service control", () => { await stopSystemdService({ stdout, env: {} }); expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Stopped systemd service"); + expect(requireFirstWrite(write)).toContain("Stopped systemd service"); }); it("allows stop when systemd status is degraded but available", async () => { From 630cf8e0792133a3dc35b1f8a0d53a10e2c4d12b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:09:09 +0100 Subject: [PATCH 606/806] test: require context engine maintenance callbacks --- .../context-engine-maintenance.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index c39e09ee497..b920078c115 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -168,7 +168,7 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { const sessionKey = "agent:main:session-rewrite-handoff"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { events.push("foreground-start"); await new Promise((resolve) => { @@ -206,6 +206,9 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { await flushAsyncWork(); expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled(); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await expect(rewritePromise!).resolves.toEqual({ changed: true, @@ -410,7 +413,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-1"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -486,6 +489,9 @@ describe("runContextEngineMaintenance", () => { deliveryStatus: "pending", }); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(1)); expect(maintain.mock.calls[0]?.[0]).toMatchObject({ @@ -541,7 +547,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-2"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -592,6 +598,9 @@ describe("runContextEngineMaintenance", () => { ); expect(queuedTasks).toHaveLength(1); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(2)); const completedTasks = listTasksForOwnerKey(sessionKey).filter( @@ -616,7 +625,7 @@ describe("runContextEngineMaintenance", () => { resetTaskFlowRegistryForTests({ persist: false }); const sessionKey = "agent:main:session-rerun"; - let releaseFirstMaintenance!: () => void; + let releaseFirstMaintenance: (() => void) | undefined; let maintenanceCalls = 0; const maintain = vi.fn(async () => { maintenanceCalls += 1; @@ -665,6 +674,9 @@ describe("runContextEngineMaintenance", () => { reason: "turn", }); + if (!releaseFirstMaintenance) { + throw new Error("Expected first maintenance release callback to be initialized"); + } releaseFirstMaintenance(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(2)); @@ -818,7 +830,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-3"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let releaseFirstForeground!: () => void; + let releaseFirstForeground: (() => void) | undefined; const firstForeground = enqueueCommandInLane(sessionLane, async () => { events.push("foreground-1-start"); await new Promise((resolve) => { @@ -865,6 +877,9 @@ describe("runContextEngineMaintenance", () => { events.push("foreground-2-end"); }); + if (!releaseFirstForeground) { + throw new Error("Expected first foreground release callback to be initialized"); + } releaseFirstForeground(); await waitForAssertion(() => expect(events).toEqual([ @@ -895,7 +910,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-rewrite-priority"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let allowRewrite!: () => void; + let allowRewrite: (() => void) | undefined; const maintain = vi.fn(async (params?: unknown) => { events.push("maintenance-start"); await new Promise((resolve) => { @@ -965,6 +980,9 @@ describe("runContextEngineMaintenance", () => { events.push("foreground-end"); }); + if (!allowRewrite) { + throw new Error("Expected maintenance rewrite release callback to be initialized"); + } allowRewrite(); await waitForAssertion(() => @@ -1047,7 +1065,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-long"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -1092,6 +1110,9 @@ describe("runContextEngineMaintenance", () => { ), ); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(peekSystemEvents(sessionKey)).toEqual( @@ -1119,7 +1140,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-throttle"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -1177,6 +1198,9 @@ describe("runContextEngineMaintenance", () => { ), ).toHaveLength(2); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await foregroundTurn; } finally { From 1c588ad4526ef940176c89209f2b69fa9979fc0e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:10:18 +0100 Subject: [PATCH 607/806] test: tighten memory backend defaults assertions --- .../src/host/backend-config.test.ts | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 0c688b0d2c3..a753647464f 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -45,6 +45,30 @@ const rootMemoryConfig = (workspaceDir: string): OpenClawConfig => const collectionNames = (resolved: ResolvedMemoryBackendConfig): Set => new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); +function requireQmdConfig( + resolved: ResolvedMemoryBackendConfig, +): NonNullable { + expect(resolved.qmd).toBeDefined(); + if (!resolved.qmd) { + throw new Error("expected qmd memory backend config"); + } + return resolved.qmd; +} + +function requireQmdCollection( + resolved: ResolvedMemoryBackendConfig, + name: string, +): NonNullable["collections"][number] { + const collection = requireQmdConfig(resolved).collections.find( + (candidate) => candidate.name === name, + ); + expect(collection).toBeDefined(); + if (!collection) { + throw new Error(`expected qmd collection ${name}`); + } + return collection; +} + const customQmdCollections = ( resolved: ResolvedMemoryBackendConfig, ): NonNullable["collections"] => @@ -89,25 +113,23 @@ describe("resolveMemoryBackendConfig", () => { } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); expect(resolved.backend).toBe("qmd"); - expect(resolved.qmd?.collections.length).toBe(2); - expect(resolved.qmd?.command).toBe("qmd"); - expect(resolved.qmd?.searchMode).toBe("search"); - expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); - expect(resolved.qmd?.update.onBoot).toBe(true); - expect(resolved.qmd?.update.startup).toBe("off"); - expect(resolved.qmd?.update.startupDelayMs).toBe(120_000); - expect(resolved.qmd?.update.waitForBootSync).toBe(false); - expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); - expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); - expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000); - const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); + const qmd = requireQmdConfig(resolved); + expect(qmd.collections.length).toBe(2); + expect(qmd.command).toBe("qmd"); + expect(qmd.searchMode).toBe("search"); + expect(qmd.update.intervalMs).toBeGreaterThan(0); + expect(qmd.update.onBoot).toBe(true); + expect(qmd.update.startup).toBe("off"); + expect(qmd.update.startupDelayMs).toBe(120_000); + expect(qmd.update.waitForBootSync).toBe(false); + expect(qmd.update.commandTimeoutMs).toBe(30_000); + expect(qmd.update.updateTimeoutMs).toBe(120_000); + expect(qmd.update.embedTimeoutMs).toBe(120_000); + const names = new Set(qmd.collections.map((collection) => collection.name)); expect(names.has("memory-root-main")).toBe(true); expect(names.has("memory-dir-main")).toBe(true); expect(names.has("memory-alt-main")).toBe(false); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); }); it("keeps uppercase MEMORY.md as the root pattern when only lowercase memory.md exists", () => { @@ -115,10 +137,7 @@ describe("resolveMemoryBackendConfig", () => { withMemoryRootEntries([memoryFileEntry("memory.md")], () => { const cfg = rootMemoryConfig(workspaceDir); const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); expect(collectionNames(resolved).has("memory-alt-main")).toBe(false); }); }); @@ -128,10 +147,7 @@ describe("resolveMemoryBackendConfig", () => { withMemoryRootEntries([memoryFileEntry("MEMORY.md"), memoryFileEntry("memory.md")], () => { const cfg = rootMemoryConfig(workspaceDir); const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); expect(collectionNames(resolved).has("memory-alt-main")).toBe(false); }); }); @@ -147,7 +163,7 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.command).toBe("/Applications/QMD Tools/qmd"); + expect(requireQmdConfig(resolved).command).toBe("/Applications/QMD Tools/qmd"); }); it("resolves custom paths relative to workspace", () => { From b1cca76b456f86f1d96fc0b09295061a7e2352a2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:11:17 +0100 Subject: [PATCH 608/806] test: tighten memory backend override assertions --- .../src/host/backend-config.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index a753647464f..fa8a700ec08 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -186,7 +186,10 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("custom-notes")); + const custom = requireQmdConfig(resolved).collections.find((c) => + c.name.startsWith("custom-notes"), + ); + expect(custom).toBeDefined(); expect(custom).toMatchObject({ path: path.resolve("/workspace/root", "notes") }); }); @@ -363,10 +366,11 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.update.waitForBootSync).toBe(true); - expect(resolved.qmd?.update.commandTimeoutMs).toBe(12_000); - expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000); - expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000); + const update = requireQmdConfig(resolved).update; + expect(update.waitForBootSync).toBe(true); + expect(update.commandTimeoutMs).toBe(12_000); + expect(update.updateTimeoutMs).toBe(480_000); + expect(update.embedTimeoutMs).toBe(360_000); }); it("resolves qmd startup refresh overrides", () => { @@ -383,9 +387,10 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.update.startup).toBe("idle"); - expect(resolved.qmd?.update.startupDelayMs).toBe(45_000); - expect(resolved.qmd?.update.onBoot).toBe(true); + const update = requireQmdConfig(resolved).update; + expect(update.startup).toBe("idle"); + expect(update.startupDelayMs).toBe(45_000); + expect(update.onBoot).toBe(true); }); it("resolves qmd search mode override", () => { @@ -399,7 +404,7 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.searchMode).toBe("vsearch"); + expect(requireQmdConfig(resolved).searchMode).toBe("vsearch"); }); it("resolves qmd mcporter search tool override", () => { @@ -414,8 +419,9 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.searchMode).toBe("query"); - expect(resolved.qmd?.searchTool).toBe("hybrid_search"); + const qmd = requireQmdConfig(resolved); + expect(qmd.searchMode).toBe("query"); + expect(qmd.searchTool).toBe("hybrid_search"); }); }); From 368fd23af6bb78d4f396260ddb1a775f157286d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:11:13 +0100 Subject: [PATCH 609/806] test: require before tool call callback --- src/agents/pi-tools.before-tool-call.integration.e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 398e1fc9352..9e48728fb9d 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -359,7 +359,7 @@ describe("before_tool_call hook integration for client tools", () => { }); it("preserves client tool source order when hooks resolve out of order", async () => { - let releaseFirstHook!: () => void; + let releaseFirstHook: (() => void) | undefined; const firstHookGate = new Promise((resolve) => { releaseFirstHook = resolve; }); @@ -445,6 +445,9 @@ describe("before_tool_call hook integration for client tools", () => { { name: "second_tool", completed: true }, ]); + if (!releaseFirstHook) { + throw new Error("Expected first before-tool-call hook release callback to be initialized"); + } releaseFirstHook(); await firstRun; From a51a9fcd065220196114ba27365f32733f2b90d2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:12:05 +0100 Subject: [PATCH 610/806] test: tighten usage helper tool assertions --- ui/src/ui/usage-helpers.node.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/ui/usage-helpers.node.test.ts index 4ad05476b2c..eedaa89654f 100644 --- a/ui/src/ui/usage-helpers.node.test.ts +++ b/ui/src/ui/usage-helpers.node.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest"; import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts"; +function requireFirstTool(tools: Array<[string, number]>): [string, number] { + const tool = tools[0]; + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("expected parsed tool summary entry"); + } + return tool; +} + describe("usage-helpers", () => { it("tokenizes query terms including quoted strings", () => { const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors'); @@ -40,7 +49,8 @@ describe("usage-helpers", () => { ); expect(res.summary).toContain("read"); expect(res.summary).toContain("exec"); - expect(res.tools[0]?.[0]).toBe("read"); - expect(res.tools[0]?.[1]).toBe(2); + const firstTool = requireFirstTool(res.tools); + expect(firstTool[0]).toBe("read"); + expect(firstTool[1]).toBe(2); }); }); From 5e80c6abedb2f7929579923c7a0e2d8dd4ce5266 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:13:16 +0100 Subject: [PATCH 611/806] test: tighten channel config snapshot assertion --- ui/src/ui/app-channels.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/app-channels.test.ts b/ui/src/ui/app-channels.test.ts index 16847e97d27..0b4bad647cc 100644 --- a/ui/src/ui/app-channels.test.ts +++ b/ui/src/ui/app-channels.test.ts @@ -31,6 +31,16 @@ function createChannelsSnapshot(name = "saved"): ChannelsStatusSnapshot { }; } +function requireConfigSnapshot( + host: ChannelsActionHostForTest, +): NonNullable { + expect(host.configSnapshot).toBeDefined(); + if (!host.configSnapshot) { + throw new Error("expected config snapshot"); + } + return host.configSnapshot; +} + function createHost(request: ReturnType = vi.fn()): ChannelsActionHostForTest { return { applySessionKey: "main", @@ -133,7 +143,7 @@ describe("channel config actions", () => { expect(host.lastError).toContain("Config hash mismatch"); expect(host.configFormDirty).toBe(true); expect(host.configForm).toEqual({ gateway: { mode: "local" } }); - expect(host.configSnapshot?.config).toEqual({ gateway: { mode: "remote" } }); + expect(requireConfigSnapshot(host).config).toEqual({ gateway: { mode: "remote" } }); expect(request.mock.calls.map(([method]) => method)).not.toContain("channels.status"); }); }); From 03e7fcfcc88f2c6e831a777c3095c2638ab51e10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:13:28 +0100 Subject: [PATCH 612/806] test: simplify supervisor adapter fixture --- src/process/supervisor/supervisor.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 74661876819..fd4ce94799c 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -55,9 +55,7 @@ function createStubChildAdapter(options?: { ); const killMock = vi.fn(); const disposeMock = vi.fn(); - let adapter!: StubChildAdapter; - - adapter = { + const adapter: StubChildAdapter = { pid: options?.pid ?? 1234, stdin: undefined, onStdout: (listener) => { From 0ff793b9965e615e873dcd14b195a17c61e71cb9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:14:04 +0100 Subject: [PATCH 613/806] test: tighten slug generator runner assertions --- src/hooks/llm-slug-generator.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 66b2be9a202..1e34ab6277a 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -22,6 +22,15 @@ vi.mock("../agents/pi-embedded.js", () => ({ import { generateSlugViaLLM } from "./llm-slug-generator.js"; +function requireFirstRunOptions(): unknown { + const options = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(options).toBeDefined(); + if (!options) { + throw new Error("expected embedded Pi agent run options"); + } + return options; +} + describe("generateSlugViaLLM", () => { beforeEach(() => { runEmbeddedPiAgentMock.mockReset(); @@ -37,7 +46,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ timeoutMs: 15_000, cleanupBundleMcpOnRunEnd: true, @@ -58,7 +67,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ timeoutMs: 500_000, }), @@ -96,7 +105,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ provider: "openai-codex", model: "gpt-5.5", From 371563f0a23e57e6953dbdaf90a3add9eb528b9c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:15:13 +0100 Subject: [PATCH 614/806] test: tighten runtime fetch init assertions --- src/infra/net/runtime-fetch.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/infra/net/runtime-fetch.test.ts b/src/infra/net/runtime-fetch.test.ts index 562ddb9f16b..314bd0502ab 100644 --- a/src/infra/net/runtime-fetch.test.ts +++ b/src/infra/net/runtime-fetch.test.ts @@ -40,6 +40,15 @@ class MockProxyAgent { readonly __testStub = true; } +function requireFetchInit(mock: ReturnType): RequestInit { + const init = mock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init).toBeDefined(); + if (!init) { + throw new Error("expected runtime fetch init"); + } + return init; +} + afterEach(() => { Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); @@ -74,7 +83,7 @@ describe("fetchWithRuntimeDispatcher", () => { }); expect(response.status).toBe(200); - const sentHeaders = runtimeFetch.mock.calls[0]?.[1]?.headers; + const sentHeaders = requireFetchInit(runtimeFetch).headers; expect(sentHeaders).not.toBe(headers); expect(Object.getOwnPropertySymbols(sentHeaders as object)).toEqual([]); expect(Object.getOwnPropertySymbols(headers)).toHaveLength(1); @@ -124,7 +133,7 @@ describe("fetchWithRuntimeDispatcher", () => { expect(response.status).toBe(200); expect(runtimeFetch).toHaveBeenCalledTimes(1); - const sentInit = runtimeFetch.mock.calls[0]?.[1] as RequestInit; + const sentInit = requireFetchInit(runtimeFetch); const sentHeaders = new Headers(sentInit.headers); expect(sentHeaders.has("content-length")).toBe(false); expect(sentHeaders.has("content-type")).toBe(false); From 834b9950fe2fac4835afd0c0296999d74926cec2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:15:46 +0100 Subject: [PATCH 615/806] test: require codex harness reference --- extensions/codex/src/app-server/run-attempt.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 80609a3a4f1..af2031fa68e 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1749,16 +1749,20 @@ describe("runCodexAppServerAttempt", () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const resetsAt = Math.ceil(Date.now() / 1000) + 120; - let harness!: ReturnType; - harness = createStartedThreadHarness(async (method) => { + const harnessRef: { current?: ReturnType } = {}; + const harness = createStartedThreadHarness(async (method) => { if (method === "turn/start") { - await harness.notify(rateLimitsUpdated(resetsAt)); + if (!harnessRef.current) { + throw new Error("Expected Codex app-server harness to be initialized"); + } + await harnessRef.current.notify(rateLimitsUpdated(resetsAt)); throw Object.assign(new Error("You've reached your usage limit."), { data: { codexErrorInfo: "usageLimitExceeded" }, }); } return undefined; }); + harnessRef.current = harness; const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch( (error: unknown) => error, From ad0abdb3d062bc42e67497bfe1a5645a7fdce942 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Fri, 8 May 2026 12:16:35 -0700 Subject: [PATCH 616/806] docs(imessage): call out includeAttachments off-by-default (#79486) Merged via squash. Prepared head SHA: e2e507b6b0616750cecbc79f568814217fd28c51 Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine --- docs/channels/imessage-from-bluebubbles.md | 2 +- docs/channels/imessage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index de5d8005bca..c16cb3f42a0 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -92,7 +92,7 @@ iMessage and BlueBubbles share a lot of channel-level config. The keys that chan | `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. | | `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. | | `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. | -| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same. | +| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same shape, **same off-by-default**. If you had attachments flowing on BlueBubbles you must re-set this explicitly on the iMessage block — it does not carry over implicitly, and inbound photos/media will be silently dropped with no `Inbound message` log line until you do. | | `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. | | _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. | | `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. | diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 5071b8132c3..6a59f0b37d7 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -403,7 +403,7 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior. - - inbound attachment ingestion is optional: `channels.imessage.includeAttachments` + - inbound attachment ingestion is **off by default** — set `channels.imessage.includeAttachments: true` to forward photos, voice memos, video, and other attachments to the agent. With it disabled, attachment-only iMessages are dropped before reaching the agent and may produce no `Inbound message` log line at all. - remote attachment paths can be fetched via SCP when `remoteHost` is set - attachment paths must match allowed roots: - `channels.imessage.attachmentRoots` (local) From 32ffbd03f2cde0fac2fb8fc3300c7b09ff6339ad Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:16:53 +0100 Subject: [PATCH 617/806] test: tighten scoped channel config assertions --- test/vitest-scoped-config.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 502e92df96f..2ea85223c67 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -396,7 +396,7 @@ describe("scoped vitest configs", () => { }); it("keeps the core channel lane limited to non-extension roots", () => { - expect(defaultChannelsConfig.test?.include).toEqual(["src/channels/**/*.test.ts"]); + expect(requireTestConfig(defaultChannelsConfig).include).toEqual(["src/channels/**/*.test.ts"]); }); it("loads channel include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => { @@ -419,7 +419,7 @@ describe("scoped vitest configs", () => { OPENCLAW_VITEST_INCLUDE_FILE: includeFile, }); - expect(config.test?.include).toEqual([ + expect(requireTestConfig(config).include).toEqual([ bundledPluginFile("discord", "src/monitor/message-handler.preflight.acp-bindings.test.ts"), ]); } finally { @@ -428,11 +428,7 @@ describe("scoped vitest configs", () => { }); it("defaults extension tests to threads with the non-isolated runner", () => { - expect(defaultExtensionsConfig.test?.isolate).toBe(false); - expect(defaultExtensionsConfig.test?.pool).toBe("threads"); - expect(normalizeConfigPath(defaultExtensionsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultExtensionsConfig); }); it("normalizes split extension channel include patterns relative to the scoped dir", () => { From 45e8f97886d2f5500ed3c68f10b709d1eb805279 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:17:44 +0100 Subject: [PATCH 618/806] test: tighten scoped extension include assertions --- test/vitest-scoped-config.test.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 2ea85223c67..ec4eb668d22 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -439,34 +439,40 @@ describe("scoped vitest configs", () => { [defaultExtensionSignalConfig, "signal/**/*.test.ts"], [defaultExtensionImessageConfig, "imessage/**/*.test.ts"], ] as const) { - expect(config.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(config.test?.include).toEqual([include]); + const testConfig = requireTestConfig(config); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual([include]); } }); it("normalizes acpx extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionAcpxConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionAcpxConfig.test?.include).toEqual(["acpx/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionAcpxConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["acpx/**/*.test.ts"]); }); it("normalizes diffs extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionDiffsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionDiffsConfig.test?.include).toEqual(["diffs/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionDiffsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["diffs/**/*.test.ts"]); }); it("normalizes feishu extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionFeishuConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionFeishuConfig.test?.include).toEqual(["feishu/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionFeishuConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["feishu/**/*.test.ts"]); }); it("normalizes irc extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionIrcConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionIrcConfig.test?.include).toEqual(["irc/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionIrcConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["irc/**/*.test.ts"]); }); it("normalizes extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes extension provider include patterns relative to the scoped dir", () => { From baa0face5c43d96391a8ba9a326135e7796f5026 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:18:54 +0100 Subject: [PATCH 619/806] test: tighten session entry line assertions --- .../src/host/session-files.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index 702e9fe8fdf..5c694aef848 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -6,6 +6,7 @@ import { buildSessionEntry, listSessionFilesForAgent, sessionPathForFile, + type SessionFileEntry, } from "./session-files.js"; let fixtureRoot: string; @@ -36,6 +37,14 @@ afterEach(() => { } }); +function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry { + expect(entry).toBeTruthy(); + if (!entry) { + throw new Error("expected session entry"); + } + return entry; +} + describe("listSessionFilesForAgent", () => { it("includes reset and deleted transcripts in session file listing", async () => { const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); @@ -110,19 +119,19 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); // The content should have 3 lines (3 message records) - const contentLines = entry?.content.split("\n"); + const contentLines = entry.content.split("\n"); expect(contentLines).toHaveLength(3); - expect(contentLines?.[0]).toContain("User: Hello world"); - expect(contentLines?.[1]).toContain("Assistant: Hi there"); - expect(contentLines?.[2]).toContain("User: Tell me a joke"); + expect(contentLines[0]).toContain("User: Hello world"); + expect(contentLines[1]).toContain("Assistant: Hi there"); + expect(contentLines[2]).toContain("User: Tell me a joke"); // lineMap should map each content line to its original JSONL line (1-indexed) // Content line 0 → JSONL line 4 (the first user message) // Content line 1 → JSONL line 6 (the assistant message) // Content line 2 → JSONL line 7 (the second user message) - expect(entry?.lineMap).toEqual([4, 6, 7]); + expect(entry.lineMap).toEqual([4, 6, 7]); }); it("returns empty lineMap when no messages are found", async () => { @@ -133,9 +142,9 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "empty-session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry?.content).toBe(""); - expect(entry?.lineMap).toEqual([]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); }); it("indexes usage-counted reset/deleted archives but still skips bak and checkpoint artifacts", async () => { From 579f091cc4fb53384f57734c7e8dc3392602e04a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:19:42 +0100 Subject: [PATCH 620/806] test: tighten archived session entry assertions --- .../src/host/session-files.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index 5c694aef848..22e975d7fdb 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -164,24 +164,24 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(bakPath, content); fsSync.writeFileSync(checkpointPath, content); - const resetEntry = await buildSessionEntry(resetPath); - const deletedEntry = await buildSessionEntry(deletedPath); - const bakEntry = await buildSessionEntry(bakPath); - const checkpointEntry = await buildSessionEntry(checkpointPath); + const resetEntry = requireSessionEntry(await buildSessionEntry(resetPath)); + const deletedEntry = requireSessionEntry(await buildSessionEntry(deletedPath)); + const bakEntry = requireSessionEntry(await buildSessionEntry(bakPath)); + const checkpointEntry = requireSessionEntry(await buildSessionEntry(checkpointPath)); // Usage-counted archives (reset, deleted) must surface real content so // post-reset memory_search can recover prior session history. - expect(resetEntry?.content).toContain("User: Archived hello"); - expect(resetEntry?.lineMap).toEqual([1]); - expect(deletedEntry?.content).toContain("User: Archived hello"); - expect(deletedEntry?.lineMap).toEqual([1]); + expect(resetEntry.content).toContain("User: Archived hello"); + expect(resetEntry.lineMap).toEqual([1]); + expect(deletedEntry.content).toContain("User: Archived hello"); + expect(deletedEntry.lineMap).toEqual([1]); // .bak and compaction checkpoints remain opaque pre-archive / snapshot // artifacts and stay empty so they do not get double-indexed. - expect(bakEntry?.content).toBe(""); - expect(bakEntry?.lineMap).toEqual([]); - expect(checkpointEntry?.content).toBe(""); - expect(checkpointEntry?.lineMap).toEqual([]); + expect(bakEntry.content).toBe(""); + expect(bakEntry.lineMap).toEqual([]); + expect(checkpointEntry.content).toBe(""); + expect(checkpointEntry.lineMap).toEqual([]); }); it("keeps cron-run deleted archives opaque when the live session store entry is gone", async () => { From 4016a4f96d289183cb544ec15dc745e9da71925e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:20:07 +0100 Subject: [PATCH 621/806] test: remove final async placeholders --- ...s.before-tool-call.integration.e2e.test.ts | 38 ++++--------------- ...ets-active-session-native-stop.e2e.test.ts | 13 ++++++- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 9e48728fb9d..be7233891b6 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -13,37 +13,13 @@ import { patchPluginSessionExtension } from "../plugins/host-hook-state.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginHookRegistration } from "../plugins/types.js"; - -type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js"); -type PiToolsAbortModule = typeof import("./pi-tools.abort.js"); -type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js"); - -type ToClientToolDefinitions = ToolDefinitionAdapterModule["toClientToolDefinitions"]; -type ToToolDefinitions = ToolDefinitionAdapterModule["toToolDefinitions"]; -type WrapToolWithAbortSignal = PiToolsAbortModule["wrapToolWithAbortSignal"]; -type BeforeToolCallTesting = BeforeToolCallModule["__testing"]; -type ConsumeAdjustedParamsForToolCall = BeforeToolCallModule["consumeAdjustedParamsForToolCall"]; -type WrapToolWithBeforeToolCallHook = BeforeToolCallModule["wrapToolWithBeforeToolCallHook"]; - -let toClientToolDefinitions!: ToClientToolDefinitions; -let toToolDefinitions!: ToToolDefinitions; -let wrapToolWithAbortSignal!: WrapToolWithAbortSignal; -let beforeToolCallTesting!: BeforeToolCallTesting; -let consumeAdjustedParamsForToolCall!: ConsumeAdjustedParamsForToolCall; -let wrapToolWithBeforeToolCallHook!: WrapToolWithBeforeToolCallHook; - -beforeEach(async () => { - if (!wrapToolWithBeforeToolCallHook) { - ({ toClientToolDefinitions, toToolDefinitions } = - await import("./pi-tool-definition-adapter.js")); - ({ wrapToolWithAbortSignal } = await import("./pi-tools.abort.js")); - ({ - __testing: beforeToolCallTesting, - consumeAdjustedParamsForToolCall, - wrapToolWithBeforeToolCallHook, - } = await import("./pi-tools.before-tool-call.js")); - } -}); +import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; +import { + __testing as beforeToolCallTesting, + consumeAdjustedParamsForToolCall, + wrapToolWithBeforeToolCallHook, +} from "./pi-tools.before-tool-call.js"; type BeforeToolCallHandlerMock = ReturnType; diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 8486273dc8c..3e8e4dc9162 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -92,11 +92,20 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }, })); -let getReplyFromConfig!: GetReplyFromConfig; +let capturedGetReplyFromConfig: GetReplyFromConfig | undefined; installTriggerHandlingReplyHarness((impl) => { - getReplyFromConfig = impl; + capturedGetReplyFromConfig = impl; }); +function getReplyFromConfig( + ...args: Parameters +): ReturnType { + if (!capturedGetReplyFromConfig) { + throw new Error("Expected trigger handling reply harness to install getReplyFromConfig"); + } + return capturedGetReplyFromConfig(...args); +} + const BASE_MESSAGE = { Body: "hello", From: "+1002", From 57f6521e3116dca91f1a3a71206d4a5e8b4cc907 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:20:48 +0100 Subject: [PATCH 622/806] test: tighten fallback status assertions --- ui/src/ui/app-tool-stream.node.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index b114f7ddf77..c99846a86b3 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -65,6 +65,14 @@ function expectCompactionCompleteAndAutoClears(host: MutableHost) { expect(host.compactionClearTimer).toBeNull(); } +function requireFallbackStatus(host: MutableHost): FallbackStatus { + expect(host.fallbackStatus).toBeTruthy(); + if (!host.fallbackStatus) { + throw new Error("expected fallback status"); + } + return host.fallbackStatus; +} + function useToolStreamFakeTimers(): void { vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); } @@ -107,11 +115,10 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus?.selected).toBe( - "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", - ); - expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5"); - expect(host.fallbackStatus?.reason).toBe("rate limit"); + const fallbackStatus = requireFallbackStatus(host); + expect(fallbackStatus.selected).toBe("fireworks/accounts/fireworks/routers/kimi-k2p5-turbo"); + expect(fallbackStatus.active).toBe("deepinfra/moonshotai/Kimi-K2.5"); + expect(fallbackStatus.reason).toBe("rate limit"); vi.useRealTimers(); }); @@ -194,8 +201,9 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus?.phase).toBe("cleared"); - expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5"); + const fallbackStatus = requireFallbackStatus(host); + expect(fallbackStatus.phase).toBe("cleared"); + expect(fallbackStatus.previous).toBe("deepinfra/moonshotai/Kimi-K2.5"); vi.useRealTimers(); }); From 5b478a8fdf35041d05846e08dd265c71e6603b4a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:21:41 +0100 Subject: [PATCH 623/806] test: tighten ssh config spawn assertions --- src/infra/ssh-config.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index de4a3cdf15f..5227b0e583c 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -46,6 +46,15 @@ vi.mock("node:child_process", async () => { const spawnMock = vi.mocked(spawn); +function requireSpawnArgs(index: number): string[] { + const args = spawnMock.mock.calls[index]?.[1] as string[] | undefined; + expect(args).toBeDefined(); + if (!args) { + throw new Error("expected ssh spawn args"); + } + return args; +} + let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput; let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig; @@ -81,8 +90,7 @@ describe("ssh-config", () => { expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); expect(config?.port).toBe(2222); expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]); - const args = spawnMock.mock.calls[0]?.[1] as string[] | undefined; - expect(args?.slice(-2)).toEqual(["--", "me@alias"]); + expect(requireSpawnArgs(0).slice(-2)).toEqual(["--", "me@alias"]); }); it("adds non-default port and trimmed identity arguments", async () => { From f9692d6d28de9404ee4ddedefead366b403c8014 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:22:37 +0100 Subject: [PATCH 624/806] test: tighten scoped provider config assertions --- test/vitest-scoped-config.test.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index ec4eb668d22..06077e668a1 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -476,27 +476,27 @@ describe("scoped vitest configs", () => { }); it("normalizes extension provider include patterns relative to the scoped dir", () => { - expect(defaultExtensionProvidersConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionProvidersConfig.test?.include).toEqual( + const providersTestConfig = requireTestConfig(defaultExtensionProvidersConfig); + expect(providersTestConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(providersTestConfig.include).toEqual( expect.arrayContaining(["xai/**/*.test.ts", "google/**/*.test.ts"]), ); - expect(defaultExtensionProvidersConfig.test?.include).not.toContain("openai/**/*.test.ts"); - expect(defaultExtensionProviderOpenAiConfig.test?.dir).toBe( - path.join(process.cwd(), "extensions"), - ); - expect(defaultExtensionProviderOpenAiConfig.test?.include).toEqual(["openai/**/*.test.ts"]); + expect(providersTestConfig.include).not.toContain("openai/**/*.test.ts"); + const openAiTestConfig = requireTestConfig(defaultExtensionProviderOpenAiConfig); + expect(openAiTestConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(openAiTestConfig.include).toEqual(["openai/**/*.test.ts"]); }); it("normalizes extension messaging include patterns relative to the scoped dir", () => { - expect(defaultExtensionMessagingConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMessagingConfig.test?.include).toEqual( - expect.arrayContaining(["googlechat/**/*.test.ts"]), - ); + const testConfig = requireTestConfig(defaultExtensionMessagingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(expect.arrayContaining(["googlechat/**/*.test.ts"])); }); it("normalizes matrix extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMatrixConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMatrixConfig.test?.include).toEqual(["matrix/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMatrixConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["matrix/**/*.test.ts"]); }); it("normalizes mattermost extension include patterns relative to the scoped dir", () => { From 0c4ccdc3c7b8c55edb89050c3d362a58d376657c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:23:41 +0100 Subject: [PATCH 625/806] test: tighten system run command assertions --- src/node-host/invoke-system-run.test.ts | 26 +++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index e027f0b2ac9..08c2f43beeb 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -131,6 +131,15 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); } + function requireFirstRunCommandArgs(runCommand: MockedRunCommand): string[] { + const args = vi.mocked(runCommand).mock.calls[0]?.[0] as string[] | undefined; + expect(args).toBeDefined(); + if (!args) { + throw new Error("expected runCommand args"); + } + return args; + } + function expectApprovalRequiredDenied(params: { sendNodeEvent: MockedSendNodeEvent; sendInvokeResult: MockedSendInvokeResult; @@ -598,8 +607,12 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { continue; } - const runArgs = vi.mocked(invoke.runCommand).mock.calls[0]?.[0] as string[] | undefined; - expect(runArgs).toEqual(["env", "sh", "-c", "echo SAFE"]); + expect(requireFirstRunCommandArgs(invoke.runCommand)).toEqual([ + "env", + "sh", + "-c", + "echo SAFE", + ]); expect(fs.existsSync(marker)).toBe(false); expectInvokeOk(invoke.sendInvokeResult); } @@ -621,10 +634,11 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expect(transparent.runCommand).not.toHaveBeenCalled(); expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" }); } else { - const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as - | string[] - | undefined; - expect(runArgs).toEqual([expect.stringMatching(/(^|[/\\])tr$/), "a", "b"]); + expect(requireFirstRunCommandArgs(transparent.runCommand)).toEqual([ + expect.stringMatching(/(^|[/\\])tr$/), + "a", + "b", + ]); expectInvokeOk(transparent.sendInvokeResult); } From cea589a8262cb63056aa8edd99c71b075ce95116 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:24:36 +0100 Subject: [PATCH 626/806] test: tighten task registry upsert assertion --- src/tasks/task-registry.store.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index a380c8524bd..f11ed93a556 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -21,6 +21,15 @@ import type { TaskRecord } from "./task-registry.types.js"; const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; +function requireFirstUpsertParams(upsertTaskWithDeliveryState: ReturnType): unknown { + const params = upsertTaskWithDeliveryState.mock.calls[0]?.[0]; + expect(params).toBeDefined(); + if (!params) { + throw new Error("expected task upsert params"); + } + return params; +} + function createStoredTask(): TaskRecord { return { taskId: "task-restored", @@ -174,7 +183,7 @@ describe("task-registry store runtime", () => { expect(deleteTaskRecordById(created.taskId)).toBe(true); expect(upsertTaskWithDeliveryState).toHaveBeenCalled(); - expect(upsertTaskWithDeliveryState.mock.calls[0]?.[0]).toMatchObject({ + expect(requireFirstUpsertParams(upsertTaskWithDeliveryState)).toMatchObject({ task: expect.objectContaining({ taskId: created.taskId, }), From 79d5f49735c3ff300d18a1fd8a57e04cd797e3e5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:26:11 +0100 Subject: [PATCH 627/806] test: tighten scoped messaging config assertions --- test/vitest-scoped-config.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 06077e668a1..0eb87312553 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -500,23 +500,27 @@ describe("scoped vitest configs", () => { }); it("normalizes mattermost extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMattermostConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMattermostConfig.test?.include).toEqual(["mattermost/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMattermostConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["mattermost/**/*.test.ts"]); }); it("normalizes msteams extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMsTeamsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMsTeamsConfig.test?.include).toEqual(["msteams/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMsTeamsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["msteams/**/*.test.ts"]); }); it("normalizes telegram extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionTelegramConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionTelegramConfig.test?.include).toEqual(["telegram/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionTelegramConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["telegram/**/*.test.ts"]); }); it("normalizes whatsapp extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionWhatsAppConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionWhatsAppConfig.test?.include).toEqual(["whatsapp/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionWhatsAppConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["whatsapp/**/*.test.ts"]); }); it("normalizes zalo extension include patterns relative to the scoped dir", () => { From 0b8a2204a371375350a15a0e145142dbe7ac3d07 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:27:53 +0100 Subject: [PATCH 628/806] test: tighten mobile controls dropdown assertions --- ui/src/ui/app-render.helpers.browser.test.ts | 33 +++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index e172eeeb9f7..2f8da37f2ef 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -81,6 +81,14 @@ function requireButton( return button; } +function requireElement(element: T | null | undefined, label: string): T { + expect(element).toBeInstanceOf(Element); + if (!element) { + throw new Error(`Expected ${label} element`); + } + return element; +} + describe("chat header controls (browser)", () => { it("renders explicit hover tooltip metadata for the top-right action buttons", async () => { const container = document.createElement("div"); @@ -218,16 +226,19 @@ describe("chat header controls (browser)", () => { container.querySelector(".chat-controls-mobile-toggle"), "mobile controls toggle", ); - const dropdown = container.querySelector(".chat-controls-dropdown"); + const dropdown = requireElement( + container.querySelector(".chat-controls-dropdown"), + "mobile controls dropdown", + ); expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(toggle.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); - expect(dropdown?.id).toBe("chat-mobile-controls-dropdown"); - expect(dropdown?.classList.contains("open")).toBe(false); + expect(dropdown.id).toBe("chat-mobile-controls-dropdown"); + expect(dropdown.classList.contains("open")).toBe(false); toggle.click(); expect(setChatMobileControlsOpen).toHaveBeenCalledWith(true, { trigger: toggle }); - expect(dropdown?.classList.contains("open")).toBe(false); + expect(dropdown.classList.contains("open")).toBe(false); render( renderChatMobileToggle( @@ -240,9 +251,15 @@ describe("chat header controls (browser)", () => { ); await Promise.resolve(); - const openToggle = container.querySelector(".chat-controls-mobile-toggle"); - const openDropdown = container.querySelector(".chat-controls-dropdown"); - expect(openToggle?.getAttribute("aria-expanded")).toBe("true"); - expect(openDropdown?.classList.contains("open")).toBe(true); + const openToggle = requireButton( + container.querySelector(".chat-controls-mobile-toggle"), + "open mobile controls toggle", + ); + const openDropdown = requireElement( + container.querySelector(".chat-controls-dropdown"), + "open mobile controls dropdown", + ); + expect(openToggle.getAttribute("aria-expanded")).toBe("true"); + expect(openDropdown.classList.contains("open")).toBe(true); }); }); From f8187cadc8979b1b16e0bfb93372d64baf74f600 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:27:57 +0100 Subject: [PATCH 629/806] fix: canonicalize gemini configured catalog ids --- CHANGELOG.md | 1 + src/agents/model-selection-shared.ts | 9 ++++-- src/agents/model-selection.test.ts | 47 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba782663dd9..f931c532149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. +- Google/Gemini: emit canonical `google/gemini-3.1-pro-preview` ids from configured provider catalog rows so model list and selection paths can test Gemini 3.1 instead of retired Gemini 3 Pro. - Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index b8e702f4fa2..09753f00af6 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -123,7 +123,11 @@ export function inferUniqueProviderFromConfiguredModels(params: { if (!modelId) { continue; } - if (modelId === model || normalizeLowercaseStringOrEmpty(modelId) === normalized) { + const normalizedModelId = normalizeStaticProviderModelId(providerId, modelId); + if ( + normalizedModelId === model || + normalizeLowercaseStringOrEmpty(normalizedModelId) === normalized + ) { addProvider(providerId); } } @@ -834,7 +838,8 @@ export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): Mo continue; } for (const model of provider.models) { - const id = normalizeOptionalString(model?.id) ?? ""; + const rawId = normalizeOptionalString(model?.id) ?? ""; + const id = rawId ? normalizeStaticProviderModelId(providerId, rawId) : ""; if (!id) { continue; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 62a9c15b75d..7ebd9ae5f92 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -5,6 +5,7 @@ import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.j import { migrateLegacyRuntimeModelRef } from "./model-runtime-aliases.js"; import { buildAllowedModelSet, + buildConfiguredModelCatalog, inferUniqueProviderFromConfiguredModels, parseModelRef, buildModelAliasIndex, @@ -689,6 +690,25 @@ describe("model-selection", () => { ).toBe("qwen-dashscope"); }); + it("infers Google provider from canonicalized configured provider catalogs", () => { + const cfg = { + models: { + providers: { + google: { + models: [{ id: "gemini-3-pro-preview" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "gemini-3.1-pro-preview", + }), + ).toBe("google"); + }); + it("returns undefined when provider catalog matches are ambiguous", () => { const cfg = { models: { @@ -712,6 +732,33 @@ describe("model-selection", () => { }); }); + describe("buildConfiguredModelCatalog", () => { + it("emits canonical Google Gemini 3.1 provider model ids", () => { + const cfg = { + models: { + providers: { + google: { + models: [ + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(buildConfiguredModelCatalog({ cfg })).toContainEqual( + expect.objectContaining({ + provider: "google", + id: "gemini-3.1-pro-preview", + name: "Gemini 3 Pro", + }), + ); + }); + }); + describe("buildModelAliasIndex", () => { it("should build alias index from config", () => { const cfg: Partial = { From 2187f984349315fb3f574e702fc8dd726328c7bb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:29:21 +0100 Subject: [PATCH 630/806] test: tighten qa credential fetch assertion --- .../qa-lab/src/qa-credentials-admin.runtime.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts index 8f13bd06303..7f0122ddd52 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts @@ -16,6 +16,15 @@ function jsonResponse(payload: unknown, status = 200) { }); } +function requireFirstFetchInput(fetchImpl: ReturnType): RequestInfo | URL { + const input = fetchImpl.mock.calls[0]?.[0] as RequestInfo | URL | undefined; + expect(input).toBeDefined(); + if (!input) { + throw new Error("expected fetch input"); + } + return input; +} + describe("qa credential admin runtime", () => { it("adds a credential set through the admin endpoint", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => @@ -112,7 +121,9 @@ describe("qa credential admin runtime", () => { fetchImpl, }); - expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://127.0.0.1:3210/qa-credentials/v1/admin/list"); + expect(requireFirstFetchInput(fetchImpl)).toBe( + "http://127.0.0.1:3210/qa-credentials/v1/admin/list", + ); }); it("rejects unsafe endpoint-prefix overrides", async () => { From f7189a4139b22cfdfa8049722493adb2b0d7a602 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:30:04 +0100 Subject: [PATCH 631/806] test: tighten memory host package assertions --- .../src/host/backend-config.test.ts | 6 ++-- .../src/host/batch-http.test.ts | 1 - .../memory-host-sdk/src/host/internal.test.ts | 17 +++++----- .../src/host/session-files.test.ts | 31 +++++++++---------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index fa8a700ec08..e909a6039da 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -48,7 +48,6 @@ const collectionNames = (resolved: ResolvedMemoryBackendConfig): Set => function requireQmdConfig( resolved: ResolvedMemoryBackendConfig, ): NonNullable { - expect(resolved.qmd).toBeDefined(); if (!resolved.qmd) { throw new Error("expected qmd memory backend config"); } @@ -62,7 +61,6 @@ function requireQmdCollection( const collection = requireQmdConfig(resolved).collections.find( (candidate) => candidate.name === name, ); - expect(collection).toBeDefined(); if (!collection) { throw new Error(`expected qmd collection ${name}`); } @@ -189,7 +187,9 @@ describe("resolveMemoryBackendConfig", () => { const custom = requireQmdConfig(resolved).collections.find((c) => c.name.startsWith("custom-notes"), ); - expect(custom).toBeDefined(); + if (!custom) { + throw new Error("expected custom-notes qmd collection"); + } expect(custom).toMatchObject({ path: path.resolve("/workspace/root", "notes") }); }); diff --git a/packages/memory-host-sdk/src/host/batch-http.test.ts b/packages/memory-host-sdk/src/host/batch-http.test.ts index 55da2e608be..d2c1876e8d1 100644 --- a/packages/memory-host-sdk/src/host/batch-http.test.ts +++ b/packages/memory-host-sdk/src/host/batch-http.test.ts @@ -13,7 +13,6 @@ type RetryOptions = { function requireRetryOptions(call: unknown[] | undefined): RetryOptions { const options = call?.[1] as RetryOptions | undefined; - expect(options).toBeDefined(); if (!options) { throw new Error("expected retry options"); } diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 5ae7c473b9d..e50bd950cae 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -44,7 +44,6 @@ function setupTempDirLifecycle(prefix: string): () => string { } function expectFileEntry(entry: Awaited>): FileEntry { - expect(entry).toBeTruthy(); if (!entry) { throw new Error("Expected file entry to be built"); } @@ -54,13 +53,21 @@ function expectFileEntry(entry: Awaited>): Fil function expectMultimodalIndexingChunk( built: Awaited>, ): MultimodalIndexingChunk { - expect(built).toBeTruthy(); if (!built) { throw new Error("Expected multimodal indexing chunk to be built"); } return built; } +function expectEmbeddingInput( + chunk: MultimodalIndexingChunk["chunk"], +): NonNullable { + if (!chunk.embeddingInput) { + throw new Error("Expected multimodal chunk embedding input"); + } + return chunk.embeddingInput; +} + const multimodal: MemoryMultimodalSettings = { enabled: true, modalities: ["image", "audio"], @@ -133,11 +140,7 @@ describe("memory host SDK package internals", () => { const entry = expectFileEntry(await buildFileEntry(imagePath, tmpDir, multimodal)); const built = expectMultimodalIndexingChunk(await buildMultimodalChunkForIndexing(entry)); - expect(built.chunk.embeddingInput).toBeDefined(); - if (!built.chunk.embeddingInput) { - throw new Error("Expected multimodal chunk embedding input"); - } - expect(built.chunk.embeddingInput.parts).toEqual([ + expect(expectEmbeddingInput(built.chunk).parts).toEqual([ { type: "text", text: "Image file: diagram.png" }, expect.objectContaining({ type: "inline-data", mimeType: "image/png" }), ]); diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index 22e975d7fdb..3e55d89f167 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -38,7 +38,6 @@ afterEach(() => { }); function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry { - expect(entry).toBeTruthy(); if (!entry) { throw new Error("expected session entry"); } @@ -201,11 +200,11 @@ describe("buildSessionEntry", () => { ]; fsSync.writeFileSync(archivePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(archivePath); + const entry = requireSessionEntry(await buildSessionEntry(archivePath)); - expect(entry?.content).toBe(""); - expect(entry?.lineMap).toEqual([]); - expect(entry?.generatedByCronRun).toBe(true); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); + expect(entry.generatedByCronRun).toBe(true); }); it("keeps cron-run reset archives opaque when session metadata preserves the cron key", async () => { @@ -222,11 +221,11 @@ describe("buildSessionEntry", () => { ]; fsSync.writeFileSync(archivePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(archivePath); + const entry = requireSessionEntry(await buildSessionEntry(archivePath)); - expect(entry?.content).toBe(""); - expect(entry?.lineMap).toEqual([]); - expect(entry?.generatedByCronRun).toBe(true); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); + expect(entry.generatedByCronRun).toBe(true); }); it("skips blank lines and invalid JSON without breaking lineMap", async () => { @@ -240,8 +239,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "gaps.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry?.lineMap).toEqual([3, 5]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.lineMap).toEqual([3, 5]); }); it("strips inbound metadata when a user envelope is split across text blocks", async () => { @@ -269,8 +268,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "enveloped-session-array.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry?.content).toBe("User: Actual user text"); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe("User: Actual user text"); }); it("skips inter-session user messages", async () => { @@ -295,8 +294,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "inter-session-session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry?.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); - expect(entry?.lineMap).toEqual([2, 3]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); + expect(entry.lineMap).toEqual([2, 3]); }); }); From 0d3ca249345836ad7e1f68c4b050f4827b83d5e2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:31:03 +0100 Subject: [PATCH 632/806] test: tighten extension lane assertions --- test/vitest-scoped-config.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 0eb87312553..7ef5ba76ce6 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -524,20 +524,23 @@ describe("scoped vitest configs", () => { }); it("normalizes zalo extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionZaloConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionZaloConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultExtensionZaloConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual( expect.arrayContaining(["zalo/**/*.test.ts", "zalouser/**/*.test.ts"]), ); }); it("normalizes voice-call extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionVoiceCallConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionVoiceCallConfig.test?.include).toEqual(["voice-call/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionVoiceCallConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["voice-call/**/*.test.ts"]); }); it("normalizes memory extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMemoryConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMemoryConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultExtensionMemoryConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual( expect.arrayContaining(["memory-core/**/*.test.ts", "memory-lancedb/**/*.test.ts"]), ); }); From 54f952e9843978a38f1339ab061f478ef1f25593 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:31:39 +0100 Subject: [PATCH 633/806] test: tighten hooks config assertions --- test/vitest-scoped-config.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 7ef5ba76ce6..55463d55ccf 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -625,13 +625,15 @@ describe("scoped vitest configs", () => { }); it("normalizes secrets include patterns relative to the scoped dir", () => { - expect(defaultSecretsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "secrets")); - expect(defaultSecretsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultSecretsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "secrets")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes hooks include patterns relative to the scoped dir", () => { - expect(defaultHooksConfig.test?.dir).toBe(path.join(process.cwd(), "src", "hooks")); - expect(defaultHooksConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultHooksConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "hooks")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("keeps memory plugin tests out of the shared extensions lane", () => { From 88d32bca40d43a1d0f78a71b4f6b340fa777c550 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:31:35 +0100 Subject: [PATCH 634/806] test: tighten tooling helper assertions --- test/official-channel-catalog.test.ts | 2 -- test/plugin-npm-runtime-build.test.ts | 1 - test/scripts/preinstall-package-manager-warning.test.ts | 1 - test/scripts/test-extension.test.ts | 3 +-- test/vitest-boundary-config.test.ts | 1 - test/vitest-projects-config.test.ts | 2 -- test/vitest-scoped-config.test.ts | 1 - test/vitest-ui-package-config.test.ts | 1 - test/vitest-unit-fast-config.test.ts | 1 - 9 files changed, 1 insertion(+), 12 deletions(-) diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index 9857bddb165..659667dddc6 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -29,7 +29,6 @@ function writeJson(filePath: string, value: unknown): void { function requireInstall(entry: OfficialChannelCatalogEntry | undefined): OfficialChannelInstall { const install = entry?.openclaw?.install; - expect(install).toBeDefined(); if (!install) { throw new Error("expected official channel install config"); } @@ -37,7 +36,6 @@ function requireInstall(entry: OfficialChannelCatalogEntry | undefined): Officia } function requireNpmInstallSource(source: ReturnType) { - expect(source.npm).toBeDefined(); if (!source.npm) { throw new Error("expected npm install source"); } diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 5b6e6be5612..e154b0ee2ea 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -16,7 +16,6 @@ function expectDistRelativePaths(paths: string[]) { function expectPluginNpmRuntimeBuildPlan( plan: ReturnType, ): PluginNpmRuntimeBuildPlan { - expect(plan).toBeTruthy(); if (!plan) { throw new Error("expected plugin npm runtime build plan"); } diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index 4263e61375d..f7bab882dfb 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -7,7 +7,6 @@ import { function requireFirstWarning(warn: ReturnType): unknown { const message = warn.mock.calls[0]?.[0]; - expect(message).toBeDefined(); if (message === undefined) { throw new Error("expected package manager warning"); } diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 50274954a80..ba2d4152448 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -36,8 +36,7 @@ function runScript(args: string[], cwd = process.cwd()) { function requireFirstMockArg(mock: { mock: { calls: Array<[T, ...unknown[]]> } }): T { const arg = mock.mock.calls[0]?.[0]; - expect(arg).toBeDefined(); - if (!arg) { + if (arg === undefined) { throw new Error("expected first mock call argument"); } return arg; diff --git a/test/vitest-boundary-config.test.ts b/test/vitest-boundary-config.test.ts index ec36ceeb67b..8b5ed8c3d8e 100644 --- a/test/vitest-boundary-config.test.ts +++ b/test/vitest-boundary-config.test.ts @@ -7,7 +7,6 @@ import { import { boundaryTestFiles } from "./vitest/vitest.unit-paths.mjs"; function requireTestConfig(config: ReturnType) { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected boundary vitest test config"); } diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index f78fbe2af7c..bac4905a902 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -33,7 +33,6 @@ import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts"; const patternFiles = createPatternFileHelper("openclaw-vitest-projects-config-"); function requireTestConfig(config: T): NonNullable { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected vitest test config"); } @@ -44,7 +43,6 @@ function requireWebOptimizer(testConfig: { deps?: { optimizer?: { web?: { enabled?: boolean } } }; }) { const webOptimizer = testConfig.deps?.optimizer?.web; - expect(webOptimizer).toBeDefined(); if (!webOptimizer) { throw new Error("expected vitest web optimizer config"); } diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 55463d55ccf..b74c0ffc72d 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -86,7 +86,6 @@ function matchingExcludePatterns(patterns: string[], file: string): string[] { } function requireTestConfig(config: T): NonNullable { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected scoped vitest test config"); } diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index 0922923709f..e4f6934bae3 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -3,7 +3,6 @@ import uiConfig from "../ui/vitest.config.ts"; import uiNodeConfig from "../ui/vitest.node.config.ts"; function requireTestConfig(config: T): NonNullable { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected ui package vitest test config"); } diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 134b56ca45a..b6c64b8532e 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -14,7 +14,6 @@ import { import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; function requireTestConfig(config: T): NonNullable { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected unit-fast vitest test config"); } From d692f89f0bea478d5e39f8481fb1203571746df8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:32:07 +0100 Subject: [PATCH 635/806] test: tighten extension group assertions --- test/vitest-scoped-config.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index b74c0ffc72d..980e93b3575 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -670,10 +670,14 @@ describe("scoped vitest configs", () => { it("keeps broad dedicated extension groups out of the shared extensions lane", () => { const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; - expect(defaultExtensionBrowserConfig.test?.include).toContain("browser/**/*.test.ts"); - expect(defaultExtensionMediaConfig.test?.include).toContain("vydra/**/*.test.ts"); - expect(defaultExtensionMiscConfig.test?.include).toContain("firecrawl/**/*.test.ts"); - expect(defaultExtensionQaConfig.test?.include).toContain("qa-lab/**/*.test.ts"); + const browserTestConfig = requireTestConfig(defaultExtensionBrowserConfig); + const mediaTestConfig = requireTestConfig(defaultExtensionMediaConfig); + const miscTestConfig = requireTestConfig(defaultExtensionMiscConfig); + const qaTestConfig = requireTestConfig(defaultExtensionQaConfig); + expect(browserTestConfig.include).toContain("browser/**/*.test.ts"); + expect(mediaTestConfig.include).toContain("vydra/**/*.test.ts"); + expect(miscTestConfig.include).toContain("firecrawl/**/*.test.ts"); + expect(qaTestConfig.include).toContain("qa-lab/**/*.test.ts"); for (const file of [ "browser/src/browser/pw.test.ts", "vydra/src/index.test.ts", From aa276e0902e92c7d6959b94a490a4b41a83f1459 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:32:43 +0100 Subject: [PATCH 636/806] test: tighten core lane assertions --- test/vitest-scoped-config.test.ts | 33 +++++++++++++++++-------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 980e93b3575..43fa71167e4 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -689,33 +689,36 @@ describe("scoped vitest configs", () => { }); it("normalizes gateway include patterns relative to the scoped dir", () => { - expect(defaultGatewayConfig.test?.dir).toBe(path.join(process.cwd(), "src", "gateway")); - expect(defaultGatewayConfig.test?.include).toEqual(["**/*.test.ts"]); - expect(defaultGatewayConfig.test?.exclude).toContain("gateway.test.ts"); - expect(defaultGatewayConfig.test?.exclude).toContain( - "server.startup-matrix-migration.integration.test.ts", - ); - expect(defaultGatewayConfig.test?.exclude).toContain("sessions-history-http.test.ts"); + const testConfig = requireTestConfig(defaultGatewayConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "gateway")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toContain("gateway.test.ts"); + expect(testConfig.exclude).toContain("server.startup-matrix-migration.integration.test.ts"); + expect(testConfig.exclude).toContain("sessions-history-http.test.ts"); }); it("normalizes infra include patterns relative to the scoped dir", () => { - expect(defaultInfraConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultInfraConfig.test?.include).toEqual(["infra/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultInfraConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["infra/**/*.test.ts"]); }); it("normalizes runtime config include patterns relative to the scoped dir", () => { - expect(defaultRuntimeConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultRuntimeConfig.test?.include).toEqual(["config/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultRuntimeConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["config/**/*.test.ts"]); }); it("normalizes cron include patterns relative to the scoped dir", () => { - expect(defaultCronConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultCronConfig.test?.include).toEqual(["cron/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCronConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["cron/**/*.test.ts"]); }); it("normalizes daemon include patterns relative to the scoped dir", () => { - expect(defaultDaemonConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultDaemonConfig.test?.include).toEqual(["daemon/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultDaemonConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["daemon/**/*.test.ts"]); }); it("normalizes media include patterns relative to the scoped dir", () => { From 56c82c8024458b32c326e504fb311967e9db637d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:33:14 +0100 Subject: [PATCH 637/806] test: tighten shared lane assertions --- test/vitest-scoped-config.test.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 43fa71167e4..7598d6764e3 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -722,31 +722,34 @@ describe("scoped vitest configs", () => { }); it("normalizes media include patterns relative to the scoped dir", () => { - expect(defaultMediaConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultMediaConfig.test?.include).toEqual(["media/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultMediaConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["media/**/*.test.ts"]); }); it("normalizes logging include patterns relative to the scoped dir", () => { - expect(defaultLoggingConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultLoggingConfig.test?.include).toEqual(["logging/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultLoggingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["logging/**/*.test.ts"]); }); it("normalizes plugin-sdk include patterns relative to the scoped dir", () => { - expect(defaultPluginSdkConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultPluginSdkConfig.test?.include).toEqual(["plugin-sdk/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultPluginSdkConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["plugin-sdk/**/*.test.ts"]); }); it("normalizes shared-core include patterns relative to the scoped dir", () => { - expect(defaultSharedCoreConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultSharedCoreConfig.test?.include).toEqual(["shared/**/*.test.ts"]); - expect(normalizeConfigPaths(defaultSharedCoreConfig.test?.setupFiles)).toEqual([ - "test/setup.ts", - ]); + const testConfig = requireTestConfig(defaultSharedCoreConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["shared/**/*.test.ts"]); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); it("normalizes process include patterns relative to the scoped dir", () => { - expect(defaultProcessConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultProcessConfig.test?.include).toEqual(["process/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultProcessConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["process/**/*.test.ts"]); }); it("normalizes tasks include patterns relative to the scoped dir", () => { From 8bd1febba12d548b12633f02c8c643483f80631e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:33:43 +0100 Subject: [PATCH 638/806] test: tighten tooling lane assertions --- test/vitest-scoped-config.test.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 7598d6764e3..408060e5656 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -753,34 +753,35 @@ describe("scoped vitest configs", () => { }); it("normalizes tasks include patterns relative to the scoped dir", () => { - expect(defaultTasksConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultTasksConfig.test?.include).toEqual(["tasks/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultTasksConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["tasks/**/*.test.ts"]); }); it("normalizes wizard include patterns relative to the scoped dir", () => { - expect(defaultWizardConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultWizardConfig.test?.include).toEqual(["wizard/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultWizardConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["wizard/**/*.test.ts"]); }); it("normalizes tui include patterns relative to the scoped dir", () => { - expect(defaultTuiConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultTuiConfig.test?.include).toEqual(["tui/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultTuiConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["tui/**/*.test.ts"]); }); it("normalizes media-understanding include patterns relative to the scoped dir", () => { - expect(defaultMediaUnderstandingConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultMediaUnderstandingConfig.test?.include).toEqual([ - "media-understanding/**/*.test.ts", - ]); + const testConfig = requireTestConfig(defaultMediaUnderstandingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["media-understanding/**/*.test.ts"]); }); it("keeps tooling tests in their own lane", () => { - expect(defaultToolingConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultToolingConfig); + expect(testConfig.include).toEqual( expect.arrayContaining(["test/**/*.test.ts", "src/scripts/**/*.test.ts"]), ); - expect(defaultToolingConfig.test?.include).not.toContain( - "src/config/doc-baseline.integration.test.ts", - ); + expect(testConfig.include).not.toContain("src/config/doc-baseline.integration.test.ts"); }); it("normalizes acp include patterns relative to the scoped dir", () => { From 9f2fda6079e2f73f7c24692fd0c92f63e949851b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:33:46 +0100 Subject: [PATCH 639/806] test: tighten core ui helper assertions --- .../provider-registry.test.ts | 1 - src/media-understanding/shared.test.ts | 1 - test/test-env.test.ts | 46 ++++++++++++------- ui/src/ui/views/debug.test.ts | 1 - ui/src/ui/views/usage-render-details.test.ts | 9 ++-- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/media-understanding/provider-registry.test.ts b/src/media-understanding/provider-registry.test.ts index 167dc32d08f..65961832d65 100644 --- a/src/media-understanding/provider-registry.test.ts +++ b/src/media-understanding/provider-registry.test.ts @@ -23,7 +23,6 @@ function requireMediaProvider( providerId: string, ): MediaUnderstandingProvider { const provider = getMediaUnderstandingProvider(providerId, registry); - expect(provider).toBeDefined(); if (!provider) { throw new Error(`expected media-understanding provider ${providerId}`); } diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index a5bf0cc30b7..a19023b69fb 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -48,7 +48,6 @@ afterEach(() => { function getFirstGuardedFetchCall() { const call = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; - expect(call).toBeTruthy(); if (!call) { throw new Error("Expected fetchWithSsrFGuard to be called"); } diff --git a/test/test-env.test.ts b/test/test-env.test.ts index 7ce7a8a3653..704690d7b58 100644 --- a/test/test-env.test.ts +++ b/test/test-env.test.ts @@ -34,6 +34,32 @@ function createTempHome(): string { return makeTempDir(tempDirs, "openclaw-test-env-real-home-"); } +function requireRecord( + value: Record | undefined, + label: string, +): Record { + if (!value) { + throw new Error(`expected copied ${label} config`); + } + return value; +} + +function requireTelegramStreaming( + value: + | { + mode?: string; + chunkMode?: string; + block?: { enabled?: boolean }; + preview?: { chunk?: { minChars?: number } }; + } + | undefined, +) { + if (!value) { + throw new Error("expected copied telegram streaming config"); + } + return value; +} + afterEach(() => { while (cleanupFns.length > 0) { cleanupFns.pop()?.(); @@ -141,29 +167,17 @@ describe("installTestEnv", () => { }; }; const providers = copiedConfig.models?.providers; - expect(providers).toBeDefined(); - if (!providers) { - throw new Error("expected copied model providers config"); - } + requireRecord(providers, "model providers"); expect(providers.custom).toEqual({ baseUrl: "https://example.test/v1" }); - const agentDefaults = copiedConfig.agents?.defaults; - const agentConfig = copiedConfig.agents?.list?.[0]; - expect(agentDefaults).toBeDefined(); - expect(agentConfig).toBeDefined(); - if (!agentDefaults || !agentConfig) { - throw new Error("expected copied agent config"); - } + const agentDefaults = requireRecord(copiedConfig.agents?.defaults, "agent defaults"); + const agentConfig = requireRecord(copiedConfig.agents?.list?.[0], "agent"); expect(agentDefaults.workspace).toBeUndefined(); expect(agentDefaults.agentDir).toBeUndefined(); expect(agentConfig.workspace).toBeUndefined(); expect(agentConfig.agentDir).toBeUndefined(); - const telegramStreaming = copiedConfig.channels?.telegram?.streaming; - expect(telegramStreaming).toBeDefined(); - if (!telegramStreaming) { - throw new Error("expected copied telegram streaming config"); - } + const telegramStreaming = requireTelegramStreaming(copiedConfig.channels?.telegram?.streaming); expect(telegramStreaming).toEqual({ mode: "block", chunkMode: "newline", diff --git a/ui/src/ui/views/debug.test.ts b/ui/src/ui/views/debug.test.ts index af731c661cd..b7766c773ba 100644 --- a/ui/src/ui/views/debug.test.ts +++ b/ui/src/ui/views/debug.test.ts @@ -58,7 +58,6 @@ describe("renderDebug", () => { ); const command = container.querySelector(".callout .mono"); - expect(command).toBeTruthy(); if (!command) { throw new Error("expected debug security audit command"); } diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/ui/views/usage-render-details.test.ts index c24fec16071..a37a4720047 100644 --- a/ui/src/ui/views/usage-render-details.test.ts +++ b/ui/src/ui/views/usage-render-details.test.ts @@ -69,14 +69,11 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 3000, totalTokens: 300, cost: 0.3 }), ]; const result = computeFilteredUsage(baseUsage, points, 1000, 2000); - expect(result).toMatchObject({ + const filtered = expectFilteredUsage(result); + expect(filtered).toMatchObject({ totalTokens: 300, // 100 + 200 }); - expect(result).toBeDefined(); - if (!result) { - throw new Error("expected filtered usage aggregate"); - } - expect(result.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 + expect(filtered.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 }); it("handles reversed range (end < start)", () => { From bff408e33221946b594ed224211440e64eeb3fce Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:34:14 +0100 Subject: [PATCH 640/806] test: tighten remaining lane assertions --- test/vitest-scoped-config.test.ts | 46 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 408060e5656..d44b74a3c0c 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -785,45 +785,53 @@ describe("scoped vitest configs", () => { }); it("normalizes acp include patterns relative to the scoped dir", () => { - expect(defaultAcpConfig.test?.dir).toBe(path.join(process.cwd(), "src", "acp")); - expect(defaultAcpConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAcpConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "acp")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes cli include patterns relative to the scoped dir", () => { - expect(defaultCliConfig.test?.dir).toBe(path.join(process.cwd(), "src", "cli")); - expect(defaultCliConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCliConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "cli")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes commands include patterns relative to the scoped dir", () => { - expect(defaultCommandsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "commands")); - expect(defaultCommandsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCommandsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "commands")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes auto-reply include patterns relative to the scoped dir", () => { - expect(defaultAutoReplyConfig.test?.dir).toBe(path.join(process.cwd(), "src", "auto-reply")); - expect(defaultAutoReplyConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAutoReplyConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "auto-reply")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes agents include patterns relative to the scoped dir", () => { - expect(defaultAgentsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "agents")); - expect(defaultAgentsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAgentsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "agents")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes plugins include patterns relative to the scoped dir", () => { - expect(defaultPluginsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "plugins")); - expect(defaultPluginsConfig.test?.include).toEqual(["**/*.test.ts"]); - expect(defaultPluginsConfig.test?.exclude).toContain("contracts/**"); + const testConfig = requireTestConfig(defaultPluginsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "plugins")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toContain("contracts/**"); }); it("normalizes ui include patterns relative to the scoped dir", () => { - expect(defaultUiConfig.test?.dir).toBe(process.cwd()); - expect(defaultUiConfig.test?.include).toEqual(["ui/src/**/*.test.ts"]); - expect(defaultUiConfig.test?.exclude).toContain("ui/src/ui/app-chat.test.ts"); + const testConfig = requireTestConfig(defaultUiConfig); + expect(testConfig.dir).toBe(process.cwd()); + expect(testConfig.include).toEqual(["ui/src/**/*.test.ts"]); + expect(testConfig.exclude).toContain("ui/src/ui/app-chat.test.ts"); }); it("normalizes utils include patterns relative to the scoped dir", () => { - expect(defaultUtilsConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultUtilsConfig.test?.include).toEqual(["utils/**/*.test.ts"]); - expect(normalizeConfigPaths(defaultUtilsConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); + const testConfig = requireTestConfig(defaultUtilsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["utils/**/*.test.ts"]); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); }); From 66112e66699c2d1e9722c7df173459f9504e873d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:34:47 +0100 Subject: [PATCH 641/806] test: tighten telegram lane assertions --- test/vitest-scoped-config.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index d44b74a3c0c..ab70c1bd65e 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -545,7 +545,10 @@ describe("scoped vitest configs", () => { }); it("keeps telegram plugin tests out of the shared extensions lane", () => { - const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; + const extensionsTestConfig = requireTestConfig(defaultExtensionsConfig); + const channelsTestConfig = requireTestConfig(defaultChannelsConfig); + const telegramTestConfig = requireTestConfig(defaultExtensionTelegramConfig); + const extensionExcludes = extensionsTestConfig.exclude ?? []; expect( extensionExcludes.some((pattern) => path.matchesGlob("telegram/src/fetch.test.ts", pattern)), ).toBe(true); @@ -554,16 +557,16 @@ describe("scoped vitest configs", () => { path.matchesGlob("telegram/src/bot/delivery.resolve-media-retry.test.ts", pattern), ), ).toBe(true); - expect(defaultChannelsConfig.test?.include).not.toContain("extensions/telegram/**/*.test.ts"); - expect(defaultChannelsConfig.test?.exclude).not.toContain( + expect(channelsTestConfig.include).not.toContain("extensions/telegram/**/*.test.ts"); + expect(channelsTestConfig.exclude).not.toContain( bundledPluginFile("telegram", "src/fetch.test.ts"), ); - expect(normalizeConfigPaths(defaultExtensionsConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(extensionsTestConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", ]); - expect(normalizeConfigPaths(defaultExtensionTelegramConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(telegramTestConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", From 635863ab385993f998751bc0e3912a4fb051f854 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:35:24 +0100 Subject: [PATCH 642/806] test: tighten plugin policy write assertions --- src/cli/plugins-cli.policy.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index a2b86bc3e38..e8edc1a90a2 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -41,6 +41,15 @@ describe("plugins cli policy mutations", () => { }); } + function requireFirstWrittenConfig(): OpenClawConfig { + const [config] = writeConfigFile.mock.calls[0] ?? []; + expect(config).toBeDefined(); + if (!config) { + throw new Error("expected writeConfigFile to receive a config"); + } + return config; + } + it("refreshes the persisted plugin registry after enabling a plugin", async () => { const sourceConfig = {} as OpenClawConfig; const enabledConfig = { @@ -103,7 +112,7 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "alpha"]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; + const nextConfig = requireFirstWrittenConfig(); expect(nextConfig.plugins?.entries?.alpha?.enabled).toBe(false); expect(refreshPluginRegistry).toHaveBeenCalledWith({ config: nextConfig, @@ -154,7 +163,7 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", alias]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; + const nextConfig = requireFirstWrittenConfig(); expect(nextConfig.plugins?.entries?.[pluginId]?.enabled).toBe(false); expect(nextConfig.plugins?.entries?.[alias]).toBeUndefined(); }, @@ -184,7 +193,7 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "twitch"]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; + const nextConfig = requireFirstWrittenConfig(); expect(nextConfig.plugins?.entries?.twitch?.enabled).toBe(false); expect(nextConfig.channels?.twitch).toBeUndefined(); }); From 40bf847394d4ac09fae2c89b271cd5f6ad016f02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:35:37 +0100 Subject: [PATCH 643/806] test: tighten ui media helper assertions --- src/media-understanding/image.test.ts | 2 -- src/media-understanding/runner.auto-audio.test.ts | 2 -- src/media-understanding/runner.vision-skip.test.ts | 3 --- ui/src/ui/app-channels.test.ts | 1 - ui/src/ui/app-render.assistant-avatar.test.ts | 4 ++-- ui/src/ui/app-tool-stream.node.test.ts | 1 - ui/src/ui/usage-helpers.node.test.ts | 1 - 7 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 6ad71796ac6..16ce4471b7d 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -388,7 +388,6 @@ describe("describeImageWithModel", () => { } const [, context] = firstCall; const userMessage = context.messages[0]; - expect(userMessage).toBeDefined(); if (!userMessage) { throw new Error("expected image completion user message"); } @@ -438,7 +437,6 @@ describe("describeImageWithModel", () => { const [, context] = firstCall; expect(context.systemPrompt).toBeUndefined(); const userMessage = context.messages[0]; - expect(userMessage).toBeDefined(); if (!userMessage) { throw new Error("expected OpenRouter image completion user message"); } diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 55949bbac50..961b851acb0 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -85,7 +85,6 @@ type CapabilityResult = Awaited>; function requireCapabilityOutput(result: CapabilityResult, index: number) { const output = result.outputs[index]; - expect(output).toBeDefined(); if (!output) { throw new Error(`expected media-understanding output at index ${index}`); } @@ -144,7 +143,6 @@ describe("runCapability auto audio entries", () => { }); }); - expect(runResult).toBeDefined(); if (!runResult) { throw new Error("expected Codex audio result"); } diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 24b27af5c18..acb2d7a0981 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -101,7 +101,6 @@ type CapabilityResult = Awaited>; function requireDecisionAttachment(result: CapabilityResult, index: number) { const attachment = result.decision.attachments[index]; - expect(attachment).toBeDefined(); if (!attachment) { throw new Error(`expected media-understanding decision attachment ${index}`); } @@ -110,7 +109,6 @@ function requireDecisionAttachment(result: CapabilityResult, index: number) { function requireCapabilityOutput(result: CapabilityResult, index: number) { const output = result.outputs[index]; - expect(output).toBeDefined(); if (!output) { throw new Error(`expected media-understanding output ${index}`); } @@ -161,7 +159,6 @@ describe("runCapability image skip", () => { const attachment = requireDecisionAttachment(result, 0); expect(attachment.attachmentIndex).toBe(0); const attempt = attachment.attempts[0]; - expect(attempt).toBeDefined(); if (!attempt) { throw new Error("expected media-understanding skipped attempt"); } diff --git a/ui/src/ui/app-channels.test.ts b/ui/src/ui/app-channels.test.ts index 0b4bad647cc..863b6c83b7d 100644 --- a/ui/src/ui/app-channels.test.ts +++ b/ui/src/ui/app-channels.test.ts @@ -34,7 +34,6 @@ function createChannelsSnapshot(name = "saved"): ChannelsStatusSnapshot { function requireConfigSnapshot( host: ChannelsActionHostForTest, ): NonNullable { - expect(host.configSnapshot).toBeDefined(); if (!host.configSnapshot) { throw new Error("expected config snapshot"); } diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index 341da86a457..463e333f9ba 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -255,7 +255,7 @@ describe("renderApp assistant avatar routing", () => { }); it("renders stale cron state containing a job without a payload", () => { - expect( + expect(() => renderApp( createState({ cronJobs: [ @@ -273,6 +273,6 @@ describe("renderApp assistant avatar routing", () => { ], }), ), - ).toBeDefined(); + ).not.toThrow(); }); }); diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index c99846a86b3..a6e9fe30f7b 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -66,7 +66,6 @@ function expectCompactionCompleteAndAutoClears(host: MutableHost) { } function requireFallbackStatus(host: MutableHost): FallbackStatus { - expect(host.fallbackStatus).toBeTruthy(); if (!host.fallbackStatus) { throw new Error("expected fallback status"); } diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/ui/usage-helpers.node.test.ts index eedaa89654f..5adbb0f0a17 100644 --- a/ui/src/ui/usage-helpers.node.test.ts +++ b/ui/src/ui/usage-helpers.node.test.ts @@ -4,7 +4,6 @@ import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./us function requireFirstTool(tools: Array<[string, number]>): [string, number] { const tool = tools[0]; - expect(tool).toBeDefined(); if (!tool) { throw new Error("expected parsed tool summary entry"); } From 469be1b5916f5b96154f8d39315edac575f65d39 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:36:02 +0100 Subject: [PATCH 644/806] test: tighten plugin policy entry assertions --- src/cli/plugins-cli.policy.test.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index e8edc1a90a2..ed48463b77d 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -50,6 +50,16 @@ describe("plugins cli policy mutations", () => { return config; } + function requirePluginEntries( + config: OpenClawConfig, + ): NonNullable["entries"]> { + expect(config.plugins?.entries).toBeDefined(); + if (!config.plugins?.entries) { + throw new Error("expected plugin entries in config"); + } + return config.plugins.entries; + } + it("refreshes the persisted plugin registry after enabling a plugin", async () => { const sourceConfig = {} as OpenClawConfig; const enabledConfig = { @@ -113,7 +123,8 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "alpha"]); const nextConfig = requireFirstWrittenConfig(); - expect(nextConfig.plugins?.entries?.alpha?.enabled).toBe(false); + const entries = requirePluginEntries(nextConfig); + expect(entries.alpha).toMatchObject({ enabled: false }); expect(refreshPluginRegistry).toHaveBeenCalledWith({ config: nextConfig, installRecords: {}, @@ -164,8 +175,9 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", alias]); const nextConfig = requireFirstWrittenConfig(); - expect(nextConfig.plugins?.entries?.[pluginId]?.enabled).toBe(false); - expect(nextConfig.plugins?.entries?.[alias]).toBeUndefined(); + const entries = requirePluginEntries(nextConfig); + expect(entries[pluginId]).toMatchObject({ enabled: false }); + expect(entries[alias]).toBeUndefined(); }, ); @@ -194,7 +206,8 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "twitch"]); const nextConfig = requireFirstWrittenConfig(); - expect(nextConfig.plugins?.entries?.twitch?.enabled).toBe(false); + const entries = requirePluginEntries(nextConfig); + expect(entries.twitch).toMatchObject({ enabled: false }); expect(nextConfig.channels?.twitch).toBeUndefined(); }); }); From 1e90eb893620c10b9688f17deb56774db4413a8b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:36:40 +0100 Subject: [PATCH 645/806] test: tighten heartbeat wake assertions --- src/infra/heartbeat-wake.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/infra/heartbeat-wake.test.ts b/src/infra/heartbeat-wake.test.ts index b07f2735dac..bd0eaaeca36 100644 --- a/src/infra/heartbeat-wake.test.ts +++ b/src/infra/heartbeat-wake.test.ts @@ -43,6 +43,11 @@ describe("heartbeat-wake", () => { return handler; } + function expectWakeCall(handler: ReturnType, index: number, request: WakeRequest) { + const [actualRequest] = handler.mock.calls[index] ?? []; + expect(actualRequest).toEqual(request); + } + async function expectRetryAfterDefaultDelay(params: { handler: ReturnType; initialReason: string; @@ -61,7 +66,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(500); expect(params.handler).toHaveBeenCalledTimes(2); - expect(params.handler.mock.calls[1]?.[0]).toEqual(wake(params.expectedRetryReason)); + expectWakeCall(params.handler, 1, wake(params.expectedRetryReason)); } beforeEach(() => { @@ -138,7 +143,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1); expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toEqual(wake("hook:wake")); + expectWakeCall(handler, 1, wake("hook:wake")); }); it("retries thrown handler errors after the default retry delay", async () => { @@ -315,7 +320,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1); expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0]?.[0]).toEqual({ + expectWakeCall(handler, 0, { source: "cron", intent: "immediate", reason: "cron:job-1", @@ -326,7 +331,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1000); expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toEqual({ + expectWakeCall(handler, 1, { source: "cron", intent: "immediate", reason: "cron:job-1", From 0122b3bd5f95c34508e3586f1de54d4484d4f447 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:37:23 +0100 Subject: [PATCH 646/806] test: tighten config controller request assertions --- ui/src/ui/controllers/config.test.ts | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index dea41b310a7..0e7bb121363 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -55,6 +55,15 @@ function createRequestWithConfigGet() { }); } +function requireRequestCall(request: ReturnType, index = 0): unknown[] { + const call = request.mock.calls[index]; + expect(call).toBeDefined(); + if (!call) { + throw new Error("expected client request call"); + } + return call; +} + describe("applyConfigSnapshot", () => { it("does not clobber form edits while dirty", () => { const state = createState(); @@ -573,8 +582,9 @@ describe("applyConfig", () => { await applyConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.apply"); - const params = request.mock.calls[0]?.[1] as { + const call = requireRequestCall(request); + expect(call[0]).toBe("config.apply"); + const params = call[1] as { raw: string; baseHash: string; sessionKey: string; @@ -615,8 +625,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; expect(params.baseHash).toBe("hash-original"); }); @@ -645,8 +656,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; const parsed = JSON.parse(params.raw) as { gateway: { port: unknown; enabled: unknown }; }; @@ -670,8 +682,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; const parsed = JSON.parse(params.raw) as { gateway: { port: unknown }; }; From 15217b2857f0456047892576746d80e0450e98e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:37:27 +0100 Subject: [PATCH 647/806] test: tighten provider media helper assertions --- extensions/byteplus/video-generation-provider.test.ts | 2 -- extensions/moonshot/media-understanding-provider.test.ts | 4 ---- extensions/openrouter/image-generation-provider.test.ts | 2 -- extensions/openrouter/video-generation-provider.test.ts | 4 ---- extensions/qwen/media-understanding-provider.test.ts | 4 ---- extensions/runway/video-generation-provider.test.ts | 1 - 6 files changed, 17 deletions(-) diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index 70827e9e43c..c8d318f90be 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -45,11 +45,9 @@ function requireBytePlusPostBody(): Record { const request = postJsonRequestMock.mock.calls[0]?.[0] as | { body?: Record } | undefined; - expect(request).toBeDefined(); if (!request) { throw new Error("expected BytePlus video request"); } - expect(request.body).toBeDefined(); if (!request.body) { throw new Error("expected BytePlus video request body"); } diff --git a/extensions/moonshot/media-understanding-provider.test.ts b/extensions/moonshot/media-understanding-provider.test.ts index 879a935be42..73e0d46af8b 100644 --- a/extensions/moonshot/media-understanding-provider.test.ts +++ b/extensions/moonshot/media-understanding-provider.test.ts @@ -28,7 +28,6 @@ describe("describeMoonshotVideo", () => { expect(result.text).toBe("video ok"); expect(result.model).toBe("kimi-k2.6"); expect(url).toBe("https://api.moonshot.ai/v1/chat/completions"); - expect(init).toBeDefined(); if (!init) { throw new Error("expected Moonshot request init"); } @@ -52,7 +51,6 @@ describe("describeMoonshotVideo", () => { }; expect(body.model).toBe("kimi-k2.6"); const content = body.messages?.[0]?.content; - expect(content).toBeDefined(); if (!content) { throw new Error("expected Moonshot user content"); } @@ -61,12 +59,10 @@ describe("describeMoonshotVideo", () => { text: "Describe the video.", }); const videoContent = content[1]; - expect(videoContent).toBeDefined(); if (!videoContent) { throw new Error("expected Moonshot video content"); } expect(videoContent.type).toBe("video_url"); - expect(videoContent.video_url).toBeDefined(); if (!videoContent.video_url) { throw new Error("expected Moonshot video URL payload"); } diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index da42646b109..2110c40ff0a 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -35,7 +35,6 @@ function requireOpenRouterPostBody(): { messages?: Array<{ content?: unknown }>; } { const request = postJsonRequestMock.mock.calls[0]?.[0]; - expect(request).toBeDefined(); if (!request) { throw new Error("expected OpenRouter image generation request"); } @@ -49,7 +48,6 @@ function requireGeneratedImage( index: number, ) { const image = result.images[index]; - expect(image).toBeDefined(); if (!image) { throw new Error(`expected OpenRouter generated image at index ${index}`); } diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index 11216b0e6b9..043a71de411 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -66,7 +66,6 @@ type OpenRouterVideoResult = Awaited { expect(result.model).toBe("qwen-vl-max"); expect(result.text).toBe("first\nsecond"); expect(url).toBe("https://example.com/v1/chat/completions"); - expect(init).toBeDefined(); if (!init) { throw new Error("expected Qwen request init"); } @@ -58,18 +57,15 @@ describe("describeQwenVideo", () => { const body = JSON.parse(bodyText); expect(body.model).toBe("qwen-vl-max"); const content = body.messages?.[0]?.content; - expect(content).toBeDefined(); if (!content) { throw new Error("expected Qwen user content"); } expect(content[0]?.text).toBe("summarize the clip"); const videoContent = content[1]; - expect(videoContent).toBeDefined(); if (!videoContent) { throw new Error("expected Qwen video content"); } expect(videoContent.type).toBe("video_url"); - expect(videoContent.video_url).toBeDefined(); if (!videoContent.video_url) { throw new Error("expected Qwen video URL payload"); } diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index ae56769e184..732354e94d7 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -73,7 +73,6 @@ describe("runway video generation provider", () => { ); expect(result.videos).toHaveLength(1); const video = result.videos[0]; - expect(video).toBeDefined(); if (!video) { throw new Error("expected Runway generated video"); } From 23c2b8e62d3c2aa52af5f81c235561ff9551e982 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:38:10 +0100 Subject: [PATCH 648/806] test: tighten unit config defaults --- test/vitest-unit-config.test.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 17c137bed76..472e5f26d77 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -11,6 +11,14 @@ import { const patternFiles = createPatternFileHelper("openclaw-vitest-unit-config-"); +function requireTestConfig(config: T): NonNullable { + expect(config.test).toBeDefined(); + if (!config.test) { + throw new Error("expected unit vitest test config"); + } + return config.test as NonNullable; +} + afterEach(() => { patternFiles.cleanup(); }); @@ -72,14 +80,16 @@ describe("loadExtraExcludePatternsFromEnv", () => { describe("unit vitest config", () => { it("defaults unit tests to the non-isolated runner", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(unitConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); }); it("keeps acp and ui tests out of the generic unit lane", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.exclude).toEqual(expect.arrayContaining(["extensions/**", "test/**"])); - expect(unitConfig.test?.include).not.toEqual( + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.exclude).toEqual(expect.arrayContaining(["extensions/**", "test/**"])); + expect(testConfig.include).not.toEqual( expect.arrayContaining([ "ui/src/ui/app-chat.test.ts", "ui/src/ui/chat/**/*.test.ts", @@ -95,8 +105,9 @@ describe("unit vitest config", () => { argv: ["node", "vitest", "run", "src/config/channel-configured.test.ts"], }, ); - expect(unitConfig.test?.include).toEqual(["src/config/channel-configured.test.ts"]); - expect(unitConfig.test?.passWithNoTests).toBe(true); + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.include).toEqual(["src/config/channel-configured.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); it("adds the OpenClaw runtime setup hooks on top of the base setup", () => { From b893e543b658ba75f8b72535bd9c7fc7e14ac921 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:38:39 +0100 Subject: [PATCH 649/806] test: tighten unit config coverage assertions --- test/vitest-unit-config.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 472e5f26d77..5418713a8e3 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -112,7 +112,8 @@ describe("unit vitest config", () => { it("adds the OpenClaw runtime setup hooks on top of the base setup", () => { const unitConfig = createUnitVitestConfig({}); - expect(normalizeConfigPaths(unitConfig.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(unitConfig); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); @@ -125,21 +126,23 @@ describe("unit vitest config", () => { extraExcludePatterns: ["src/security/**"], }, ); - expect(unitConfig.test?.exclude).toEqual( + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.exclude).toEqual( expect.arrayContaining(["src/commands/**", "src/config/**", "src/security/**"]), ); }); it("scopes default coverage to source files owned by the unit lane", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.coverage?.include).toEqual( + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.coverage?.include).toEqual( expect.arrayContaining([ "src/commitments/runtime.ts", "src/media-generation/runtime-shared.ts", "src/web-search/runtime.ts", ]), ); - expect(unitConfig.test?.coverage?.include).not.toEqual( + expect(testConfig.coverage?.include).not.toEqual( expect.arrayContaining(["src/markdown/render.ts", "src/security/audit-workspace-skills.ts"]), ); }); From a79b88280d24c9dc4740091cdb13e79469ea9a03 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:39:08 +0100 Subject: [PATCH 650/806] test: tighten unit include assertions --- test/vitest-unit-config.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 5418713a8e3..3d7439de560 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -164,8 +164,9 @@ describe("unit vitest config", () => { includePatterns: ["src/commitments/runtime.test.ts"], }, ); + const testConfig = requireTestConfig(unitConfig); - expect(unitConfig.test?.coverage?.include).toBeUndefined(); + expect(testConfig.coverage?.include).toBeUndefined(); }); it("keeps bundled unit include files out of the resolved exclude list", () => { @@ -179,13 +180,14 @@ describe("unit vitest config", () => { ], }, ); + const testConfig = requireTestConfig(unitConfig); - expect(unitConfig.test?.include).toEqual([ + expect(testConfig.include).toEqual([ "src/infra/matrix-plugin-helper.test.ts", "src/plugin-sdk/facade-runtime.test.ts", "src/plugins/loader.test.ts", ]); - expect(unitConfig.test?.exclude).not.toEqual( + expect(testConfig.exclude).not.toEqual( expect.arrayContaining([ "src/infra/**", "src/plugin-sdk/**", From 13dacceed47f30bfda184ed8287a956839b64e74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:39:00 +0100 Subject: [PATCH 651/806] test: tighten extension helper assertions --- extensions/duckduckgo/src/ddg-search-provider.test.ts | 1 - extensions/inworld/tts.test.ts | 1 - extensions/microsoft-foundry/index.test.ts | 2 -- extensions/moonshot/provider-catalog.test.ts | 1 - extensions/web-readability/web-content-extractor.test.ts | 1 - 5 files changed, 6 deletions(-) diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 597fa401bbe..6c6836fb7ed 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -47,7 +47,6 @@ describe("duckduckgo web search provider", () => { expect(provider.requiresCredential).toBe(false); expect(provider.credentialPath).toBe(""); const pluginEntry = applied.plugins?.entries?.duckduckgo; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected DuckDuckGo plugin entry"); } diff --git a/extensions/inworld/tts.test.ts b/extensions/inworld/tts.test.ts index f901d19a489..33f57b6ac99 100644 --- a/extensions/inworld/tts.test.ts +++ b/extensions/inworld/tts.test.ts @@ -266,7 +266,6 @@ describe("inworldTTS", () => { expect(request.url).toBe("https://api.inworld.ai/tts/v1/voice:stream"); expect(request.auditContext).toBe("inworld-tts"); expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] }); - expect(request.init).toBeDefined(); if (!request.init) { throw new Error("expected Inworld TTS request init"); } diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index fe4c906cef6..66ea188bbfd 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -54,7 +54,6 @@ function registerProvider() { ); expect(registerProviderMock).toHaveBeenCalledTimes(1); const firstCall = registerProviderMock.mock.calls[0]; - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected Microsoft Foundry provider registration"); } @@ -75,7 +74,6 @@ function requirePrepareRuntimeAuth( } function requireRuntimeAuthResult(result: { apiKey?: string; baseUrl?: string } | undefined) { - expect(result).toBeDefined(); if (!result) { throw new Error("expected Microsoft Foundry runtime auth result"); } diff --git a/extensions/moonshot/provider-catalog.test.ts b/extensions/moonshot/provider-catalog.test.ts index d1e69ae2c89..4644acf91ad 100644 --- a/extensions/moonshot/provider-catalog.test.ts +++ b/extensions/moonshot/provider-catalog.test.ts @@ -26,7 +26,6 @@ function requireFirstMoonshotModel(provider: MoonshotProvider): MoonshotModel { } function requireMoonshotCompat(model: MoonshotModel): NonNullable { - expect(model.compat).toBeDefined(); if (!model.compat) { throw new Error(`expected Moonshot model ${model.id} compat`); } diff --git a/extensions/web-readability/web-content-extractor.test.ts b/extensions/web-readability/web-content-extractor.test.ts index 62c0fa0693b..3eabc9fd860 100644 --- a/extensions/web-readability/web-content-extractor.test.ts +++ b/extensions/web-readability/web-content-extractor.test.ts @@ -30,7 +30,6 @@ type ReadabilityResult = Awaited< >; function requireReadabilityResult(result: ReadabilityResult): NonNullable { - expect(result).toBeDefined(); if (!result) { throw new Error("expected readability extraction result"); } From 1dfe696b712060a828c456dce09cd0ddfe275f87 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:39:59 +0100 Subject: [PATCH 652/806] test: tighten image request header assertion --- .../openai-compatible-image-provider.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 71414476daa..b708022b18d 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -61,9 +61,12 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ })); function requireFirstRequestHeaders(mock: ReturnType): Headers { - const request = mock.mock.calls[0]?.[0] as { headers?: Headers } | undefined; - const headers = request?.headers; + const [request] = (mock.mock.calls[0] ?? []) as [{ headers?: Headers }?]; expect(request).toBeDefined(); + if (!request) { + throw new Error("expected request call"); + } + const headers = request.headers; expect(headers).toBeInstanceOf(Headers); if (!headers) { throw new Error("expected request headers"); From bab07e994fdad0562be03fd4cec2a3105899a5b9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:40:33 +0100 Subject: [PATCH 653/806] test: tighten preinstall warning assertion --- test/scripts/preinstall-package-manager-warning.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index f7bab882dfb..af9a7a01f3f 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -6,7 +6,7 @@ import { } from "../../scripts/preinstall-package-manager-warning.mjs"; function requireFirstWarning(warn: ReturnType): unknown { - const message = warn.mock.calls[0]?.[0]; + const [message] = warn.mock.calls[0] ?? []; if (message === undefined) { throw new Error("expected package manager warning"); } From 700230c07cd2c68eafd701b8b7c831d483311ee8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:41:06 +0100 Subject: [PATCH 654/806] test: tighten extension script mock assertion --- test/scripts/test-extension.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index ba2d4152448..20e9b3da69e 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -35,7 +35,7 @@ function runScript(args: string[], cwd = process.cwd()) { } function requireFirstMockArg(mock: { mock: { calls: Array<[T, ...unknown[]]> } }): T { - const arg = mock.mock.calls[0]?.[0]; + const [arg] = mock.mock.calls[0] ?? []; if (arg === undefined) { throw new Error("expected first mock call argument"); } From 6a76976f739fed9a5cebed5e583aff1085a3f2ce Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:41:46 +0100 Subject: [PATCH 655/806] test: tighten gateway request assertions --- src/commands/agent-via-gateway.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index e0343f34538..c6bf6e656ac 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -87,6 +87,15 @@ function mockLocalAgentReply(text = "local") { }); } +function requireFirstCallArg(mock: { mock: { calls: unknown[][] } }, label: string): T { + const [arg] = mock.mock.calls[0] ?? []; + expect(arg).toBeDefined(); + if (arg === undefined) { + throw new Error(`expected ${label} call`); + } + return arg as T; +} + function createGatewayTimeoutError() { const err = new Error("gateway timeout after 90000ms"); err.name = "GatewayTransportError"; @@ -142,7 +151,7 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - const request = callGateway.mock.calls[0]?.[0] as { timeoutMs?: number }; + const request = requireFirstCallArg<{ timeoutMs?: number }>(callGateway, "gateway"); expect(request.timeoutMs).toBe(2_147_000_000); }); }); @@ -154,7 +163,11 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway.mock.calls[0]?.[0]?.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); + const request = requireFirstCallArg<{ params?: Record }>( + callGateway, + "gateway", + ); + expect(request.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); expect(agentCommand).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("hello"); }); @@ -203,7 +216,8 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555", model: "ollama/qwen3.5:9b" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway.mock.calls[0]?.[0]).toMatchObject({ + const request = requireFirstCallArg(callGateway, "gateway"); + expect(request).toMatchObject({ params: { model: "ollama/qwen3.5:9b", }, From a632a68c5563473e8354d30d682fb3770de82165 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:41:36 +0100 Subject: [PATCH 656/806] test: tighten core helper assertions --- src/hooks/fire-and-forget.test.ts | 1 - src/hooks/llm-slug-generator.test.ts | 1 - src/infra/ssh-config.test.ts | 1 - src/media-understanding/runner.skip-tiny-audio.test.ts | 2 -- src/media-understanding/runner.video.test.ts | 1 - src/secrets/channel-contract-api.external.test.ts | 1 - src/secrets/plan.test.ts | 1 - src/secrets/provider-env-vars.dynamic.test.ts | 1 - src/secrets/runtime-inactive-telegram-surfaces.test.ts | 1 - src/secrets/target-registry.fast-path.test.ts | 1 - src/tasks/task-registry.store.test.ts | 1 - src/web-fetch/runtime.test.ts | 1 - test/vitest-unit-config.test.ts | 1 - 13 files changed, 14 deletions(-) diff --git a/src/hooks/fire-and-forget.test.ts b/src/hooks/fire-and-forget.test.ts index 70656da2aef..66fe24836ac 100644 --- a/src/hooks/fire-and-forget.test.ts +++ b/src/hooks/fire-and-forget.test.ts @@ -3,7 +3,6 @@ import { fireAndForgetBoundedHook, fireAndForgetHook } from "./fire-and-forget.j function requireFirstLog(logger: ReturnType): string { const message = logger.mock.calls[0]?.[0]; - expect(message).toBeDefined(); if (typeof message !== "string") { throw new Error("expected string log message"); } diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 1e34ab6277a..def7068c829 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -24,7 +24,6 @@ import { generateSlugViaLLM } from "./llm-slug-generator.js"; function requireFirstRunOptions(): unknown { const options = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(options).toBeDefined(); if (!options) { throw new Error("expected embedded Pi agent run options"); } diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index 5227b0e583c..80c83af330f 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -48,7 +48,6 @@ const spawnMock = vi.mocked(spawn); function requireSpawnArgs(index: number): string[] { const args = spawnMock.mock.calls[index]?.[1] as string[] | undefined; - expect(args).toBeDefined(); if (!args) { throw new Error("expected ssh spawn args"); } diff --git a/src/media-understanding/runner.skip-tiny-audio.test.ts b/src/media-understanding/runner.skip-tiny-audio.test.ts index 5f769401182..843995eaa37 100644 --- a/src/media-understanding/runner.skip-tiny-audio.test.ts +++ b/src/media-understanding/runner.skip-tiny-audio.test.ts @@ -180,13 +180,11 @@ describe("runCapability skips tiny audio files", () => { expect(result.decision.outcome).toBe("failed"); expect(result.decision.attachments).toHaveLength(1); const attachment = result.decision.attachments[0]; - expect(attachment).toBeDefined(); if (!attachment) { throw new Error("expected failed audio decision attachment"); } expect(attachment.attempts).toHaveLength(1); const attempt = attachment.attempts[0]; - expect(attempt).toBeDefined(); if (!attempt) { throw new Error("expected failed audio decision attempt"); } diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index e6263bea0dc..b451ded0702 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -26,7 +26,6 @@ type CapabilityResult = Awaited>; function requireCapabilityOutput(result: CapabilityResult, index: number) { const output = result.outputs[index]; - expect(output).toBeDefined(); if (!output) { throw new Error(`expected media-understanding output at index ${index}`); } diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index ffe48c21bfc..6145725dedf 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -38,7 +38,6 @@ type ChannelSecretContractApi = NonNullable, ): ChannelSecretContractApi { - expect(api).toBeDefined(); if (!api) { throw new Error("expected channel secret contract API"); } diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 81a94820e27..6795d1a3b79 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -15,7 +15,6 @@ type ValidatedPlanTarget = NonNullable, ): ValidatedPlanTarget { - expect(resolved).not.toBeNull(); if (!resolved) { throw new Error("expected validated secrets plan target"); } diff --git a/src/secrets/provider-env-vars.dynamic.test.ts b/src/secrets/provider-env-vars.dynamic.test.ts index 88fcc4c4aaa..e401457fd21 100644 --- a/src/secrets/provider-env-vars.dynamic.test.ts +++ b/src/secrets/provider-env-vars.dynamic.test.ts @@ -65,7 +65,6 @@ const pluginRegistryMocks = vi.hoisted(() => { function requireLastMetadataSnapshotCall(): unknown[] { const call = pluginRegistryMocks.loadPluginMetadataSnapshot.mock.calls.at(-1); - expect(call).toBeDefined(); if (!call) { throw new Error("expected plugin metadata snapshot call"); } diff --git a/src/secrets/runtime-inactive-telegram-surfaces.test.ts b/src/secrets/runtime-inactive-telegram-surfaces.test.ts index fb5b76003d4..de428a7ddd9 100644 --- a/src/secrets/runtime-inactive-telegram-surfaces.test.ts +++ b/src/secrets/runtime-inactive-telegram-surfaces.test.ts @@ -8,7 +8,6 @@ function requireTelegramConfig( snapshot: Awaited>, ) { const config = snapshot.config.channels?.telegram; - expect(config).toBeDefined(); if (!config) { throw new Error("expected Telegram runtime config"); } diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 21d338eb75d..4bc098f1970 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -53,7 +53,6 @@ describe("secret target registry fast path", () => { it("resolves bundled channel targets by explicit channel id without manifest scans", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target).toBeDefined(); if (!target) { throw new Error("expected googlechat service account target"); } diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index f11ed93a556..228e417ae12 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -23,7 +23,6 @@ const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; function requireFirstUpsertParams(upsertTaskWithDeliveryState: ReturnType): unknown { const params = upsertTaskWithDeliveryState.mock.calls[0]?.[0]; - expect(params).toBeDefined(); if (!params) { throw new Error("expected task upsert params"); } diff --git a/src/web-fetch/runtime.test.ts b/src/web-fetch/runtime.test.ts index 5f19abc7d48..ba9ed22364e 100644 --- a/src/web-fetch/runtime.test.ts +++ b/src/web-fetch/runtime.test.ts @@ -78,7 +78,6 @@ type ResolvedWebFetchDefinition = NonNullable< function requireResolvedWebFetch( resolved: ReturnType["resolveWebFetchDefinition"]>, ): ResolvedWebFetchDefinition { - expect(resolved).toBeDefined(); if (!resolved) { throw new Error("expected resolved web fetch definition"); } diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 3d7439de560..955738be0de 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -12,7 +12,6 @@ import { const patternFiles = createPatternFileHelper("openclaw-vitest-unit-config-"); function requireTestConfig(config: T): NonNullable { - expect(config.test).toBeDefined(); if (!config.test) { throw new Error("expected unit vitest test config"); } From 004ba1012f2595481a908e00a8df0317d63fd161 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:42:22 +0100 Subject: [PATCH 657/806] test: tighten embedded fallback assertions --- src/commands/agent-via-gateway.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index c6bf6e656ac..18d7190cefd 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -256,7 +256,8 @@ describe("agentCliCommand", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ resultMetaOverrides: { transport: "embedded", fallbackFrom: "gateway", @@ -304,12 +305,12 @@ describe("agentCliCommand", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); - const fallbackOpts = agentCommand.mock.calls[0]?.[0] as { + const fallbackOpts = requireFirstCallArg<{ sessionId?: string; sessionKey?: string; runId?: string; resultMetaOverrides?: unknown; - }; + }>(agentCommand, "embedded agent"); expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); expect(fallbackOpts.sessionId).not.toBe("locked-session"); expect(fallbackOpts.sessionKey).toBe(`agent:main:explicit:${fallbackOpts.sessionId}`); @@ -389,7 +390,8 @@ describe("agentCliCommand", () => { const result = await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ resultMetaOverrides: { transport: "embedded", fallbackFrom: "gateway", From 91d8e556798d31f64ec576cf4815714fc8c67ebf Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:42:54 +0100 Subject: [PATCH 658/806] test: tighten embedded cleanup assertions --- src/commands/agent-via-gateway.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 18d7190cefd..42cc03ce49d 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -344,11 +344,11 @@ describe("agentCliCommand", () => { runtime, ); - const fallbackOpts = agentCommand.mock.calls[0]?.[0] as { + const fallbackOpts = requireFirstCallArg<{ sessionId?: string; sessionKey?: string; to?: string; - }; + }>(agentCommand, "embedded agent"); expect(fallbackOpts.to).toBe("+1555"); expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); expect(fallbackOpts.sessionKey).toBe(`agent:main:explicit:${fallbackOpts.sessionId}`); @@ -436,11 +436,12 @@ describe("agentCliCommand", () => { expect(callGateway).not.toHaveBeenCalled(); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const localOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(localOpts).toMatchObject({ cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }); - expect(agentCommand.mock.calls[0]?.[0]).not.toHaveProperty("resultMetaOverrides"); + expect(localOpts).not.toHaveProperty("resultMetaOverrides"); expect(runtime.log).toHaveBeenCalledWith("local"); }); }); @@ -453,7 +454,8 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }); From 7645da264391bb2d1af5f9d21adf42ea3defd2c6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:43:23 +0100 Subject: [PATCH 659/806] test: tighten fallback json assertion --- src/commands/agent-via-gateway.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 42cc03ce49d..3f48f7d83b1 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -402,7 +402,8 @@ describe("agentCliCommand", () => { ); expect(loggingState.forceConsoleToStderr).toBe(true); expect(jsonRuntime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(jsonRuntime.log.mock.calls[0]?.[0])); + const jsonPayload = requireFirstCallArg(jsonRuntime.log, "json runtime log"); + const payload = JSON.parse(String(jsonPayload)); expect(payload).toMatchObject({ payloads: [{ text: "local" }], meta: { From cbe805e49db1e1eb360b900e56dda658d2cf90d6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:44:24 +0100 Subject: [PATCH 660/806] test: tighten status json log assertions --- src/commands/status.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 044d50f31df..12bcce0e532 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -85,6 +85,15 @@ function getRuntimeLogs() { return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0])); } +function getRuntimeLog(index: number): string { + const call = runtimeLogMock.mock.calls[index]; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`expected runtime log call ${index}`); + } + return String(call[0]); +} + function getJoinedRuntimeLogs() { return getRuntimeLogs().join("\n"); } @@ -967,7 +976,7 @@ describe("statusCommand", () => { createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); await statusCommand({ json: true }, runtime as never); - const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); + const payload = JSON.parse(getRuntimeLog(0)); expect(payload.linkChannel).toBeUndefined(); expect(payload.memory).toBeNull(); expect(payload.memoryPlugin.enabled).toBe(true); @@ -1001,7 +1010,7 @@ describe("statusCommand", () => { runtimeLogMock.mockClear(); await statusCommand({ json: true, all: true }, runtime as never); - const allPayload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); + const allPayload = JSON.parse(getRuntimeLog(0)); expect(allPayload.securityAudit.summary.critical).toBe(1); expect(allPayload.securityAudit.summary.warn).toBe(1); expect(mocks.runSecurityAudit).toHaveBeenCalledWith( From 150ded8f270cf551853013611589925f28baf191 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:44:41 +0100 Subject: [PATCH 661/806] test: tighten core capture assertions --- src/commands/agent-via-gateway.test.ts | 14 ++++++-------- src/daemon/systemd.test.ts | 1 - .../openai-compatible-image-provider.test.ts | 1 - src/infra/net/runtime-fetch.test.ts | 1 - src/node-host/invoke-system-run.test.ts | 1 - .../runtime-config-collectors-plugins.test.ts | 2 -- ...ntime-external-channel-origin-discovery.test.ts | 1 - src/secrets/runtime-matrix-shadowing.test.ts | 1 - src/secrets/runtime-zalo-token-activity.test.ts | 1 - src/secrets/runtime.fast-path.test.ts | 1 - ui/src/ui/controllers/config.test.ts | 1 - 11 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 3f48f7d83b1..26ff3800bb4 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -87,13 +87,12 @@ function mockLocalAgentReply(text = "local") { }); } -function requireFirstCallArg(mock: { mock: { calls: unknown[][] } }, label: string): T { +function requireFirstCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown { const [arg] = mock.mock.calls[0] ?? []; - expect(arg).toBeDefined(); if (arg === undefined) { throw new Error(`expected ${label} call`); } - return arg as T; + return arg; } function createGatewayTimeoutError() { @@ -151,7 +150,7 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - const request = requireFirstCallArg<{ timeoutMs?: number }>(callGateway, "gateway"); + const request = requireFirstCallArg(callGateway, "gateway") as { timeoutMs?: number }; expect(request.timeoutMs).toBe(2_147_000_000); }); }); @@ -163,10 +162,9 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - const request = requireFirstCallArg<{ params?: Record }>( - callGateway, - "gateway", - ); + const request = requireFirstCallArg(callGateway, "gateway") as { + params?: Record; + }; expect(request.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); expect(agentCommand).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("hello"); diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 1934025b41b..5321d71264c 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -63,7 +63,6 @@ const createWritableStreamMock = () => { function requireFirstWrite(write: ReturnType): string { const value = write.mock.calls[0]?.[0]; - expect(value).toBeDefined(); if (value === undefined) { throw new Error("expected systemd status write"); } diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index b708022b18d..91360253d8e 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -62,7 +62,6 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ function requireFirstRequestHeaders(mock: ReturnType): Headers { const [request] = (mock.mock.calls[0] ?? []) as [{ headers?: Headers }?]; - expect(request).toBeDefined(); if (!request) { throw new Error("expected request call"); } diff --git a/src/infra/net/runtime-fetch.test.ts b/src/infra/net/runtime-fetch.test.ts index 314bd0502ab..8790c5e2ccc 100644 --- a/src/infra/net/runtime-fetch.test.ts +++ b/src/infra/net/runtime-fetch.test.ts @@ -42,7 +42,6 @@ class MockProxyAgent { function requireFetchInit(mock: ReturnType): RequestInit { const init = mock.mock.calls[0]?.[1] as RequestInit | undefined; - expect(init).toBeDefined(); if (!init) { throw new Error("expected runtime fetch init"); } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 08c2f43beeb..5a98382c1c3 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -133,7 +133,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { function requireFirstRunCommandArgs(runCommand: MockedRunCommand): string[] { const args = vi.mocked(runCommand).mock.calls[0]?.[0] as string[] | undefined; - expect(args).toBeDefined(); if (!args) { throw new Error("expected runCommand args"); } diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index a6c582e3e6b..5c23d6fcccb 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -44,7 +44,6 @@ type RuntimeConfigAssignment = ResolverContext["assignments"][number]; function requireAssignment(context: ResolverContext, index: number): RuntimeConfigAssignment { const assignment = context.assignments[index]; - expect(assignment).toBeDefined(); if (!assignment) { throw new Error(`expected runtime config assignment ${index}`); } @@ -195,7 +194,6 @@ describe("collectPluginConfigAssignments", () => { Record >; const env = mcpServers?.mcp1?.env as Record; - expect(env).toBeDefined(); if (!env) { throw new Error("expected acpx mcp env config"); } diff --git a/src/secrets/runtime-external-channel-origin-discovery.test.ts b/src/secrets/runtime-external-channel-origin-discovery.test.ts index 27cf07f3a8f..82e3f1c8610 100644 --- a/src/secrets/runtime-external-channel-origin-discovery.test.ts +++ b/src/secrets/runtime-external-channel-origin-discovery.test.ts @@ -22,7 +22,6 @@ const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks() function requireDiscordConfig(snapshot: Awaited>) { const config = snapshot.config.channels?.discord; - expect(config).toBeDefined(); if (!config) { throw new Error("expected Discord runtime config"); } diff --git a/src/secrets/runtime-matrix-shadowing.test.ts b/src/secrets/runtime-matrix-shadowing.test.ts index 6dc68380b2e..d34728dc0ef 100644 --- a/src/secrets/runtime-matrix-shadowing.test.ts +++ b/src/secrets/runtime-matrix-shadowing.test.ts @@ -10,7 +10,6 @@ const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks() function requireMatrixConfig(snapshot: Awaited>) { const config = snapshot.config.channels?.matrix; - expect(config).toBeDefined(); if (!config) { throw new Error("expected Matrix runtime config"); } diff --git a/src/secrets/runtime-zalo-token-activity.test.ts b/src/secrets/runtime-zalo-token-activity.test.ts index be53d7f96ff..4f35033eba0 100644 --- a/src/secrets/runtime-zalo-token-activity.test.ts +++ b/src/secrets/runtime-zalo-token-activity.test.ts @@ -10,7 +10,6 @@ const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks() function requireZaloConfig(snapshot: Awaited>) { const config = snapshot.config.channels?.zalo; - expect(config).toBeDefined(); if (!config) { throw new Error("expected Zalo runtime config"); } diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index 577f5b16e7d..253307cb95f 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -42,7 +42,6 @@ function requireGatewayAuth( snapshot: Awaited>, ) { const auth = snapshot.config.gateway?.auth; - expect(auth).toBeDefined(); if (!auth) { throw new Error("expected gateway auth config"); } diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 0e7bb121363..b32a57069a7 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -57,7 +57,6 @@ function createRequestWithConfigGet() { function requireRequestCall(request: ReturnType, index = 0): unknown[] { const call = request.mock.calls[index]; - expect(call).toBeDefined(); if (!call) { throw new Error("expected client request call"); } From f0dfabfc38f19cba5ad2e947564e013d40e71075 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:45:03 +0100 Subject: [PATCH 662/806] test: tighten browser doctor warning assertion --- src/commands/doctor-browser.facade.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/doctor-browser.facade.test.ts b/src/commands/doctor-browser.facade.test.ts index 9b110e05eb0..522e6c9a7dc 100644 --- a/src/commands/doctor-browser.facade.test.ts +++ b/src/commands/doctor-browser.facade.test.ts @@ -8,6 +8,15 @@ vi.mock("../plugin-sdk/facade-loader.js", () => ({ loadBundledPluginPublicSurfaceModuleSync, })); +function requireFirstNoteCall(noteFn: ReturnType): unknown[] { + const call = noteFn.mock.calls[0]; + expect(call).toBeDefined(); + if (!call) { + throw new Error("expected browser doctor note"); + } + return call; +} + describe("doctor browser facade", () => { beforeEach(() => { loadBundledPluginPublicSurfaceModuleSync.mockReset(); @@ -45,8 +54,9 @@ describe("doctor browser facade", () => { await expect(noteChromeMcpBrowserReadiness({}, { noteFn })).resolves.toBeUndefined(); expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("Browser health check is unavailable"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("missing browser doctor facade"); - expect(noteFn.mock.calls[0]?.[1]).toBe("Browser"); + const noteCall = requireFirstNoteCall(noteFn); + expect(String(noteCall[0])).toContain("Browser health check is unavailable"); + expect(String(noteCall[0])).toContain("missing browser doctor facade"); + expect(noteCall[1]).toBe("Browser"); }); }); From 2cd44d864a2d54092b116c5683d02ec719c3f095 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:45:46 +0100 Subject: [PATCH 663/806] test: tighten backup asset assertions --- src/commands/backup.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 57908cf9a59..89b71f1599a 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -88,12 +88,19 @@ describe("backup commands", () => { plan: Awaited>, ) { expect(plan.included).toHaveLength(1); - expect(plan.included[0]?.kind).toBe("state"); + const [included] = plan.included; + expect(included).toMatchObject({ kind: "state" }); expect(plan.skipped).toEqual( expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), ); } + function expectOnlyAssetKind(assets: Array<{ kind: string }>, kind: string) { + expect(assets).toHaveLength(1); + const [asset] = assets; + expect(asset).toMatchObject({ kind }); + } + it("collapses default config, credentials, and workspace into the state backup root", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); @@ -394,8 +401,7 @@ describe("backup commands", () => { dryRun: true, onlyConfig: true, }); - expect(configOnly.assets).toHaveLength(1); - expect(configOnly.assets[0]?.kind).toBe("config"); + expectOnlyAssetKind(configOnly.assets, "config"); }); }); @@ -428,7 +434,6 @@ describe("backup commands", () => { expect(result.onlyConfig).toBe(true); expect(result.includeWorkspace).toBe(false); - expect(result.assets).toHaveLength(1); - expect(result.assets[0]?.kind).toBe("config"); + expectOnlyAssetKind(result.assets, "config"); }); }); From 35adf7cbd3037b378c7fe189d18cf764b66e2c0c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:46:23 +0100 Subject: [PATCH 664/806] test: tighten agent identity write assertion --- src/commands/agents.identity.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index 15d1f1469ca..adce0af0b8a 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -43,7 +43,11 @@ async function writeIdentityFile(workspace: string, lines: string[]) { } function getWrittenMainIdentity() { - const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as ConfigWritePayload; + const [written] = configMocks.writeConfigFile.mock.calls[0] ?? []; + expect(written).toBeDefined(); + if (!written) { + throw new Error("expected written agent config"); + } return written.agents?.list?.find((entry) => entry.id === "main")?.identity; } From 189a07457313ece787ac6c6a4585a22055f88d25 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:46:52 +0100 Subject: [PATCH 665/806] test: tighten channel config write assertion --- .../channels.adds-non-default-telegram-account.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 00c3ec57c0f..68090ff2cd1 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -339,7 +339,12 @@ describe("channels command", () => { // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe written config shape. function getWrittenConfig(): T { expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); - return configMocks.writeConfigFile.mock.calls[0]?.[0] as T; + const [config] = configMocks.writeConfigFile.mock.calls[0] ?? []; + expect(config).toBeDefined(); + if (config === undefined) { + throw new Error("expected written channel config"); + } + return config as T; } async function runRemoveWithConfirm( From 3a2cd7ded57b52a0e97864d7c16fd016dcad66ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:47:32 +0100 Subject: [PATCH 666/806] test: tighten cli status helper assertions --- src/cli/plugins-cli.policy.test.ts | 2 -- src/commands/agent-via-gateway.test.ts | 8 ++++---- src/commands/status.test.ts | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index ed48463b77d..5701656dcca 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -43,7 +43,6 @@ describe("plugins cli policy mutations", () => { function requireFirstWrittenConfig(): OpenClawConfig { const [config] = writeConfigFile.mock.calls[0] ?? []; - expect(config).toBeDefined(); if (!config) { throw new Error("expected writeConfigFile to receive a config"); } @@ -53,7 +52,6 @@ describe("plugins cli policy mutations", () => { function requirePluginEntries( config: OpenClawConfig, ): NonNullable["entries"]> { - expect(config.plugins?.entries).toBeDefined(); if (!config.plugins?.entries) { throw new Error("expected plugin entries in config"); } diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 26ff3800bb4..3d37203cf89 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -303,12 +303,12 @@ describe("agentCliCommand", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); - const fallbackOpts = requireFirstCallArg<{ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { sessionId?: string; sessionKey?: string; runId?: string; resultMetaOverrides?: unknown; - }>(agentCommand, "embedded agent"); + }; expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); expect(fallbackOpts.sessionId).not.toBe("locked-session"); expect(fallbackOpts.sessionKey).toBe(`agent:main:explicit:${fallbackOpts.sessionId}`); @@ -342,11 +342,11 @@ describe("agentCliCommand", () => { runtime, ); - const fallbackOpts = requireFirstCallArg<{ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { sessionId?: string; sessionKey?: string; to?: string; - }>(agentCommand, "embedded agent"); + }; expect(fallbackOpts.to).toBe("+1555"); expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); expect(fallbackOpts.sessionKey).toBe(`agent:main:explicit:${fallbackOpts.sessionId}`); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 12bcce0e532..40aa4a7d9b2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -87,7 +87,6 @@ function getRuntimeLogs() { function getRuntimeLog(index: number): string { const call = runtimeLogMock.mock.calls[index]; - expect(call).toBeDefined(); if (!call) { throw new Error(`expected runtime log call ${index}`); } From 93af8cffcb548cd25ed01aae5d4c264d4cf7388c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:47:58 +0100 Subject: [PATCH 667/806] test: tighten cron repair assertions --- src/commands/doctor-cron.test.ts | 84 ++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index e2f949fb346..7e451303260 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -76,6 +76,22 @@ async function writeCronStore(storePath: string, jobs: Array>> { + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + return persisted.jobs; +} + +function requirePersistedJob(jobs: Array>, index: number) { + const job = jobs[index]; + expect(job).toBeDefined(); + if (!job) { + throw new Error(`expected persisted cron job ${index}`); + } + return job; +} + describe("maybeRepairLegacyCronStore", () => { it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => { const storePath = await makeTempStorePath(); @@ -90,23 +106,21 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - const [job] = persisted.jobs; - expect(job?.jobId).toBeUndefined(); - expect(job?.id).toBe("legacy-job"); - expect(job?.notify).toBeUndefined(); - expect(job?.schedule).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.jobId).toBeUndefined(); + expect(job.id).toBe("legacy-job"); + expect(job.notify).toBeUndefined(); + expect(job.schedule).toMatchObject({ kind: "cron", expr: "0 7 * * *", tz: "UTC", }); - expect(job?.delivery).toMatchObject({ + expect(job.delivery).toMatchObject({ mode: "webhook", to: "https://example.invalid/cron-finished", }); - expect(job?.payload).toMatchObject({ + expect(job.payload).toMatchObject({ kind: "systemEvent", text: "Morning brief", }); @@ -143,12 +157,12 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.id).toBe("42"); - expect(typeof persisted.jobs[1]?.id).toBe("string"); - expect(String(persisted.jobs[1]?.id)).toMatch(/^cron-/); + const jobs = await readPersistedJobs(storePath); + const firstJob = requirePersistedJob(jobs, 0); + const secondJob = requirePersistedJob(jobs, 1); + expect(firstJob.id).toBe("42"); + expect(typeof secondJob.id).toBe("string"); + expect(String(secondJob.id)).toMatch(/^cron-/); expect(noteMock).toHaveBeenCalledWith( expect.stringContaining("stores `id` as a non-string value"), "Cron", @@ -202,10 +216,9 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.notify).toBe(true); + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.notify).toBe(true); expect(noteSpy).toHaveBeenCalledWith( expect.stringContaining('uses legacy notify fallback alongside delivery mode "announce"'), "Doctor warnings", @@ -225,15 +238,14 @@ describe("maybeRepairLegacyCronStore", () => { prompter, }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); expect(prompter.confirm).toHaveBeenCalledWith({ message: "Repair legacy cron jobs now?", initialValue: true, }); - expect(persisted.jobs[0]?.jobId).toBe("legacy-job"); - expect(persisted.jobs[0]?.notify).toBe(true); + expect(job.jobId).toBe("legacy-job"); + expect(job.notify).toBe(true); expect(noteSpy).not.toHaveBeenCalledWith( expect.stringContaining("Cron store normalized"), "Doctor changes", @@ -282,11 +294,10 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.notify).toBeUndefined(); - expect(persisted.jobs[0]?.delivery).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.notify).toBeUndefined(); + expect(job.delivery).toMatchObject({ mode: "webhook", to: "https://example.invalid/cron-finished", }); @@ -321,13 +332,12 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.channel).toBeUndefined(); - expect(persisted.jobs[0]?.to).toBeUndefined(); - expect(persisted.jobs[0]?.threadId).toBeUndefined(); - expect(persisted.jobs[0]?.delivery).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.channel).toBeUndefined(); + expect(job.to).toBeUndefined(); + expect(job.threadId).toBeUndefined(); + expect(job.delivery).toMatchObject({ mode: "announce", channel: "telegram", to: "-1001234567890", From 1a8c643734eef5ffa8566531f8a56514936eaaac Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:49:11 +0100 Subject: [PATCH 668/806] test: tighten auth repair assertions --- .../doctor-auth.deprecated-cli-profiles.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 7d680ab3183..840a19e0ce9 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -50,6 +50,14 @@ function makePrompter(confirmValue: boolean): DoctorPrompter { }; } +function requireAuthConfig(config: OpenClawConfig): NonNullable { + expect(config.auth).toBeDefined(); + if (!config.auth) { + throw new Error("expected repaired auth config"); + } + return config.auth; +} + beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); @@ -144,13 +152,14 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { provider: "anthropic", legacyProfileId: "anthropic:default", }); - expect(next.auth?.profiles?.["anthropic:default"]).toBeUndefined(); - expect(next.auth?.profiles?.["anthropic:user@example.com"]).toMatchObject({ + const auth = requireAuthConfig(next); + expect(auth.profiles?.["anthropic:default"]).toBeUndefined(); + expect(auth.profiles?.["anthropic:user@example.com"]).toMatchObject({ provider: "anthropic", mode: "oauth", email: "user@example.com", }); - expect(next.auth?.order?.anthropic).toEqual(["anthropic:user@example.com"]); + expect(auth.order?.anthropic).toEqual(["anthropic:user@example.com"]); }); it("strips provider-controlled terminal escapes from repair prompts", async () => { From 5ee3a505e6db2f75a0d82f6153bd60490362ae83 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:49:50 +0100 Subject: [PATCH 669/806] test: tighten image provider lookup assertions --- src/image-generation/provider-registry.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/image-generation/provider-registry.test.ts b/src/image-generation/provider-registry.test.ts index 6c4ed231fc1..7cfa36343b3 100644 --- a/src/image-generation/provider-registry.test.ts +++ b/src/image-generation/provider-registry.test.ts @@ -34,6 +34,15 @@ async function loadProviderRegistry() { return await import("./provider-registry.js"); } +function requireImageProvider(id: string): ImageGenerationProviderPlugin { + const provider = getImageGenerationProvider(id); + expect(provider).toBeDefined(); + if (!provider) { + throw new Error(`expected image generation provider ${id}`); + } + return provider; +} + describe("image-generation provider registry", () => { beforeEach(async () => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -56,7 +65,7 @@ describe("image-generation provider registry", () => { const provider = getImageGenerationProvider("custom-image"); - expect(provider?.id).toBe("custom-image"); + expect(provider).toMatchObject({ id: "custom-image" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "imageGenerationProviders", cfg: undefined, @@ -72,6 +81,6 @@ describe("image-generation provider registry", () => { expect(listImageGenerationProviders().map((provider) => provider.id)).toEqual(["safe-image"]); expect(getImageGenerationProvider("__proto__")).toBeUndefined(); expect(getImageGenerationProvider("constructor")).toBeUndefined(); - expect(getImageGenerationProvider("safe-alias")?.id).toBe("safe-image"); + expect(requireImageProvider("safe-alias")).toMatchObject({ id: "safe-image" }); }); }); From d456dd1bd34797c6bdcd7a68b6249748bf9fccbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:50:02 +0100 Subject: [PATCH 670/806] test: tighten command extension helper assertions --- extensions/discord/src/api.test.ts | 1 - extensions/discord/src/resolve-allowlist-common.test.ts | 1 - extensions/firecrawl/src/firecrawl-tools.test.ts | 2 -- extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts | 1 - extensions/zalo/src/api.test.ts | 3 --- extensions/zalo/src/setup-surface.test.ts | 5 ----- extensions/zalouser/src/setup-surface.test.ts | 2 -- src/commands/agents.identity.test.ts | 4 ++-- .../channels.adds-non-default-telegram-account.test.ts | 1 - src/commands/doctor-browser.facade.test.ts | 1 - 10 files changed, 2 insertions(+), 19 deletions(-) diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 375182c58d0..3f90477fd89 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -142,7 +142,6 @@ describe("fetchDiscord", () => { }); expect(result).toEqual({ id: "42" }); - expect(request).toBeDefined(); if (!request) { throw new Error("expected Discord request init"); } diff --git a/extensions/discord/src/resolve-allowlist-common.test.ts b/extensions/discord/src/resolve-allowlist-common.test.ts index 163a708057c..a110d54832e 100644 --- a/extensions/discord/src/resolve-allowlist-common.test.ts +++ b/extensions/discord/src/resolve-allowlist-common.test.ts @@ -14,7 +14,6 @@ describe("resolve-allowlist-common", () => { it("resolves and filters guilds by id or name", () => { const mainGuild = findDiscordGuildByName(guilds, "Main Guild"); - expect(mainGuild).toBeDefined(); if (!mainGuild) { throw new Error("expected Main Guild lookup result"); } diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index 027a8e9df04..52a3f37956d 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -83,7 +83,6 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webSearch.apiKey"); const pluginEntry = applied.plugins?.entries?.firecrawl; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected Firecrawl plugin entry"); } @@ -354,7 +353,6 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); const pluginEntry = applied.plugins?.entries?.firecrawl; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected Firecrawl fetch plugin entry"); } diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts index 7f0122ddd52..e56e5146a27 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts @@ -18,7 +18,6 @@ function jsonResponse(payload: unknown, status = 200) { function requireFirstFetchInput(fetchImpl: ReturnType): RequestInfo | URL { const input = fetchImpl.mock.calls[0]?.[0] as RequestInfo | URL | undefined; - expect(input).toBeDefined(); if (!input) { throw new Error("expected fetch input"); } diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts index 919d6f0b4b7..8a13c4d8871 100644 --- a/extensions/zalo/src/api.test.ts +++ b/extensions/zalo/src/api.test.ts @@ -18,7 +18,6 @@ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => await run("test-token", fetcher); expect(fetcher).toHaveBeenCalledTimes(1); const [, init] = fetcher.mock.calls[0] ?? []; - expect(init).toBeDefined(); if (!init) { throw new Error("expected Zalo request init"); } @@ -71,11 +70,9 @@ describe("Zalo API request methods", () => { await rejected; const [, init] = fetcher.mock.calls[0] ?? []; - expect(init).toBeDefined(); if (!init) { throw new Error("expected Zalo chat action request init"); } - expect(init.signal).toBeDefined(); if (!init.signal) { throw new Error("expected Zalo chat action abort signal"); } diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index 856963f292b..5065017a6a6 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -61,7 +61,6 @@ describe("zalo setup wizard", () => { expect(result.accountId).toBe("default"); const zaloConfig = result.cfg.channels?.zalo; - expect(zaloConfig).toBeDefined(); if (!zaloConfig) { throw new Error("expected Zalo config"); } @@ -123,7 +122,6 @@ describe("zalo setup wizard", () => { const next = zaloDmPolicy.setPolicy(cfg, "open"); const zaloConfig = next.channels?.zalo; - expect(zaloConfig).toBeDefined(); if (!zaloConfig) { throw new Error("expected Zalo config"); } @@ -131,7 +129,6 @@ describe("zalo setup wizard", () => { const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount).toBeDefined(); if (!workAccount) { throw new Error("expected Zalo work account"); } @@ -157,7 +154,6 @@ describe("zalo setup wizard", () => { ); const zaloConfig = next.channels?.zalo; - expect(zaloConfig).toBeDefined(); if (!zaloConfig) { throw new Error("expected Zalo config"); } @@ -165,7 +161,6 @@ describe("zalo setup wizard", () => { const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount).toBeDefined(); if (!workAccount) { throw new Error("expected Zalo work account"); } diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 79e0955889e..67510b26254 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -33,12 +33,10 @@ describe("zalouser setup wizard", () => { ) { expect(result.accountId).toBe("default"); const channelConfig = result.cfg.channels?.zalouser; - expect(channelConfig).toBeDefined(); if (!channelConfig) { throw new Error("expected Zalo Personal channel config"); } const pluginEntry = result.cfg.plugins?.entries?.zalouser; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected Zalo Personal plugin entry"); } diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index adce0af0b8a..e5ef4d8bc93 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -44,11 +44,11 @@ async function writeIdentityFile(workspace: string, lines: string[]) { function getWrittenMainIdentity() { const [written] = configMocks.writeConfigFile.mock.calls[0] ?? []; - expect(written).toBeDefined(); if (!written) { throw new Error("expected written agent config"); } - return written.agents?.list?.find((entry) => entry.id === "main")?.identity; + const payload = written as ConfigWritePayload; + return payload.agents?.list?.find((entry) => entry.id === "main")?.identity; } async function runIdentityCommandFromWorkspace(workspace: string, fromIdentity = true) { diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 68090ff2cd1..604c6e67941 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -340,7 +340,6 @@ describe("channels command", () => { function getWrittenConfig(): T { expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); const [config] = configMocks.writeConfigFile.mock.calls[0] ?? []; - expect(config).toBeDefined(); if (config === undefined) { throw new Error("expected written channel config"); } diff --git a/src/commands/doctor-browser.facade.test.ts b/src/commands/doctor-browser.facade.test.ts index 522e6c9a7dc..9f89ac36891 100644 --- a/src/commands/doctor-browser.facade.test.ts +++ b/src/commands/doctor-browser.facade.test.ts @@ -10,7 +10,6 @@ vi.mock("../plugin-sdk/facade-loader.js", () => ({ function requireFirstNoteCall(noteFn: ReturnType): unknown[] { const call = noteFn.mock.calls[0]; - expect(call).toBeDefined(); if (!call) { throw new Error("expected browser doctor note"); } From c5cc6d6ae424980b357bd8ea865936f4837ae4e6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:50:32 +0100 Subject: [PATCH 671/806] test: tighten config validation log assertion --- src/commands/config-validation.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 8c9f2055207..5f0d3cc1737 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -45,6 +45,15 @@ describe("requireValidConfigSnapshot", () => { }; } + function requireFirstLog(runtime: ReturnType): string { + const [message] = runtime.log.mock.calls[0] ?? []; + expect(message).toBeDefined(); + if (message === undefined) { + throw new Error("expected runtime log message"); + } + return String(message); + } + it("returns config without emitting compatibility advice by default", async () => { createValidSnapshot(); const runtime = createRuntime(); @@ -69,10 +78,9 @@ describe("requireValidConfigSnapshot", () => { expect(config).toEqual({ plugins: {} }); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); - expect(String(runtime.log.mock.calls[0]?.[0])).toContain("Plugin compatibility: 1 notice."); - expect(String(runtime.log.mock.calls[0]?.[0])).toContain( - "legacy-plugin still uses legacy before_agent_start", - ); + const logMessage = requireFirstLog(runtime); + expect(logMessage).toContain("Plugin compatibility: 1 notice."); + expect(logMessage).toContain("legacy-plugin still uses legacy before_agent_start"); }); it("blocks invalid config before emitting compatibility advice", async () => { From 9f5400c108f7887f9e63ee60e9edc18e26cdcf40 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:51:07 +0100 Subject: [PATCH 672/806] test: tighten health json log assertion --- src/commands/health.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 4ff32c2bb35..ae85944cc55 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -56,6 +56,15 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); +function requireFirstRuntimeLog(): string { + const [message] = runtime.log.mock.calls[0] ?? []; + expect(message).toBeDefined(); + if (message === undefined) { + throw new Error("expected health command log output"); + } + return String(message); +} + describe("healthCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -90,8 +99,7 @@ describe("healthCommand", () => { await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never); expect(runtime.exit).not.toHaveBeenCalled(); - const logged = runtime.log.mock.calls[0]?.[0] as string; - const parsed = JSON.parse(logged) as HealthSummary; + const parsed = JSON.parse(requireFirstRuntimeLog()) as HealthSummary; expect(parsed.channels.whatsapp?.linked).toBe(true); expect(parsed.channels.telegram?.configured).toBe(true); expect(parsed.sessions.count).toBe(1); From 4783dc1e05c1fc4526d012841a3df8ca497a24d6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:51:59 +0100 Subject: [PATCH 673/806] test: tighten gateway auth prompt assertions --- src/commands/configure.gateway.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index 1a8144fc8ae..2796ff16c32 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -83,7 +83,11 @@ async function runGatewayPrompt(params: { ); const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime()); - const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + const [call] = mocks.buildGatewayAuthConfig.mock.calls[0] ?? []; + expect(call).toBeDefined(); + if (!call) { + throw new Error("expected gateway auth config input"); + } return { result, call }; } @@ -116,8 +120,8 @@ describe("promptGatewayConfig", () => { randomToken: "unused", authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }), }); - expect(call?.password).not.toBe("undefined"); - expect(call?.password).toBe(""); + expect(call.password).not.toBe("undefined"); + expect(call.password).toBe(""); }); it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => { @@ -131,8 +135,8 @@ describe("promptGatewayConfig", () => { ], }); - expect(call?.mode).toBe("trusted-proxy"); - expect(call?.trustedProxy).toEqual({ + expect(call.mode).toBe("trusted-proxy"); + expect(call.trustedProxy).toEqual({ userHeader: "x-forwarded-user", requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], allowUsers: ["nick@example.com"], @@ -146,8 +150,8 @@ describe("promptGatewayConfig", () => { textQueue: ["18789", "x-remote-user", "", "", "10.0.0.1"], }); - expect(call?.mode).toBe("trusted-proxy"); - expect(call?.trustedProxy).toEqual({ + expect(call.mode).toBe("trusted-proxy"); + expect(call.trustedProxy).toEqual({ userHeader: "x-remote-user", // requiredHeaders and allowUsers should be undefined when empty }); @@ -249,7 +253,7 @@ describe("promptGatewayConfig", () => { authConfigFactory: ({ mode, token }) => ({ mode, token }), }); - expect(call?.token).toEqual({ + expect(call.token).toEqual({ source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN", From 92c702b97a7bb754b086623820ef8b79db01f774 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:51:59 +0100 Subject: [PATCH 674/806] test: tighten provider config helper assertions --- extensions/chutes/models.test.ts | 2 -- extensions/cloudflare-ai-gateway/index.test.ts | 2 -- extensions/exa/src/exa-web-search-provider.test.ts | 2 -- extensions/fireworks/index.test.ts | 2 -- extensions/qwen/provider-catalog.test.ts | 2 -- src/commands/doctor-auth.deprecated-cli-profiles.test.ts | 1 - src/commands/doctor-cron.test.ts | 1 - src/image-generation/provider-registry.test.ts | 1 - 8 files changed, 13 deletions(-) diff --git a/extensions/chutes/models.test.ts b/extensions/chutes/models.test.ts index be87ac2919e..381dd2e1afb 100644 --- a/extensions/chutes/models.test.ts +++ b/extensions/chutes/models.test.ts @@ -79,7 +79,6 @@ describe("chutes-models", () => { expect(def.cost).toEqual(entry.cost); expect(def.contextWindow).toBe(entry.contextWindow); expect(def.maxTokens).toBe(entry.maxTokens); - expect(def.compat).toBeDefined(); if (!def.compat) { throw new Error("expected Chutes model compat"); } @@ -124,7 +123,6 @@ describe("chutes-models", () => { const secondModel = requireChutesModel(models, 1); expect(firstModel.id).toBe("zai-org/GLM-4.7-TEE"); expect(secondModel.reasoning).toBe(true); - expect(secondModel.compat).toBeDefined(); if (!secondModel.compat) { throw new Error("expected Chutes API model compat"); } diff --git a/extensions/cloudflare-ai-gateway/index.test.ts b/extensions/cloudflare-ai-gateway/index.test.ts index e4f0733e8ff..2c7064b39f5 100644 --- a/extensions/cloudflare-ai-gateway/index.test.ts +++ b/extensions/cloudflare-ai-gateway/index.test.ts @@ -6,7 +6,6 @@ import plugin from "./index.js"; function registerProvider() { const captured = capturePluginRegistration(plugin); const provider = captured.providers[0]; - expect(provider).toBeDefined(); if (!provider) { throw new Error("expected Cloudflare AI Gateway provider"); } @@ -53,7 +52,6 @@ describe("cloudflare-ai-gateway plugin", () => { {}, ); - expect(capturedPayload).toBeDefined(); if (!capturedPayload) { throw new Error("expected Cloudflare AI Gateway payload capture"); } diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index cffa03c2baf..2646e85eccd 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -15,7 +15,6 @@ describe("exa web search provider", () => { expect(provider.onboardingScopes).toEqual(["text-inference"]); expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey"); const pluginEntry = applied.plugins?.entries?.exa; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected Exa plugin entry"); } @@ -45,7 +44,6 @@ describe("exa web search provider", () => { }); expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull(); const pluginEntry = applied.plugins?.entries?.exa; - expect(pluginEntry).toBeDefined(); if (!pluginEntry) { throw new Error("expected contract Exa plugin entry"); } diff --git a/extensions/fireworks/index.test.ts b/extensions/fireworks/index.test.ts index 52e655f8442..7e03deebdf5 100644 --- a/extensions/fireworks/index.test.ts +++ b/extensions/fireworks/index.test.ts @@ -48,7 +48,6 @@ describe("fireworks provider plugin", () => { expect(provider.aliases).toEqual(["fireworks-ai"]); expect(provider.envVars).toEqual(["FIREWORKS_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved).toBeDefined(); if (!resolved) { throw new Error("expected Fireworks api-key auth choice"); } @@ -63,7 +62,6 @@ describe("fireworks provider plugin", () => { expect(catalogProvider.api).toBe("openai-completions"); expect(catalogProvider.baseUrl).toBe(FIREWORKS_BASE_URL); const models = catalogProvider.models; - expect(models).toBeDefined(); if (!models) { throw new Error("expected Fireworks catalog models"); } diff --git a/extensions/qwen/provider-catalog.test.ts b/extensions/qwen/provider-catalog.test.ts index 3e73879878a..ff3d6cd2bf5 100644 --- a/extensions/qwen/provider-catalog.test.ts +++ b/extensions/qwen/provider-catalog.test.ts @@ -10,7 +10,6 @@ import { type QwenProvider = ReturnType; function getQwenModelIds(provider: QwenProvider): string[] { - expect(provider.models).toBeDefined(); return provider.models.map((model) => model.id); } @@ -43,7 +42,6 @@ describe("qwen provider catalog", () => { expect(nativeProvider.models.length).toBeGreaterThan(0); expect( nativeProvider.models.every((model) => { - expect(model.compat).toBeDefined(); if (!model.compat) { throw new Error(`expected Qwen model ${model.id} compat`); } diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 840a19e0ce9..2b40d6531ef 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -51,7 +51,6 @@ function makePrompter(confirmValue: boolean): DoctorPrompter { } function requireAuthConfig(config: OpenClawConfig): NonNullable { - expect(config.auth).toBeDefined(); if (!config.auth) { throw new Error("expected repaired auth config"); } diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 7e451303260..de85eb80c76 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -85,7 +85,6 @@ async function readPersistedJobs(storePath: string): Promise>, index: number) { const job = jobs[index]; - expect(job).toBeDefined(); if (!job) { throw new Error(`expected persisted cron job ${index}`); } diff --git a/src/image-generation/provider-registry.test.ts b/src/image-generation/provider-registry.test.ts index 7cfa36343b3..5ececbdb82a 100644 --- a/src/image-generation/provider-registry.test.ts +++ b/src/image-generation/provider-registry.test.ts @@ -36,7 +36,6 @@ async function loadProviderRegistry() { function requireImageProvider(id: string): ImageGenerationProviderPlugin { const provider = getImageGenerationProvider(id); - expect(provider).toBeDefined(); if (!provider) { throw new Error(`expected image generation provider ${id}`); } From 7d2dd284438299351fb3b6da3d8f4a055ba8fdfa Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:52:48 +0100 Subject: [PATCH 675/806] test: tighten vitest process listener assertions --- test/scripts/vitest-process-group.test.ts | 37 +++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/test/scripts/vitest-process-group.test.ts b/test/scripts/vitest-process-group.test.ts index 75791bdac0c..f8d1c321050 100644 --- a/test/scripts/vitest-process-group.test.ts +++ b/test/scripts/vitest-process-group.test.ts @@ -7,6 +7,23 @@ import { } from "../../scripts/vitest-process-group.mjs"; describe("vitest process group helpers", () => { + function getListenerSet(listeners: Map void>>, event: string) { + const set = listeners.get(event); + expect(set).toBeDefined(); + if (!set) { + throw new Error(`expected ${event} listener set`); + } + return set; + } + + function expectListenerCount( + listeners: Map void>>, + event: string, + count: number, + ) { + expect(getListenerSet(listeners, event).size).toBe(count); + } + it("uses detached process groups on non-Windows hosts", () => { expect(shouldUseDetachedVitestProcessGroup("darwin")).toBe(true); expect(shouldUseDetachedVitestProcessGroup("linux")).toBe(true); @@ -84,17 +101,17 @@ describe("vitest process group helpers", () => { kill, }); - expect(listeners.get("SIGINT")?.size).toBe(1); - expect(listeners.get("SIGTERM")?.size).toBe(1); - expect(listeners.get("exit")?.size).toBe(1); + expectListenerCount(listeners, "SIGINT", 1); + expectListenerCount(listeners, "SIGTERM", 1); + expectListenerCount(listeners, "exit", 1); - listeners.get("SIGTERM")?.values().next().value?.(); + getListenerSet(listeners, "SIGTERM").values().next().value(); expect(kill).toHaveBeenCalledWith(-4200, "SIGTERM"); teardown(); - expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); - expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); - expect(listeners.get("exit")?.size ?? 0).toBe(0); + expectListenerCount(listeners, "SIGINT", 0); + expectListenerCount(listeners, "SIGTERM", 0); + expectListenerCount(listeners, "exit", 0); }); it("raises process listener limits for highly parallel cleanup handlers", () => { @@ -134,8 +151,8 @@ describe("vitest process group helpers", () => { for (const teardown of teardowns) { teardown(); } - expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); - expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); - expect(listeners.get("exit")?.size ?? 0).toBe(0); + expectListenerCount(listeners, "SIGINT", 0); + expectListenerCount(listeners, "SIGTERM", 0); + expectListenerCount(listeners, "exit", 0); }); }); From 3a09899e2a4fa7234760b4ab9eb906de236ae730 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:53:24 +0100 Subject: [PATCH 676/806] test: tighten command capture helper assertions --- src/commands/config-validation.test.ts | 1 - src/commands/configure.gateway.test.ts | 1 - src/commands/health.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 5f0d3cc1737..81b7a0550d3 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -47,7 +47,6 @@ describe("requireValidConfigSnapshot", () => { function requireFirstLog(runtime: ReturnType): string { const [message] = runtime.log.mock.calls[0] ?? []; - expect(message).toBeDefined(); if (message === undefined) { throw new Error("expected runtime log message"); } diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index 2796ff16c32..2cdff2563bd 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -84,7 +84,6 @@ async function runGatewayPrompt(params: { const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime()); const [call] = mocks.buildGatewayAuthConfig.mock.calls[0] ?? []; - expect(call).toBeDefined(); if (!call) { throw new Error("expected gateway auth config input"); } diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index ae85944cc55..0dee0a5ad03 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -58,7 +58,6 @@ vi.mock("../gateway/call.js", () => ({ function requireFirstRuntimeLog(): string { const [message] = runtime.log.mock.calls[0] ?? []; - expect(message).toBeDefined(); if (message === undefined) { throw new Error("expected health command log output"); } From 789fd014cf7d4ab6fb32a6d7d54479ff717cefbf Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:53:49 +0100 Subject: [PATCH 677/806] test: tighten video provider lookup assertions --- src/video-generation/provider-registry.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/video-generation/provider-registry.test.ts b/src/video-generation/provider-registry.test.ts index 2773b49e5ca..ee2213faba7 100644 --- a/src/video-generation/provider-registry.test.ts +++ b/src/video-generation/provider-registry.test.ts @@ -30,6 +30,15 @@ async function loadProviderRegistry() { return await import("./provider-registry.js"); } +function requireVideoProvider(id: string): VideoGenerationProviderPlugin { + const provider = getVideoGenerationProvider(id); + expect(provider).toBeDefined(); + if (!provider) { + throw new Error(`expected video generation provider ${id}`); + } + return provider; +} + describe("video-generation provider registry", () => { beforeEach(async () => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -50,7 +59,7 @@ describe("video-generation provider registry", () => { const provider = getVideoGenerationProvider("custom-video"); - expect(provider?.id).toBe("custom-video"); + expect(provider).toMatchObject({ id: "custom-video" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "videoGenerationProviders", cfg: undefined, @@ -66,6 +75,6 @@ describe("video-generation provider registry", () => { expect(listVideoGenerationProviders().map((provider) => provider.id)).toEqual(["safe-video"]); expect(getVideoGenerationProvider("__proto__")).toBeUndefined(); expect(getVideoGenerationProvider("constructor")).toBeUndefined(); - expect(getVideoGenerationProvider("safe-alias")?.id).toBe("safe-video"); + expect(requireVideoProvider("safe-alias")).toMatchObject({ id: "safe-video" }); }); }); From b758abd3adc8e1a893a880754f0b5bd7a45797ea Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:54:38 +0100 Subject: [PATCH 678/806] test: tighten video fallback attempt assertions --- src/video-generation/runtime.test.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/video-generation/runtime.test.ts b/src/video-generation/runtime.test.ts index c1381782ab1..de14122b26e 100644 --- a/src/video-generation/runtime.test.ts +++ b/src/video-generation/runtime.test.ts @@ -29,6 +29,18 @@ function runGenerateVideo(params: GenerateVideoParams) { return generateVideo(params, runtimeDeps); } +function requireAttempt( + result: Awaited>, + index: number, +): NonNullable<(typeof result.attempts)[number]> { + const attempt = result.attempts[index]; + expect(attempt).toBeDefined(); + if (!attempt) { + throw new Error(`expected video generation attempt ${index}`); + } + return attempt; +} + function createProviderOptionsCaptureProvider( capabilities: VideoGenerationProvider["capabilities"], ): { provider: VideoGenerationProvider; getSeenProviderOptions: () => unknown } { @@ -330,8 +342,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("byteplus"); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/does not accept providerOptions/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/does not accept providerOptions/); }); it("skips providers that cannot satisfy reference audio inputs and falls back", async () => { @@ -376,8 +389,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("byteplus"); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/does not support reference audio inputs/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/does not support reference audio inputs/); }); it("forwards mixed image, video, and audio references when explicitly supported", async () => { @@ -498,8 +512,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("runway"); expect(seenDurationSeconds).toBe(6); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/supports at most 4s per video, 6s requested/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/supports at most 4s per video, 6s requested/); }); it("fails when every candidate is skipped for exceeding hard duration caps", async () => { From 45ef4815df67e9f19cc0b740549d09267abc27bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:55:16 +0100 Subject: [PATCH 679/806] test: tighten messaging helper assertions --- .../src/channel-actions-setup-status.contract.test.ts | 1 - extensions/mattermost/src/doctor.test.ts | 9 ++++++--- extensions/mattermost/src/mattermost/client.test.ts | 2 -- .../mattermost/src/mattermost/model-picker.test.ts | 2 -- .../mattermost/src/mattermost/slash-commands.test.ts | 1 - extensions/mattermost/src/setup.test.ts | 1 - extensions/synology-chat/src/client.test.ts | 3 --- test/scripts/vitest-process-group.test.ts | 1 - 8 files changed, 6 insertions(+), 14 deletions(-) diff --git a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts index 3ed1863efa9..d399c603943 100644 --- a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts @@ -71,7 +71,6 @@ describe("mattermost setup contract", () => { expectedAccountId: "default", assertPatchedConfig: (cfg) => { const mattermostConfig = cfg.channels?.mattermost; - expect(mattermostConfig).toBeDefined(); if (!mattermostConfig) { throw new Error("expected Mattermost config patch"); } diff --git a/extensions/mattermost/src/doctor.test.ts b/extensions/mattermost/src/doctor.test.ts index 96ecd7b0357..cc16ff3b2f7 100644 --- a/extensions/mattermost/src/doctor.test.ts +++ b/extensions/mattermost/src/doctor.test.ts @@ -31,15 +31,18 @@ describe("mattermost doctor", () => { }); const mattermostConfig = result.config.channels?.mattermost; - expect(mattermostConfig).toBeDefined(); if (!mattermostConfig) { throw new Error("expected normalized Mattermost config"); } expect(mattermostConfig.network).toEqual({ dangerouslyAllowPrivateNetwork: true, }); - const workAccount = mattermostConfig.accounts?.work as { network?: Record }; - expect(workAccount).toBeDefined(); + const workAccount = mattermostConfig.accounts?.work as + | { network?: Record } + | undefined; + if (!workAccount) { + throw new Error("expected Mattermost work account config"); + } expect(workAccount.network).toEqual({ dangerouslyAllowPrivateNetwork: false, }); diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts index c280fcfd903..6cbf3a91f7f 100644 --- a/extensions/mattermost/src/mattermost/client.test.ts +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -277,12 +277,10 @@ describe("updateMattermostPost", () => { const { calls } = await updatePostAndCapture({ message: "Updated" }); const firstCall = calls[0]; - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected Mattermost update post request"); } expect(firstCall.url).toContain("/posts/post1"); - expect(firstCall.init).toBeDefined(); if (!firstCall.init) { throw new Error("expected Mattermost update post request init"); } diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 2e53b9e336f..c3c37e94dcc 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -60,12 +60,10 @@ describe("Mattermost model picker", () => { expect(view.text).toContain("Browse keeps the current runtime"); expect(view.text).toContain("/oc_model --runtime "); const firstRow = view.buttons[0]; - expect(firstRow).toBeDefined(); if (!firstRow) { throw new Error("expected Mattermost model picker button row"); } const browseButton = firstRow[0]; - expect(browseButton).toBeDefined(); if (!browseButton) { throw new Error("expected Mattermost browse providers button"); } diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts index 2e3fb09f192..d2f756f08d9 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.test.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -131,7 +131,6 @@ describe("slash-commands", () => { expect(result).toHaveLength(1); const firstCommand = result[0]; - expect(firstCommand).toBeDefined(); if (!firstCommand) { throw new Error("expected Mattermost slash command result"); } diff --git a/extensions/mattermost/src/setup.test.ts b/extensions/mattermost/src/setup.test.ts index 361abb6b12d..9af8238d314 100644 --- a/extensions/mattermost/src/setup.test.ts +++ b/extensions/mattermost/src/setup.test.ts @@ -392,7 +392,6 @@ describe("mattermost setup", () => { ); expect(textMessages).toEqual(["Enter Mattermost bot token", "Enter Mattermost base URL"]); const mattermostConfig = result.cfg.channels?.mattermost; - expect(mattermostConfig).toBeDefined(); if (!mattermostConfig) { throw new Error("expected Mattermost config"); } diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 37bf9cd26a4..d305342faeb 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -122,7 +122,6 @@ describe("Synology Chat TLS verification defaults", () => { await settleTimers(invoke()); const httpsRequest = vi.mocked(https.request); const firstCall = httpsRequest.mock.calls[0]; - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected Synology Chat HTTPS request"); } @@ -159,7 +158,6 @@ describe("sendMessage", () => { await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", undefined, true)); const httpsRequest = vi.mocked(https.request); const firstCall = httpsRequest.mock.calls[0]; - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected Synology Chat HTTPS request"); } @@ -387,7 +385,6 @@ describe("fetchChatUsers", () => { const httpsGet = vi.mocked(https.get); const firstCall = httpsGet.mock.calls[0]; - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected Synology Chat HTTPS get"); } diff --git a/test/scripts/vitest-process-group.test.ts b/test/scripts/vitest-process-group.test.ts index f8d1c321050..c0c2a7d47fc 100644 --- a/test/scripts/vitest-process-group.test.ts +++ b/test/scripts/vitest-process-group.test.ts @@ -9,7 +9,6 @@ import { describe("vitest process group helpers", () => { function getListenerSet(listeners: Map void>>, event: string) { const set = listeners.get(event); - expect(set).toBeDefined(); if (!set) { throw new Error(`expected ${event} listener set`); } From 76b5ea57755bf04f56cd510f7fd05e83af263a05 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:55:21 +0100 Subject: [PATCH 680/806] test: tighten media fetch guard assertion --- src/media-understanding/shared.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index a19023b69fb..605b3212b02 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -47,7 +47,7 @@ afterEach(() => { }); function getFirstGuardedFetchCall() { - const call = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; + const [call] = fetchWithSsrFGuardMock.mock.calls[0] ?? []; if (!call) { throw new Error("Expected fetchWithSsrFGuard to be called"); } From 4b913dc347d04f0b7e8d356898291cfec31bb178 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:56:08 +0100 Subject: [PATCH 681/806] test: tighten docs audit invocation assertions --- src/scripts/docs-link-audit.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/scripts/docs-link-audit.test.ts b/src/scripts/docs-link-audit.test.ts index d3127e7e2b2..9b9be50daca 100644 --- a/src/scripts/docs-link-audit.test.ts +++ b/src/scripts/docs-link-audit.test.ts @@ -211,7 +211,8 @@ describe("docs-link-audit", () => { expect(exitCode).toBe(0); expect(invocations).toHaveLength(2); - expect(invocations[0]).toMatchObject({ + const [versionCheck, linkCheck] = invocations; + expect(versionCheck).toMatchObject({ command: "fnm", args: [ "exec", @@ -222,13 +223,13 @@ describe("docs-link-audit", () => { ], options: { stdio: "ignore" }, }); - expect(invocations[1]).toMatchObject({ + expect(linkCheck).toMatchObject({ command: "fnm", args: ["exec", "--using=22", "pnpm", "dlx", "mint", "broken-links", "--check-anchors"], options: { stdio: "inherit" }, }); - expect(invocations[0]?.options.cwd).toBe(anchorDocsDir); - expect(invocations[1]?.options.cwd).toBe(anchorDocsDir); + expect(versionCheck.options.cwd).toBe(anchorDocsDir); + expect(linkCheck.options.cwd).toBe(anchorDocsDir); expect(cleanedDir).toBe(anchorDocsDir); }); }); From 9235dcc7b7394a219098a4d9b213e9eb167b4883 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:56:14 +0100 Subject: [PATCH 682/806] test: tighten video helper assertions --- src/video-generation/provider-registry.test.ts | 1 - src/video-generation/runtime.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/video-generation/provider-registry.test.ts b/src/video-generation/provider-registry.test.ts index ee2213faba7..2aa877a2fc7 100644 --- a/src/video-generation/provider-registry.test.ts +++ b/src/video-generation/provider-registry.test.ts @@ -32,7 +32,6 @@ async function loadProviderRegistry() { function requireVideoProvider(id: string): VideoGenerationProviderPlugin { const provider = getVideoGenerationProvider(id); - expect(provider).toBeDefined(); if (!provider) { throw new Error(`expected video generation provider ${id}`); } diff --git a/src/video-generation/runtime.test.ts b/src/video-generation/runtime.test.ts index de14122b26e..72eb2b3e260 100644 --- a/src/video-generation/runtime.test.ts +++ b/src/video-generation/runtime.test.ts @@ -34,7 +34,6 @@ function requireAttempt( index: number, ): NonNullable<(typeof result.attempts)[number]> { const attempt = result.attempts[index]; - expect(attempt).toBeDefined(); if (!attempt) { throw new Error(`expected video generation attempt ${index}`); } From 1d1883ad88685bf0b6f94a4f69714af28ca2999f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:56:58 +0100 Subject: [PATCH 683/806] test: tighten docker digest update assertions --- src/docker-image-digests.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index ae2646beb3f..9b4600bf998 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -114,6 +114,14 @@ function requireDependabotDockerUpdate(config: DependabotConfig): DependabotUpda return dockerUpdate; } +function requireDockerImageGroup(update: DependabotUpdate): DependabotDockerGroup { + const group = update.groups?.["docker-images"]; + if (!group) { + throw new Error("expected Dependabot docker-images group"); + } + return group; +} + describe("docker base image pinning", () => { it("pins selected Dockerfile FROM lines to immutable sha256 digests", async () => { for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) { @@ -141,8 +149,9 @@ describe("docker base image pinning", () => { const raw = await readFile(resolve(repoRoot, ".github/dependabot.yml"), "utf8"); const config = parse(raw) as DependabotConfig; const dockerUpdate = requireDependabotDockerUpdate(config); + const dockerImagesGroup = requireDockerImageGroup(dockerUpdate); expect(dockerUpdate.schedule?.interval).toBe("weekly"); - expect(dockerUpdate.groups?.["docker-images"]?.patterns).toContain("*"); + expect(dockerImagesGroup.patterns).toContain("*"); }); }); From 0c2f604051822c27ad39cd8816a4d5dbef3a756d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:57:37 +0100 Subject: [PATCH 684/806] test: tighten extension helper assertions --- extensions/canvas/src/config-migration.test.ts | 2 -- extensions/canvas/src/host/server.test.ts | 1 - extensions/huggingface/index.test.ts | 1 - extensions/qqbot/src/engine/gateway/message-queue.test.ts | 3 --- extensions/qqbot/src/engine/utils/audio.test.ts | 8 +++++--- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/extensions/canvas/src/config-migration.test.ts b/extensions/canvas/src/config-migration.test.ts index 78dde201702..bd336d996fa 100644 --- a/extensions/canvas/src/config-migration.test.ts +++ b/extensions/canvas/src/config-migration.test.ts @@ -12,7 +12,6 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result).toBeDefined(); if (!result) { throw new Error("expected Canvas config migration result"); } @@ -55,7 +54,6 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result).toBeDefined(); if (!result) { throw new Error("expected Canvas config migration result"); } diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index af5dec286b7..c939ad048f8 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -346,7 +346,6 @@ describe("canvas host", () => { ); expect(upgraded).toBe(true); const latestServer = TrackingWebSocketServerClass.latestInstance; - expect(latestServer).toBeDefined(); if (!latestServer) { throw new Error("expected Canvas host websocket server"); } diff --git a/extensions/huggingface/index.test.ts b/extensions/huggingface/index.test.ts index d4cb715d3cc..e40c1843fe5 100644 --- a/extensions/huggingface/index.test.ts +++ b/extensions/huggingface/index.test.ts @@ -41,7 +41,6 @@ function registerProviderWithPluginConfig(pluginConfig: Record) expect(registerProviderMock).toHaveBeenCalledTimes(1); const firstCall = registerProviderMock.mock.calls.at(0); - expect(firstCall).toBeDefined(); if (!firstCall) { throw new Error("expected huggingface provider registration"); } diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts index 601f449821c..f898a5c1540 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.test.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.test.ts @@ -15,7 +15,6 @@ function groupMsg(overrides: Partial = {}): QueuedMessage { } function requireMergeMetadata(message: QueuedMessage): NonNullable { - expect(message.merge).toBeDefined(); if (!message.merge) { throw new Error("expected QQBot merged message metadata"); } @@ -71,7 +70,6 @@ describe("engine/gateway/message-queue", () => { ], }), ]); - expect(merged.attachments).toBeDefined(); if (!merged.attachments) { throw new Error("expected QQBot merged attachments"); } @@ -83,7 +81,6 @@ describe("engine/gateway/message-queue", () => { groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Y" }] }), groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Z" }] }), ]); - expect(merged.mentions).toBeDefined(); if (!merged.mentions) { throw new Error("expected QQBot merged mentions"); } diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts index 8f1e281e150..6b25ddd7a22 100644 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -229,10 +229,12 @@ describe("engine/utils/audio", () => { const wav = buildMinimalWav(stereoPcm, 24000, 2); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected downmixed WAV fallback result"); + } // mono output: 2 samples × 2 bytes = 4 bytes - expect(result!.length).toBe(4); - const outView = new DataView(result!.buffer, result!.byteOffset); + expect(result.length).toBe(4); + const outView = new DataView(result.buffer, result.byteOffset); expect(outView.getInt16(0, true)).toBe(150); // (100+200)/2 expect(outView.getInt16(2, true)).toBe(-150); // (-100+-200)/2 }); From 8fe1379426a33017ea4b5a16b50371ba36502976 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:58:04 +0100 Subject: [PATCH 685/806] test: tighten native command lookup assertions --- src/auto-reply/commands-registry.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fa9de784a69..7360404ab5d 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -138,7 +138,7 @@ describe("commands registry", () => { textAliases: ["/btw", "/side"], }); expect(normalizeCommandBody("/side what changed?")).toBe("/btw what changed?"); - expect(findCommandByNativeName("side")?.key).toBe("btw"); + expect(requireNativeCommand("side").key).toBe("btw"); expect(listNativeCommandSpecs().find((spec) => spec.name === "side")).toMatchObject({ acceptsArgs: true, }); @@ -217,7 +217,7 @@ describe("commands registry", () => { { provider: "discord" }, ); expect([...nativeNameSet(native)]).toContain("voice"); - expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts"); + expect(requireNativeCommand("voice", "discord").key).toBe("tts"); expect(findCommandByNativeName("tts", "discord")).toBeUndefined(); }); @@ -228,7 +228,7 @@ describe("commands registry", () => { { provider: "slack" }, ); expect([...nativeNameSet(native)]).toContain("agentstatus"); - expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status"); + expect(requireNativeCommand("agentstatus", "slack").key).toBe("status"); expect(findCommandByNativeName("status", "slack")).toBeUndefined(); expect( findCommandByNativeName("agentstatus", "slack", { @@ -243,11 +243,10 @@ describe("commands registry", () => { }); it("can resolve default native command names without loading bundled channel fallbacks", () => { - expect( - findCommandByNativeName("status", "discord", { - includeBundledChannelFallback: false, - })?.key, - ).toBe("status"); + const command = findCommandByNativeName("status", "discord", { + includeBundledChannelFallback: false, + }); + expect(command).toMatchObject({ key: "status" }); }); it("keeps discord native command specs within slash-command limits", () => { @@ -262,7 +261,7 @@ describe("commands registry", () => { const command = requireNativeCommand(spec.name, "discord"); - const args = command?.args ?? spec.args ?? []; + const args = command.args ?? spec.args ?? []; const argNames = new Set(); let sawOptional = false; for (const arg of args) { From 127d698b68a0fb7987cca55a2b8c00a71f72c4de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 20:58:54 +0100 Subject: [PATCH 686/806] test: tighten slack prepared message assertions --- .../monitor/message-handler/prepare.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index e42b0c80ddc..c81b362f3c0 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -1981,10 +1981,12 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; + if (!result) { + throw new Error("expected Slack sender prefix message"); + } + const body = result.ctxPayload.Body; expect(body).toContain("Alice (U1): <@BOT> (Bek) hello"); - expect(result?.ctxPayload.RawBody).toBe("<@BOT> (Bek) hello"); + expect(result.ctxPayload.RawBody).toBe("<@BOT> (Bek) hello"); }); it("keeps raw Slack mention tokens when user lookup cannot resolve them", async () => { @@ -1997,10 +1999,12 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; + if (!result) { + throw new Error("expected Slack sender prefix message"); + } + const body = result.ctxPayload.Body; expect(body).toContain("Alice (U1): <@BOT> hello"); - expect(result?.ctxPayload.RawBody).toBe("<@BOT> hello"); + expect(result.ctxPayload.RawBody).toBe("<@BOT> hello"); }); it("caps Slack mention username lookups per inbound message and leaves overflow mentions raw", async () => { @@ -2183,7 +2187,9 @@ describe("slack thread.requireExplicitMention", () => { message, opts: { source: "message" }, }); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected Slack thread reply message"); + } }); it("allows thread reply without explicit mention when requireExplicitMention is false (default)", async () => { @@ -2210,6 +2216,8 @@ describe("slack thread.requireExplicitMention", () => { message, opts: { source: "message" }, }); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected Slack thread reply message"); + } }); }); From c3cab25ff4fc55353ae3dcafccfbbfcc6926645c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 20:59:20 +0100 Subject: [PATCH 687/806] test: tighten command arg assertions --- src/auto-reply/commands-registry.test.ts | 58 +++++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 7360404ab5d..5ee7ff315db 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -115,6 +115,39 @@ function requireNativeCommand(name: string, provider?: string): ChatCommandDefin return command; } +function requireCommandArg( + command: ChatCommandDefinition, + name: string, +): NonNullable[number] { + const arg = command.args?.find((candidate) => candidate.name === name); + if (!arg) { + throw new Error(`Expected ${command.key} command arg "${name}"`); + } + return arg; +} + +function requireCommandArgAt( + command: ChatCommandDefinition, + index: number, +): NonNullable[number] { + const arg = command.args?.[index]; + if (!arg) { + throw new Error(`Expected ${command.key} command arg ${index}`); + } + return arg; +} + +function requireCommandArgMenu( + params: Parameters[0], +): NonNullable> { + const menu = resolveCommandArgMenu(params); + expect(menu).not.toBeNull(); + if (!menu) { + throw new Error(`Expected arg menu for ${params.command.key}`); + } + return menu; +} + describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); @@ -301,8 +334,8 @@ describe("commands registry", () => { it("keeps ACP native action choices aligned with implemented handlers", () => { const acp = requireChatCommand("acp"); - const actionArg = acp.args?.find((arg) => arg.name === "action"); - expect(actionArg?.choices).toEqual([ + const actionArg = requireCommandArg(acp, "action"); + expect(actionArg.choices).toEqual([ "spawn", "cancel", "steer", @@ -323,14 +356,14 @@ describe("commands registry", () => { }); it("registers fast mode as a first-class options command", () => { - const fast = listChatCommands().find((command) => command.key === "fast"); + const fast = requireChatCommand("fast"); expect(fast).toMatchObject({ nativeName: "fast", textAliases: ["/fast"], category: "options", }); - const modeArg = fast?.args?.find((arg) => arg.name === "mode"); - expect(modeArg?.choices).toEqual(["status", "on", "off"]); + const modeArg = requireCommandArg(fast, "mode"); + expect(modeArg.choices).toEqual(["status", "on", "off"]); }); it("detects known text commands", () => { @@ -458,7 +491,7 @@ describe("commands registry args", () => { }; const args = parseCommandArgs(command, "set foo bar baz"); - expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" }); + expect(args).toMatchObject({ values: { action: "set", path: "foo", value: "bar baz" } }); }); it("serializes args via raw first, then values", () => { @@ -481,9 +514,9 @@ describe("commands registry args", () => { it("resolves auto arg menus when missing a choice arg", () => { const command = createUsageModeCommand(); - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("mode"); - expect(menu?.choices).toEqual([ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu.arg.name).toBe("mode"); + expect(menu.choices).toEqual([ { label: "off", value: "off" }, { label: "tokens", value: "tokens" }, { label: "full", value: "full" }, @@ -492,11 +525,12 @@ describe("commands registry args", () => { }); it("keeps verbose full available while preserving no-arg status dispatch", () => { - const verbose = listChatCommands().find((command) => command.key === "verbose"); + const verbose = requireChatCommand("verbose"); - expect(verbose?.args?.[0]?.choices).toEqual(["on", "off", "full"]); + const modeArg = requireCommandArgAt(verbose, 0); + expect(modeArg.choices).toEqual(["on", "off", "full"]); expect( - resolveCommandArgMenu({ command: verbose!, args: undefined, cfg: {} as never }), + resolveCommandArgMenu({ command: verbose, args: undefined, cfg: {} as never }), ).toBeNull(); }); From 42141d24aa6e8c0d067552631f035916b29681e6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:00:18 +0100 Subject: [PATCH 688/806] test: tighten command menu assertions --- src/auto-reply/commands-registry.test.ts | 50 ++++++++++++++---------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 5ee7ff315db..46f9940ae15 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -148,6 +148,22 @@ function requireCommandArgMenu( return menu; } +function requireSeenChoice( + seen: { + provider?: string; + model?: string; + catalogLength?: number; + commandKey: string; + argName: string; + } | null, +) { + expect(seen).not.toBeNull(); + if (!seen) { + throw new Error("Expected command choice context"); + } + return seen; +} + describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); @@ -580,34 +596,28 @@ describe("commands registry args", () => { ], }; - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("level"); - expect(menu?.choices).toEqual([ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu.arg.name).toBe("level"); + expect(menu.choices).toEqual([ { label: "low", value: "low" }, { label: "high", value: "high" }, ]); expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( "Choose level for /think.\nOptions: low, high.", ); - const seenChoice = seen as { - provider?: string; - model?: string; - catalogLength?: number; - commandKey: string; - argName: string; - } | null; - expect(seenChoice?.commandKey).toBe("think"); - expect(seenChoice?.argName).toBe("level"); - expect(seenChoice?.provider).toEqual(expect.stringMatching(/\S/)); - expect(seenChoice?.model).toEqual(expect.stringMatching(/\S/)); - expect(seenChoice?.catalogLength).toBe(0); + const seenChoice = requireSeenChoice(seen); + expect(seenChoice.commandKey).toBe("think"); + expect(seenChoice.argName).toBe("level"); + expect(seenChoice.provider).toEqual(expect.stringMatching(/\S/)); + expect(seenChoice.model).toEqual(expect.stringMatching(/\S/)); + expect(seenChoice.catalogLength).toBe(0); }); it("uses configured model catalog reasoning for /think arg menus", () => { installOllamaThinkingProvider(); const command = requireNativeCommand("think"); - const menu = resolveCommandArgMenu({ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: { @@ -623,8 +633,8 @@ describe("commands registry args", () => { model: "glm-5.1:cloud", }); - expect(menu?.arg.name).toBe("level"); - expect(menu?.choices.map((choice) => choice.value)).toEqual([ + expect(menu.arg.name).toBe("level"); + expect(menu.choices.map((choice) => choice.value)).toEqual([ "off", "low", "medium", @@ -639,7 +649,7 @@ describe("commands registry args", () => { it("uses configured model compat for /think arg menus", () => { const command = requireNativeCommand("think"); - const menu = resolveCommandArgMenu({ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: { @@ -662,7 +672,7 @@ describe("commands registry args", () => { model: "gpt-5.4", }); - expect(menu?.choices.map((choice) => choice.value)).toContain("xhigh"); + expect(menu.choices.map((choice) => choice.value)).toContain("xhigh"); expect(formatCommandArgMenuTitle({ command, menu: menu! })).toContain("xhigh"); }); From c9716d934ac410bf59754e229b67b78d05811bb2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:00:43 +0100 Subject: [PATCH 689/806] test: tighten discord msteams assertions --- extensions/discord/src/monitor/monitor.test.ts | 18 +++++++++++++++--- .../msteams/src/conversation-store-fs.test.ts | 9 ++++++++- .../msteams/src/monitor.lifecycle.test.ts | 4 +++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 52c4afbfc26..bd27e152136 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -392,7 +392,11 @@ describe("discord component interactions", () => { await button.run(secondInteraction, { cid: "btn_1" } as ComponentData); expect(dispatchReplyMock).toHaveBeenCalledTimes(2); - expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false }); + if (!entry) { + throw new Error("expected reusable Discord component entry"); + } + expect(entry.id).toBe("btn_1"); }); it("blocks buttons when allowedUsers does not match", async () => { @@ -411,7 +415,11 @@ describe("discord component interactions", () => { ephemeral: true, }); expect(dispatchReplyMock).not.toHaveBeenCalled(); - expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false }); + if (!entry) { + throw new Error("expected unauthorized Discord component entry to remain active"); + } + expect(entry.id).toBe("btn_1"); }); it("blocks buttons from guilds removed from the allowlist", async () => { @@ -590,7 +598,11 @@ describe("discord component interactions", () => { const { acknowledge } = await runModalSubmission({ reusable: true }); expect(acknowledge).toHaveBeenCalledTimes(1); - expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordModalEntry({ id: "mdl_1", consume: false }); + if (!entry) { + throw new Error("expected reusable Discord modal entry"); + } + expect(entry.id).toBe("mdl_1"); }); it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => { diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts index d7d92071cad..e45d31e4d48 100644 --- a/extensions/msteams/src/conversation-store-fs.test.ts +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -56,7 +56,14 @@ describe("msteams conversation store (fs-only)", () => { expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]); expect(await store.get("19:old@thread.tacv2")).toBeNull(); - expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull(); + const legacyConversation = await store.get("19:legacy@thread.tacv2"); + if (!legacyConversation) { + throw new Error("expected migrated legacy Teams conversation"); + } + if (!legacyConversation.conversation) { + throw new Error("expected migrated legacy Teams conversation payload"); + } + expect(legacyConversation.conversation.id).toBe("19:legacy@thread.tacv2"); await store.upsert("19:new@thread.tacv2", { ...ref, diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index f65cc063b99..85ee1b5dc47 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -276,7 +276,9 @@ describe("monitorMSTeamsProvider lifecycle", () => { abort.abort(); const result = await task; - expect(result.app).not.toBeNull(); + if (!result.app) { + throw new Error("expected Teams monitor app after startup abort"); + } await expect(result.shutdown()).resolves.toBeUndefined(); }); From 517c7660dd925a58a563d7b2a0909ae49a4a4802 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:01:13 +0100 Subject: [PATCH 690/806] test: tighten chunk length assertions --- src/auto-reply/chunk.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 2fd983bcc25..162b24ee71b 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -17,10 +17,19 @@ function expectFencesBalanced(chunks: string[]) { } } +function requireChunk(chunks: string[], index: number): string { + const chunk = chunks[index]; + expect(chunk).toBeDefined(); + if (chunk === undefined) { + throw new Error(`expected chunk ${index}`); + } + return chunk; +} + function expectChunkLengths(chunks: string[], expectedLengths: number[]) { expect(chunks).toHaveLength(expectedLengths.length); expectedLengths.forEach((length, index) => { - expect(chunks[index]?.length).toBe(length); + expect(requireChunk(chunks, index).length).toBe(length); }); } @@ -191,8 +200,8 @@ describe("chunkText", () => { text: "This is a message that should break nicely near a word boundary.", limit: 30, assert: (chunks: string[], text: string) => { - expect(chunks[0]?.length).toBeLessThanOrEqual(30); - expect(chunks[1]?.length).toBeLessThanOrEqual(30); + expect(requireChunk(chunks, 0).length).toBeLessThanOrEqual(30); + expect(requireChunk(chunks, 1).length).toBeLessThanOrEqual(30); expectNormalizedChunkJoin(chunks, text); }, }, @@ -395,7 +404,7 @@ describe("chunkMarkdownText", () => { run: () => { const text = `(${"a".repeat(80)})`; const chunks = chunkMarkdownText(text, 20); - expect(chunks[0]?.length).toBe(20); + expect(requireChunk(chunks, 0).length).toBe(20); expect(chunks.join("")).toBe(text); }, }, From f10e5c80f17167d1634d8eb3b375db46049c61ac Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:02:06 +0100 Subject: [PATCH 691/806] test: tighten live media plan assertions --- src/scripts/test-live-media.test.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/scripts/test-live-media.test.ts b/src/scripts/test-live-media.test.ts index 4adbab23c62..c9084a5c45c 100644 --- a/src/scripts/test-live-media.test.ts +++ b/src/scripts/test-live-media.test.ts @@ -13,6 +13,18 @@ vi.mock("../../src/agents/live-auth-keys.js", () => ({ collectProviderApiKeys: collectProviderApiKeysMock, })); +function requirePlanEntry( + plan: ReturnType, + suiteId: string, +) { + const entry = plan.find((candidate) => candidate.suite.id === suiteId); + expect(entry).toBeDefined(); + if (!entry) { + throw new Error(`expected ${suiteId} run plan entry`); + } + return entry; +} + describe("test-live-media", () => { afterEach(() => { collectProviderApiKeysMock.mockClear(); @@ -31,18 +43,15 @@ describe("test-live-media", () => { const plan = buildRunPlan(parseArgs([])); expect(plan.map((entry) => entry.suite.id)).toEqual(["image", "music", "video"]); - expect(plan.find((entry) => entry.suite.id === "image")?.providers).toEqual([ + expect(requirePlanEntry(plan, "image").providers).toEqual([ "fal", "google", "minimax", "openai", "vydra", ]); - expect(plan.find((entry) => entry.suite.id === "music")?.providers).toEqual([ - "google", - "minimax", - ]); - expect(plan.find((entry) => entry.suite.id === "video")?.providers).toEqual([ + expect(requirePlanEntry(plan, "music").providers).toEqual(["google", "minimax"]); + expect(requirePlanEntry(plan, "video").providers).toEqual([ "google", "minimax", "openai", @@ -57,8 +66,11 @@ describe("test-live-media", () => { ); expect(plan).toHaveLength(1); - expect(plan[0]?.suite.id).toBe("video"); - expect(plan[0]?.providers).toEqual(["fal", "openai", "runway"]); + const [entry] = plan; + expect(entry).toMatchObject({ + suite: { id: "video" }, + providers: ["fal", "openai", "runway"], + }); }); it("forwards quiet flags separately from passthrough args", async () => { From b570511e238f4712fcb34bff562a64fe1fcd3e00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:02:32 +0100 Subject: [PATCH 692/806] test: tighten auto reply nullable assertions --- src/auto-reply/commands-registry.test.ts | 8 +++----- src/auto-reply/reply/session-fork.runtime.test.ts | 8 +++++--- src/auto-reply/reply/session.test.ts | 5 ++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 46f9940ae15..48aaae362e1 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -141,7 +141,6 @@ function requireCommandArgMenu( params: Parameters[0], ): NonNullable> { const menu = resolveCommandArgMenu(params); - expect(menu).not.toBeNull(); if (!menu) { throw new Error(`Expected arg menu for ${params.command.key}`); } @@ -157,7 +156,6 @@ function requireSeenChoice( argName: string; } | null, ) { - expect(seen).not.toBeNull(); if (!seen) { throw new Error("Expected command choice context"); } @@ -602,7 +600,7 @@ describe("commands registry args", () => { { label: "low", value: "low" }, { label: "high", value: "high" }, ]); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( + expect(formatCommandArgMenuTitle({ command, menu })).toBe( "Choose level for /think.\nOptions: low, high.", ); const seenChoice = requireSeenChoice(seen); @@ -641,7 +639,7 @@ describe("commands registry args", () => { "high", "max", ]); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( + expect(formatCommandArgMenuTitle({ command, menu })).toBe( "Choose level for /think.\nOptions: off, low, medium, high, max.", ); }); @@ -673,7 +671,7 @@ describe("commands registry args", () => { }); expect(menu.choices.map((choice) => choice.value)).toContain("xhigh"); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toContain("xhigh"); + expect(formatCommandArgMenuTitle({ command, menu })).toContain("xhigh"); }); it("does not show menus when args were provided as raw text only", () => { diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index 1d021ae5e1e..caa4a63db26 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -344,14 +344,16 @@ describe("forkSessionFromParentRuntime", () => { sessionsDir, }); - expect(fork).not.toBeNull(); - const raw = await fs.readFile(fork?.sessionFile ?? "", "utf-8"); + if (!fork) { + throw new Error("expected forked session entry"); + } + const raw = await fs.readFile(fork.sessionFile, "utf-8"); const lines = raw.trim().split(/\r?\n/u); expect(lines).toHaveLength(1); const resolvedParentSessionFile = await fs.realpath(parentSessionFile); expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ type: "session", - id: fork?.sessionId, + id: fork.sessionId, parentSession: resolvedParentSessionFile, }); }); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 0121badb8be..c0678a5024f 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2693,7 +2693,10 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(result.isNewSession).toBe(false); expect(result.sessionId).toBe(existingSessionId); expect(result.sessionEntry.cliSessionBindings?.["claude-cli"]).toEqual(cliBinding); - expect(await fs.stat(transcriptPath).catch(() => null)).not.toBeNull(); + const transcriptStat = await fs.stat(transcriptPath).catch(() => null); + if (!transcriptStat) { + throw new Error("expected transcript file to remain after stale reset"); + } const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) => entry.startsWith(`${existingSessionId}.jsonl.reset.`), ); From 8465629fb851d33e5639b6749eec5b1b98955ca1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:03:07 +0100 Subject: [PATCH 693/806] test: tighten pairing request assertions --- src/pairing/pairing-store.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index eccddd61157..5b1214303d8 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -78,6 +78,18 @@ function setDefaultRandomIntMock() { }); } +function requireFirstPairingRequest( + requests: Awaited>, +) { + expect(requests).toHaveLength(1); + const [request] = requests; + expect(request).toBeDefined(); + if (!request) { + throw new Error("expected pairing request"); + } + return request; +} + async function withTempStateDir(fn: (stateDir: string) => Promise) { const dir = path.join(fixtureRoot, `case-${caseId++}`); fsSync.mkdirSync(dir, { recursive: true }); @@ -277,10 +289,8 @@ async function expectPendingPairingRequestsIsolatedByAccount(params: { process.env, params.secondAccountId, ); - expect(firstList).toHaveLength(1); - expect(secondList).toHaveLength(1); - expect(firstList[0]?.code).toBe(first.code); - expect(secondList[0]?.code).toBe(second.code); + expect(requireFirstPairingRequest(firstList).code).toBe(first.code); + expect(requireFirstPairingRequest(secondList).code).toBe(second.code); } describe("pairing store", () => { @@ -300,8 +310,7 @@ describe("pairing store", () => { expect(second.created).toBe(false); expect(second.code).toBe(first.code); const reusedList = await listChannelPairingRequests("demo-pairing-a"); - expect(reusedList).toHaveLength(1); - expect(reusedList[0]?.code).toBe(first.code); + expect(requireFirstPairingRequest(reusedList).code).toBe(first.code); const created = await upsertChannelPairingRequest({ channel: "demo-pairing-b", @@ -435,7 +444,7 @@ describe("pairing store", () => { channel: "telegram", code: created.code, }); - expect(approved?.id).toBe("67890"); + expect(approved).toMatchObject({ id: "67890" }); await expectAccountScopedEntryIsolated("67890"); const filtered = await createTelegramPairingRequest("yy", "filtered"); From c895afe872226e29fd3faa9f2a68b44436d95d26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:03:55 +0100 Subject: [PATCH 694/806] test: tighten live media chunk helpers --- src/auto-reply/chunk.test.ts | 1 - src/scripts/test-live-media.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 162b24ee71b..e1e90ebd7eb 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -19,7 +19,6 @@ function expectFencesBalanced(chunks: string[]) { function requireChunk(chunks: string[], index: number): string { const chunk = chunks[index]; - expect(chunk).toBeDefined(); if (chunk === undefined) { throw new Error(`expected chunk ${index}`); } diff --git a/src/scripts/test-live-media.test.ts b/src/scripts/test-live-media.test.ts index c9084a5c45c..ad2399bb5fd 100644 --- a/src/scripts/test-live-media.test.ts +++ b/src/scripts/test-live-media.test.ts @@ -18,7 +18,6 @@ function requirePlanEntry( suiteId: string, ) { const entry = plan.find((candidate) => candidate.suite.id === suiteId); - expect(entry).toBeDefined(); if (!entry) { throw new Error(`expected ${suiteId} run plan entry`); } From 986efee29cdae8f5bdbb621b09788958474beaab Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:05:06 +0100 Subject: [PATCH 695/806] test: tighten safe regex assertions --- src/security/safe-regex.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/security/safe-regex.test.ts b/src/security/safe-regex.test.ts index 1de9f51d977..851d13a55e0 100644 --- a/src/security/safe-regex.test.ts +++ b/src/security/safe-regex.test.ts @@ -6,6 +6,15 @@ import { testRegexWithBoundedInput, } from "./safe-regex.js"; +function expectCompiledRegex(pattern: string, flags?: string): RegExp { + const re = compileSafeRegex(pattern, flags); + expect(re).toBeInstanceOf(RegExp); + if (!re) { + throw new Error(`Expected ${pattern} to compile safely`); + } + return re; +} + describe("safe regex", () => { it.each([ ["(a+)+$", true], @@ -30,16 +39,14 @@ describe("safe regex", () => { }); it("compiles common safe filter regex", () => { - const re = compileSafeRegex("^agent:.*:discord:"); - expect(re).toBeInstanceOf(RegExp); - expect(re?.test("agent:main:discord:channel:123")).toBe(true); - expect(re?.test("agent:main:telegram:channel:123")).toBe(false); + const re = expectCompiledRegex("^agent:.*:discord:"); + expect(re.test("agent:main:discord:channel:123")).toBe(true); + expect(re.test("agent:main:telegram:channel:123")).toBe(false); }); it("supports explicit flags", () => { - const re = compileSafeRegex("token=([A-Za-z0-9]+)", "gi"); - expect(re).toBeInstanceOf(RegExp); - expect("TOKEN=abcd1234".replace(re as RegExp, "***")).toBe("***"); + const re = expectCompiledRegex("token=([A-Za-z0-9]+)", "gi"); + expect("TOKEN=abcd1234".replace(re, "***")).toBe("***"); }); it.each([ From e63ca03bc9514c26c5b94c2010bf73f11fc19319 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:05:27 +0100 Subject: [PATCH 696/806] test: tighten pairing provider assertions --- extensions/matrix/src/matrix/sdk.test.ts | 4 +++- extensions/twitch/src/setup-surface.test.ts | 6 ++++-- src/pairing/pairing-store.test.ts | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 01e088972a4..69d0a0a5be2 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -3213,7 +3213,9 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.success).toBe(true); expect(result.verification.verified).toBe(true); expect(result.crossSigning.published).toBe(true); - expect(result.cryptoBootstrap).not.toBeNull(); + if (!result.cryptoBootstrap) { + throw new Error("expected Matrix crypto bootstrap result"); + } }); it("reports bootstrap failure when the device is only locally trusted", async () => { diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts index c1cc263ae22..4b102f3f97f 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -200,8 +200,10 @@ describe("setup surface helpers", () => { ); // Should return config with username and clientId - expect(result).not.toBeNull(); - const defaultAccount = result?.cfg.channels?.twitch?.accounts?.default as + if (!result) { + throw new Error("expected Twitch env-token setup result"); + } + const defaultAccount = result.cfg.channels?.twitch?.accounts?.default as | { username?: string; clientId?: string } | undefined; expect(defaultAccount?.username).toBe("testbot"); diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 5b1214303d8..2f47c756df4 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -83,7 +83,6 @@ function requireFirstPairingRequest( ) { expect(requests).toHaveLength(1); const [request] = requests; - expect(request).toBeDefined(); if (!request) { throw new Error("expected pairing request"); } From 2e28459a0e332586ba79acbf62cba8f6bab7faac Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:05:47 +0100 Subject: [PATCH 697/806] test: tighten audit summary assertion --- src/security/audit-summary.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/security/audit-summary.test.ts b/src/security/audit-summary.test.ts index b0497bfc7b0..ce65f6ad3bf 100644 --- a/src/security/audit-summary.test.ts +++ b/src/security/audit-summary.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { collectAttackSurfaceSummaryFindings } from "./audit-extra.summary.js"; +function requireAttackSurfaceSummary( + findings: ReturnType, +) { + const summary = findings.find((f) => f.checkId === "summary.attack_surface"); + expect(summary).toEqual( + expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), + ); + if (!summary) { + throw new Error("Expected attack surface summary finding"); + } + return summary; +} + describe("security audit attack surface summary", () => { it("includes an attack surface summary (info)", () => { const cfg: OpenClawConfig = { @@ -12,13 +25,8 @@ describe("security audit attack surface summary", () => { }; const findings = collectAttackSurfaceSummaryFindings(cfg); - const summary = findings.find((f) => f.checkId === "summary.attack_surface"); + const summary = requireAttackSurfaceSummary(findings); - expect(findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), - ]), - ); - expect(summary?.detail).toContain("trust model: personal assistant"); + expect(summary.detail).toContain("trust model: personal assistant"); }); }); From 3cdb73c423ce5a4e65f175c8e561ffd1c6d5c005 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:06:13 +0100 Subject: [PATCH 698/806] test: tighten audit extra assertions --- src/security/audit-extra.sync.test.ts | 35 +++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/security/audit-extra.sync.test.ts b/src/security/audit-extra.sync.test.ts index 8d872eb32fb..11b2c3e1c28 100644 --- a/src/security/audit-extra.sync.test.ts +++ b/src/security/audit-extra.sync.test.ts @@ -10,6 +10,15 @@ vi.mock("../plugins/web-search-credential-presence.js", () => ({ hasConfiguredWebSearchCredential: () => false, })); +function requireFirstFinding(findings: readonly T[], label: string): T { + const [finding] = findings; + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${label} finding`); + } + return finding; +} + describe("collectAttackSurfaceSummaryFindings", () => { it.each([ { @@ -39,7 +48,10 @@ describe("collectAttackSurfaceSummaryFindings", () => { expectedDetail: ["hooks.internal: disabled"], }, ])("$name", ({ cfg, expectedDetail }) => { - const [finding] = collectAttackSurfaceSummaryFindings(cfg); + const finding = requireFirstFinding( + collectAttackSurfaceSummaryFindings(cfg), + "attack surface summary", + ); expect(finding.checkId).toBe("summary.attack_surface"); for (const snippet of expectedDetail) { expect(finding.detail).toContain(snippet); @@ -89,19 +101,22 @@ describe("collectSmallModelRiskFindings", () => { detailExcludes: ["No web/browser tools detected"], }, ])("$name", ({ cfg, env, detailIncludes, detailExcludes }) => { - const [finding] = collectSmallModelRiskFindings({ - cfg, - env, - }); + const finding = requireFirstFinding( + collectSmallModelRiskFindings({ + cfg, + env, + }), + "small model risk", + ); - expect(finding?.checkId).toBe("models.small_params"); - expect(finding?.severity).toBe("critical"); - expect(finding?.detail).toContain("ollama/mistral-8b"); + expect(finding.checkId).toBe("models.small_params"); + expect(finding.severity).toBe("critical"); + expect(finding.detail).toContain("ollama/mistral-8b"); for (const snippet of detailIncludes) { - expect(finding?.detail).toContain(snippet); + expect(finding.detail).toContain(snippet); } for (const snippet of detailExcludes) { - expect(finding?.detail).not.toContain(snippet); + expect(finding.detail).not.toContain(snippet); } }); }); From bffa43df09c8a7f4d70f1028fba938ad55751972 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:06:54 +0100 Subject: [PATCH 699/806] test: tighten exec surface assertion --- src/security/audit-exec-surface.test.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index ecfcab73b7d..9af9e5f7b2e 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -16,6 +16,18 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireFinding( + checkId: "tools.exec.fs_tools_disabled_but_exec_enabled", + findings: ReturnType, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + afterEach(() => { saveExecApprovals({ version: 1, agents: {} }); }); @@ -132,13 +144,11 @@ describe("security audit exec surface findings", () => { }, } satisfies OpenClawConfig); - const finding = findings.find( - (entry) => entry.checkId === "tools.exec.fs_tools_disabled_but_exec_enabled", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("tools"); - expect(finding?.detail).toContain("runtime=[exec, process]"); - expect(finding?.remediation).toContain("deny exec and process"); + const finding = requireFinding("tools.exec.fs_tools_disabled_but_exec_enabled", findings); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain("tools"); + expect(finding.detail).toContain("runtime=[exec, process]"); + expect(finding.remediation).toContain("deny exec and process"); }); it("does not warn when sandbox filesystem policy constrains exec", () => { From 1e9d8b4d92f4e2ced3d7d571ba83d7444f2889ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:07:02 +0100 Subject: [PATCH 700/806] test: tighten telegram context assertions --- .../src/bot-message-context.dm-threads.test.ts | 8 +++++--- ...bot-message-context.dm-topic-threadid.test.ts | 16 ++++++++++++---- .../bot-message-context.require-mention.test.ts | 12 +++++++++--- extensions/telegram/src/sticker-cache.test.ts | 6 +++++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index 33a46b7a23c..090fcb47176 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -208,11 +208,13 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram non-forum group context"); + } // Session key should NOT include :topic:42 - expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); + expect(ctx.ctxPayload.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); // MessageThreadId should be undefined (not a forum) - expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); + expect(ctx.ctxPayload.MessageThreadId).toBeUndefined(); }); it("keeps same session for regular group with and without message_thread_id", async () => { diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index 3ca63a75c15..3d079be9076 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -67,7 +67,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx?.ctxPayload).toBeDefined(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram DM topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234", threadId: "42" }); @@ -80,7 +82,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx?.ctxPayload).toBeDefined(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram DM context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234" }); @@ -97,7 +101,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx?.ctxPayload).toBeDefined(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram forum topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:99", threadId: "99" }); @@ -113,7 +119,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx?.ctxPayload).toBeDefined(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram General topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:1", threadId: "1" }); diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts index 90a7de251a8..6154f0cbc77 100644 --- a/extensions/telegram/src/bot-message-context.require-mention.test.ts +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -56,7 +56,9 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram context when topic disables requireMention"); + } }); it("lets explicit topic requireMention=false override mention activation", async () => { @@ -72,7 +74,9 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx?.ctxPayload).toBeDefined(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram context payload when topic disables requireMention"); + } expect(resolveGroupActivation).toHaveBeenCalledWith( expect.objectContaining({ chatId: -1001234567890, @@ -107,6 +111,8 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram context when topic config keeps agent"); + } }); }); diff --git a/extensions/telegram/src/sticker-cache.test.ts b/extensions/telegram/src/sticker-cache.test.ts index b73c101a7c8..966bbbdd01a 100644 --- a/extensions/telegram/src/sticker-cache.test.ts +++ b/extensions/telegram/src/sticker-cache.test.ts @@ -59,7 +59,11 @@ describe("sticker-cache", () => { }; stickerCache.cacheSticker(sticker); - expect(stickerCache.getCachedSticker("unique123")).not.toBeNull(); + const cachedSticker = stickerCache.getCachedSticker("unique123"); + if (!cachedSticker) { + throw new Error("expected cached Telegram sticker"); + } + expect(cachedSticker.fileUniqueId).toBe("unique123"); jsonStoreMocks.store.value = null; From 94314ef8cfa63eeef3d67f27ad61cd8ca5b4607e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:07:29 +0100 Subject: [PATCH 701/806] test: tighten gateway auth warning assertion --- .../audit-gateway-auth-selection.test.ts | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/security/audit-gateway-auth-selection.test.ts b/src/security/audit-gateway-auth-selection.test.ts index ca1c139ed6a..9767e60516f 100644 --- a/src/security/audit-gateway-auth-selection.test.ts +++ b/src/security/audit-gateway-auth-selection.test.ts @@ -3,6 +3,17 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayProbeAuthSafe, resolveGatewayProbeTarget } from "../gateway/probe-auth.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; +function requireProbeAuthWarning(findings: ReturnType) { + const warning = findings.find( + (finding) => finding.checkId === "gateway.probe_auth_secretref_unavailable", + ); + expect(warning).toBeDefined(); + if (!warning) { + throw new Error("Expected gateway probe auth SecretRef warning"); + } + return warning; +} + describe("security audit gateway auth selection", () => { it("applies gateway auth precedence across local and remote modes", async () => { const makeProbeEnv = (env?: { token?: string; password?: string }) => { @@ -129,19 +140,21 @@ describe("security audit gateway auth selection", () => { mode: "local", env: {}, }); - const warning = collectDeepProbeFindings({ - deep: { - gateway: { - attempted: true, - url: "ws://127.0.0.1:18789", - ok: true, - error: null, - close: null, + const warning = requireProbeAuthWarning( + collectDeepProbeFindings({ + deep: { + gateway: { + attempted: true, + url: "ws://127.0.0.1:18789", + ok: true, + error: null, + close: null, + }, }, - }, - authWarning: result.warning, - }).find((finding) => finding.checkId === "gateway.probe_auth_secretref_unavailable"); - expect(warning?.severity).toBe("warn"); - expect(warning?.detail).toContain("gateway.auth.token"); + authWarning: result.warning, + }), + ); + expect(warning.severity).toBe("warn"); + expect(warning.detail).toContain("gateway.auth.token"); }); }); From 558cc44e74e75bb4923f3085d59ce80918878273 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:07:53 +0100 Subject: [PATCH 702/806] test: tighten channel readonly assertion --- .../audit-channel-readonly-resolution.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/security/audit-channel-readonly-resolution.test.ts b/src/security/audit-channel-readonly-resolution.test.ts index a53ab13a546..872eda225d9 100644 --- a/src/security/audit-channel-readonly-resolution.test.ts +++ b/src/security/audit-channel-readonly-resolution.test.ts @@ -31,6 +31,19 @@ function stubChannelPlugin(params: { }; } +function requireReadOnlyResolutionFinding( + findings: Awaited>, +) { + const finding = findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error("Expected Zalo read-only resolution warning"); + } + return finding; +} + describe("security audit channel read-only resolution", () => { it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { const plugin = stubChannelPlugin({ @@ -54,12 +67,10 @@ describe("security audit channel read-only resolution", () => { plugins: [plugin], }); - const finding = findings.find( - (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.title).toContain("could not be fully resolved"); - expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); - expect(finding?.detail).toContain("missing SecretRef"); + const finding = requireReadOnlyResolutionFinding(findings); + expect(finding.severity).toBe("warn"); + expect(finding.title).toContain("could not be fully resolved"); + expect(finding.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding.detail).toContain("missing SecretRef"); }); }); From 69cecf4030232af6cef5f13dc996ec900e4c38fb Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:08:39 +0100 Subject: [PATCH 703/806] test: tighten exec safe bin assertion --- src/security/audit-exec-safe-bins.test.ts | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/security/audit-exec-safe-bins.test.ts b/src/security/audit-exec-safe-bins.test.ts index caf61f246d7..794ea36fcc9 100644 --- a/src/security/audit-exec-safe-bins.test.ts +++ b/src/security/audit-exec-safe-bins.test.ts @@ -12,6 +12,18 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === "warn"); } +function requireFinding( + checkId: "tools.exec.safe_bin_trusted_dirs_risky", + findings: ReturnType, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit exec safe-bin findings", () => { it.each([ { @@ -136,13 +148,11 @@ describe("security audit exec safe-bin findings", () => { }, } satisfies OpenClawConfig); - const riskyFinding = findings.find( - (finding) => finding.checkId === "tools.exec.safe_bin_trusted_dirs_risky", - ); - expect(riskyFinding?.severity).toBe("warn"); - expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[0]); - expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[1]); - expect(riskyFinding?.detail).toContain("agents.list.ops.tools.exec"); + const riskyFinding = requireFinding("tools.exec.safe_bin_trusted_dirs_risky", findings); + expect(riskyFinding.severity).toBe("warn"); + expect(riskyFinding.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(riskyFinding.detail).toContain(riskyGlobalTrustedDirs[1]); + expect(riskyFinding.detail).toContain("agents.list.ops.tools.exec"); }); it("ignores non-risky absolute dirs", () => { From f5c7465dacf8c568ebb89583579233555dd06730 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:08:58 +0100 Subject: [PATCH 704/806] test: tighten telegram media assertions --- .../bot/delivery.resolve-media-retry.test.ts | 26 +++++++++++++------ src/security/audit-exec-surface.test.ts | 1 - src/security/audit-extra.sync.test.ts | 1 - 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index abad770a277..637102e1abe 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -181,6 +181,16 @@ function resolveMediaWithDefaults( }); } +function requireResolvedMedia( + result: Awaited>, + label: string, +) { + if (!result) { + throw new Error(`expected ${label} media result`); + } + return result; +} + async function expectTransientGetFileRetrySuccess() { const getFile = setupTransientGetFileRetry(); const promise = resolveMediaWithDefaults(makeCtx("voice", getFile)); @@ -628,7 +638,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "my-song.mp3", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "audio filename"); }); it("passes video.file_name to saveMediaBuffer", async () => { @@ -653,7 +663,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "presentation.mp4", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "video filename"); }); it("falls back to fetched.fileName when telegram file_name is absent", async () => { @@ -670,7 +680,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "file_42.pdf", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "fetched filename fallback"); }); it("falls back to filePath when neither telegram nor fetched fileName is available", async () => { @@ -687,7 +697,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "documents/file_42.pdf", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "file path fallback"); }); it("allows a configured custom apiRoot host while keeping the hostname allowlist", async () => { @@ -708,7 +718,7 @@ describe("resolveMedia original filename preservation", () => { }, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot allowlist"); }); it("opts into private-network Telegram media downloads only when explicitly configured", async () => { @@ -727,7 +737,7 @@ describe("resolveMedia original filename preservation", () => { }, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "private network opt-in"); }); it("constructs correct download URL with custom apiRoot for documents", async () => { @@ -744,7 +754,7 @@ describe("resolveMedia original filename preservation", () => { url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot document URL"); }); it("constructs correct download URL with custom apiRoot for stickers", async () => { @@ -769,6 +779,6 @@ describe("resolveMedia original filename preservation", () => { url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot sticker URL"); }); }); diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index 9af9e5f7b2e..a47ac7a95a2 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -21,7 +21,6 @@ function requireFinding( findings: ReturnType, ) { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding`); } diff --git a/src/security/audit-extra.sync.test.ts b/src/security/audit-extra.sync.test.ts index 11b2c3e1c28..6c5f0b2a980 100644 --- a/src/security/audit-extra.sync.test.ts +++ b/src/security/audit-extra.sync.test.ts @@ -12,7 +12,6 @@ vi.mock("../plugins/web-search-credential-presence.js", () => ({ function requireFirstFinding(findings: readonly T[], label: string): T { const [finding] = findings; - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${label} finding`); } From bb8a16f37c14f89243bc228eded2f1cc8e08bb95 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:09:03 +0100 Subject: [PATCH 705/806] test: tighten workspace skill assertion --- src/security/audit-workspace-skill-escape.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/security/audit-workspace-skill-escape.test.ts b/src/security/audit-workspace-skill-escape.test.ts index 28c319dd33e..f708e798670 100644 --- a/src/security/audit-workspace-skill-escape.test.ts +++ b/src/security/audit-workspace-skill-escape.test.ts @@ -47,11 +47,9 @@ describe("security audit workspace skill path escape findings", () => { const findings = await collectWorkspaceSkillSymlinkEscapeFindings({ cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig, }); - const finding = findings.find( - (entry) => entry.checkId === "skills.workspace.symlink_escape", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(outsideSkillPath); + const finding = requireFinding(findings, "skills.workspace.symlink_escape"); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain(outsideSkillPath); })() : Promise.resolve(), (async () => { From 39405ebe148670d766f70b804b956ebc39a7e4e7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:09:59 +0100 Subject: [PATCH 706/806] test: tighten small model risk assertions --- src/security/audit-small-model-risk.test.ts | 64 +++++++++++++-------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/src/security/audit-small-model-risk.test.ts b/src/security/audit-small-model-risk.test.ts index 7d788b96b9d..fcfdfe360e2 100644 --- a/src/security/audit-small-model-risk.test.ts +++ b/src/security/audit-small-model-risk.test.ts @@ -2,6 +2,18 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { collectSmallModelRiskFindings } from "./audit-extra.summary.js"; +function requireFirstSmallModelFinding( + findings: ReturnType, + label: string, +) { + const [finding] = findings; + expect(finding, label).toBeDefined(); + if (!finding) { + throw new Error(`Expected small-model risk finding for ${label}`); + } + return finding; +} + describe("security audit small-model risk findings", () => { it("scores small-model risk by tool/sandbox exposure", () => { const cases: Array<{ @@ -35,37 +47,43 @@ describe("security audit small-model risk findings", () => { ]; for (const testCase of cases) { - const [finding] = collectSmallModelRiskFindings({ - cfg: testCase.cfg, - env: process.env, - }); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + const finding = requireFirstSmallModelFinding( + collectSmallModelRiskFindings({ + cfg: testCase.cfg, + env: process.env, + }), + testCase.name, + ); + expect(finding.severity, testCase.name).toBe(testCase.expectedSeverity); for (const snippet of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet); + expect(finding.detail, `${testCase.name}:${snippet}`).toContain(snippet); } } }); it("resolves configured aliases before parameter-size classification", () => { - const [finding] = collectSmallModelRiskFindings({ - cfg: { - agents: { - defaults: { - model: { primary: "tiny" }, - models: { - "ollama/mistral-8b": { alias: "tiny" }, + const finding = requireFirstSmallModelFinding( + collectSmallModelRiskFindings({ + cfg: { + agents: { + defaults: { + model: { primary: "tiny" }, + models: { + "ollama/mistral-8b": { alias: "tiny" }, + }, }, }, - }, - tools: { web: { search: { enabled: true }, fetch: { enabled: true } } }, - browser: { enabled: true }, - } satisfies OpenClawConfig, - env: {}, - }); + tools: { web: { search: { enabled: true }, fetch: { enabled: true } } }, + browser: { enabled: true }, + } satisfies OpenClawConfig, + env: {}, + }), + "configured alias", + ); - expect(finding?.checkId).toBe("models.small_params"); - expect(finding?.detail).toContain("ollama/mistral-8b"); - expect(finding?.detail).toContain("@ agents.defaults.model.primary"); - expect(finding?.detail).not.toContain("- tiny"); + expect(finding.checkId).toBe("models.small_params"); + expect(finding.detail).toContain("ollama/mistral-8b"); + expect(finding.detail).toContain("@ agents.defaults.model.primary"); + expect(finding.detail).not.toContain("- tiny"); }); }); From 0cb6382da152cd8e6c62051db4e66fb23310574e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:10:22 +0100 Subject: [PATCH 707/806] test: tighten probe failure assertion --- src/security/audit-probe-failure.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/security/audit-probe-failure.test.ts b/src/security/audit-probe-failure.test.ts index 9eb6dba9e62..cf2e4bb683e 100644 --- a/src/security/audit-probe-failure.test.ts +++ b/src/security/audit-probe-failure.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; +function requireProbeFailure(findings: ReturnType) { + const finding = findings.find((entry) => entry.checkId === "gateway.probe_failed"); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error("Expected gateway probe failure finding"); + } + return finding; +} + describe("security audit deep probe failure", () => { it("adds probe_failed warnings for deep probe failure modes", () => { const cases: Array<{ @@ -46,12 +55,8 @@ describe("security audit deep probe failure", () => { for (const testCase of cases) { const findings = collectDeepProbeFindings({ deep: testCase.deep }); - expect( - findings.some((finding) => finding.checkId === "gateway.probe_failed"), - testCase.name, - ).toBe(true); - const probeFailure = findings.find((finding) => finding.checkId === "gateway.probe_failed"); - expect(probeFailure?.detail, testCase.name).toContain(testCase.expectedError); + const probeFailure = requireProbeFailure(findings); + expect(probeFailure.detail, testCase.name).toContain(testCase.expectedError); } }); }); From 4a3b51655706c5f257a42cddea9181af17409ea8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:10:55 +0100 Subject: [PATCH 708/806] test: tighten account metadata assertion --- .../audit-channel-account-metadata.test.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/security/audit-channel-account-metadata.test.ts b/src/security/audit-channel-account-metadata.test.ts index 65835722586..7d9026763c8 100644 --- a/src/security/audit-channel-account-metadata.test.ts +++ b/src/security/audit-channel-account-metadata.test.ts @@ -37,6 +37,21 @@ function stubChannelPlugin(): ChannelPlugin { }; } +function requireDangerousMatchingFinding( + findings: Awaited>, +) { + const finding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", + ); + expect(finding).toMatchObject({ + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + }); + if (!finding) { + throw new Error("Expected dangerous name matching finding"); + } + return finding; +} + describe("security audit channel account metadata", () => { it("does not treat prototype properties as explicit account config paths", async () => { const cfg: OpenClawConfig = { @@ -55,12 +70,7 @@ describe("security audit channel account metadata", () => { plugins: [stubChannelPlugin()], }); - const dangerousMatchingFinding = findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", - ); - expect(dangerousMatchingFinding).toMatchObject({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - }); - expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)"); + const dangerousMatchingFinding = requireDangerousMatchingFinding(findings); + expect(dangerousMatchingFinding.title).not.toContain("(account: toString)"); }); }); From a0ef60eb4cc1d6a1d4c1063d660d6dc70ce147ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:11:04 +0100 Subject: [PATCH 709/806] test: clear weak assertion scan --- ...o-reply.web-auto-reply.connection-and-logging.e2e.test.ts | 5 ++++- src/security/audit-channel-readonly-resolution.test.ts | 1 - src/security/audit-exec-safe-bins.test.ts | 1 - src/security/audit-gateway-auth-selection.test.ts | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index bf19703872d..667ffab64ae 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -246,7 +246,10 @@ describe("web auto-reply connection", () => { await vi.waitFor( () => { expect(scripted.getListenerCount()).toBe(1); - expect(getActiveWebListener(accountId)).not.toBeNull(); + const activeListener = getActiveWebListener(accountId); + if (!activeListener) { + throw new Error("expected active WhatsApp web listener"); + } }, { timeout: 250, interval: 2 }, ); diff --git a/src/security/audit-channel-readonly-resolution.test.ts b/src/security/audit-channel-readonly-resolution.test.ts index 872eda225d9..7bb19383db5 100644 --- a/src/security/audit-channel-readonly-resolution.test.ts +++ b/src/security/audit-channel-readonly-resolution.test.ts @@ -37,7 +37,6 @@ function requireReadOnlyResolutionFinding( const finding = findings.find( (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", ); - expect(finding).toBeDefined(); if (!finding) { throw new Error("Expected Zalo read-only resolution warning"); } diff --git a/src/security/audit-exec-safe-bins.test.ts b/src/security/audit-exec-safe-bins.test.ts index 794ea36fcc9..a3b87010f18 100644 --- a/src/security/audit-exec-safe-bins.test.ts +++ b/src/security/audit-exec-safe-bins.test.ts @@ -17,7 +17,6 @@ function requireFinding( findings: ReturnType, ) { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding`); } diff --git a/src/security/audit-gateway-auth-selection.test.ts b/src/security/audit-gateway-auth-selection.test.ts index 9767e60516f..2a0c4b59f56 100644 --- a/src/security/audit-gateway-auth-selection.test.ts +++ b/src/security/audit-gateway-auth-selection.test.ts @@ -7,7 +7,6 @@ function requireProbeAuthWarning(findings: ReturnType finding.checkId === "gateway.probe_auth_secretref_unavailable", ); - expect(warning).toBeDefined(); if (!warning) { throw new Error("Expected gateway probe auth SecretRef warning"); } From ab16feb5bfabce2f0aebff51125add46a09fb99a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:11:30 +0100 Subject: [PATCH 710/806] test: tighten gateway exposure assertions --- src/security/audit-gateway-exposure.test.ts | 31 +++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index 933c7b2bb66..b5f3c7d33ae 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -10,6 +10,20 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireDangerousFlagsFinding( + findings: ReturnType, + label: string, +) { + const finding = findings.find((entry) => entry.checkId === "config.insecure_or_dangerous_flags"); + expect(finding, label).toMatchObject({ + checkId: "config.insecure_or_dangerous_flags", + }); + if (!finding) { + throw new Error(`Expected dangerous flags finding for ${label}`); + } + return finding; +} + describe("security audit gateway exposure findings", () => { it("warns on insecure or dangerous flags", () => { const cases = [ @@ -69,15 +83,10 @@ describe("security audit gateway exposure findings", () => { expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); } - const finding = findings.find( - (entry) => entry.checkId === "config.insecure_or_dangerous_flags", - ); - expect(finding, testCase.name).toMatchObject({ - checkId: "config.insecure_or_dangerous_flags", - }); - expect(finding?.severity, testCase.name).toBe("warn"); + const finding = requireDangerousFlagsFinding(findings, testCase.name); + expect(finding.severity, testCase.name).toBe("warn"); for (const snippet of testCase.expectedDangerousDetails) { - expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet); + expect(finding.detail, `${testCase.name}:${snippet}`).toContain(snippet); } } }); @@ -150,10 +159,8 @@ describe("security audit gateway exposure findings", () => { expect( findings.some((finding) => finding.checkId === "gateway.control_ui.allowed_origins_required"), ).toBe(false); - const flags = findings.find( - (finding) => finding.checkId === "config.insecure_or_dangerous_flags", - ); - expect(flags?.detail ?? "").toContain( + const flags = requireDangerousFlagsFinding(findings, "host header origin fallback"); + expect(flags.detail).toContain( "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true", ); }); From ea1220016b7d1da1f7e6770cde5e64da97f66091 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:11:57 +0100 Subject: [PATCH 711/806] test: tighten trust model assertion --- src/security/audit-trust-model.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/security/audit-trust-model.test.ts b/src/security/audit-trust-model.test.ts index 84f8ef66df3..9eef6ba9bca 100644 --- a/src/security/audit-trust-model.test.ts +++ b/src/security/audit-trust-model.test.ts @@ -9,6 +9,17 @@ function audit(cfg: OpenClawConfig) { return [...collectExposureMatrixFindings(cfg), ...collectLikelyMultiUserSetupFindings(cfg)]; } +function requireMultiUserHeuristicFinding(findings: ReturnType) { + const finding = findings.find( + (entry) => entry.checkId === "security.trust_model.multi_user_heuristic", + ); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error("Expected multi-user heuristic finding"); + } + return finding; +} + describe("security audit trust model findings", () => { it("evaluates trust-model exposure findings", () => { const cases = [ @@ -108,15 +119,13 @@ describe("security audit trust model findings", () => { } satisfies OpenClawConfig, assert: () => { const findings = audit(cases[4].cfg); - const finding = findings.find( - (entry) => entry.checkId === "security.trust_model.multi_user_heuristic", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain( + const finding = requireMultiUserHeuristicFinding(findings); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain( 'channels.discord.groupPolicy="allowlist" with configured group targets', ); - expect(finding?.detail).toContain("personal-assistant"); - expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); + expect(finding.detail).toContain("personal-assistant"); + expect(finding.remediation).toContain('agents.defaults.sandbox.mode="all"'); }, }, { From dd857616381a600730d2a2594727728e75f3e719 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:12:35 +0100 Subject: [PATCH 712/806] test: tighten gateway http auth assertions --- src/security/audit-gateway-http-auth.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/security/audit-gateway-http-auth.test.ts b/src/security/audit-gateway-http-auth.test.ts index 9b502cd4e54..02e6724494c 100644 --- a/src/security/audit-gateway-http-auth.test.ts +++ b/src/security/audit-gateway-http-auth.test.ts @@ -5,6 +5,15 @@ import { collectGatewayHttpSessionKeyOverrideFindings, } from "./audit-extra.sync.js"; +function requireFinding(findings: Array<{ checkId: string; detail: string }>, checkId: string) { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit gateway HTTP auth findings", () => { it.each([ { @@ -75,9 +84,9 @@ describe("security audit gateway HTTP auth findings", () => { if (expectedFinding) { expect(findings).toEqual(expect.arrayContaining([expect.objectContaining(expectedFinding)])); if (detailIncludes) { - const finding = findings.find((entry) => entry.checkId === expectedFinding.checkId); + const finding = requireFinding(findings, expectedFinding.checkId); for (const text of detailIncludes) { - expect(finding?.detail, `${expectedFinding.checkId}:${text}`).toContain(text); + expect(finding.detail, `${expectedFinding.checkId}:${text}`).toContain(text); } } } From f2c21e42780758f733522310fb3b7551dcd433d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:12:52 +0100 Subject: [PATCH 713/806] test: tighten security audit helpers --- src/security/audit-probe-failure.test.ts | 1 - src/security/audit-small-model-risk.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/security/audit-probe-failure.test.ts b/src/security/audit-probe-failure.test.ts index cf2e4bb683e..4fd3e735077 100644 --- a/src/security/audit-probe-failure.test.ts +++ b/src/security/audit-probe-failure.test.ts @@ -3,7 +3,6 @@ import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; function requireProbeFailure(findings: ReturnType) { const finding = findings.find((entry) => entry.checkId === "gateway.probe_failed"); - expect(finding).toBeDefined(); if (!finding) { throw new Error("Expected gateway probe failure finding"); } diff --git a/src/security/audit-small-model-risk.test.ts b/src/security/audit-small-model-risk.test.ts index fcfdfe360e2..f9bf17f0378 100644 --- a/src/security/audit-small-model-risk.test.ts +++ b/src/security/audit-small-model-risk.test.ts @@ -7,7 +7,6 @@ function requireFirstSmallModelFinding( label: string, ) { const [finding] = findings; - expect(finding, label).toBeDefined(); if (!finding) { throw new Error(`Expected small-model risk finding for ${label}`); } From 3a66f982f5e49d68158e7aab2688b82a326b91c5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:13:02 +0100 Subject: [PATCH 714/806] test: tighten sandbox browser assertion --- src/security/audit-sandbox-browser.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index 75e621b9654..fa9b48ca742 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -14,6 +14,18 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireFinding( + checkId: "sandbox.browser_container.hash_epoch_stale", + findings: Array<{ checkId: string; severity: string; detail: string }>, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit sandbox browser findings", () => { it("warns when sandbox browser containers have missing or stale hash labels", async () => { const findings = await collectSandboxBrowserHashLabelFindings({ @@ -49,10 +61,8 @@ describe("security audit sandbox browser findings", () => { expect(hasFinding("sandbox.browser_container.hash_label_missing", "warn", findings)).toBe(true); expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(true); - const staleEpoch = findings.find( - (finding) => finding.checkId === "sandbox.browser_container.hash_epoch_stale", - ); - expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); + const staleEpoch = requireFinding("sandbox.browser_container.hash_epoch_stale", findings); + expect(staleEpoch.detail).toContain("openclaw-sbx-browser-old"); }); it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => { From 86f393062d420b770156ab54f0e10be82452ed7d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:13:43 +0100 Subject: [PATCH 715/806] test: tighten async audit assertion --- src/security/audit-extra.async.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index 30d8db58066..b855a18e059 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -205,8 +205,12 @@ description: test skill const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); expect(scanSpy.mock.calls.map(([dirPath]) => path.basename(dirPath))).toEqual(["demo"]); - const codeSafetyFinding = findings.find((f) => f.checkId === "plugins.code_safety"); - expect(codeSafetyFinding?.title).toContain('Plugin "demo"'); + const codeSafetyFinding = requireFinding( + findings, + (finding) => finding.checkId === "plugins.code_safety", + "plugin code-safety", + ); + expect(codeSafetyFinding.title).toContain('Plugin "demo"'); expect(findings.map((f) => f.title).join("\n")).not.toContain(".openclaw-install-backups"); } finally { scanSpy.mockRestore(); From 2ad93720a91fc995647f662f29f3860b36227dcd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:14:20 +0100 Subject: [PATCH 716/806] test: tighten plugin trust assertions --- src/security/audit-plugins-trust.test.ts | 35 +++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index f18c3d41421..f00932a1b5f 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -165,6 +165,18 @@ describe("security audit install metadata findings", () => { return await collectPluginsTrustFindingsForTest({ cfg, stateDir }); }; + const requireInstallFinding = ( + findings: Awaited>, + checkId: string, + ) => { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; + }; + const writePluginIndexInstallRecords = async ( stateDir: string, records: Record, @@ -362,12 +374,10 @@ describe("security audit install metadata findings", () => { }, reportedStateDir, ); - const phantomFinding = reportedFindings.find( - (finding) => finding.checkId === "plugins.allow_phantom_entries", - ); - expect(phantomFinding?.severity).toBe("warn"); - expect(phantomFinding?.detail).toContain("ghost-plugin-xyz"); - expect(phantomFinding?.detail).not.toContain("installed-plugin"); + const phantomFinding = requireInstallFinding(reportedFindings, "plugins.allow_phantom_entries"); + expect(phantomFinding.severity).toBe("warn"); + expect(phantomFinding.detail).toContain("ghost-plugin-xyz"); + expect(phantomFinding.detail).not.toContain("installed-plugin"); }); it("ignores install backup and debris dirs when auditing installed plugin roots", async () => { @@ -387,15 +397,14 @@ describe("security audit install metadata findings", () => { const findings = await runInstallMetadataAudit({}, stateDir); - const noAllowlist = findings.find( - (finding) => finding.checkId === "plugins.extensions_no_allowlist", - ); - expect(noAllowlist?.detail).toContain("Found 1 extension(s)"); + const noAllowlist = requireInstallFinding(findings, "plugins.extensions_no_allowlist"); + expect(noAllowlist.detail).toContain("Found 1 extension(s)"); - const toolsReachable = findings.find( - (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy", + const toolsReachable = requireInstallFinding( + findings, + "plugins.tools_reachable_permissive_policy", ); - expect(toolsReachable?.detail).toContain("Enabled extension plugins: live-plugin."); + expect(toolsReachable.detail).toContain("Enabled extension plugins: live-plugin."); expect(findings.map((finding) => finding.detail).join("\n")).not.toContain( ".openclaw-install-backups", ); From ea65056e21e9eb1f10a2c2491f39a2739d548b52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:14:33 +0100 Subject: [PATCH 717/806] test: tighten security trust helpers --- src/security/audit-gateway-http-auth.test.ts | 1 - src/security/audit-trust-model.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/security/audit-gateway-http-auth.test.ts b/src/security/audit-gateway-http-auth.test.ts index 02e6724494c..3388743fcdb 100644 --- a/src/security/audit-gateway-http-auth.test.ts +++ b/src/security/audit-gateway-http-auth.test.ts @@ -7,7 +7,6 @@ import { function requireFinding(findings: Array<{ checkId: string; detail: string }>, checkId: string) { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding`); } diff --git a/src/security/audit-trust-model.test.ts b/src/security/audit-trust-model.test.ts index 9eef6ba9bca..afd33d76899 100644 --- a/src/security/audit-trust-model.test.ts +++ b/src/security/audit-trust-model.test.ts @@ -13,7 +13,6 @@ function requireMultiUserHeuristicFinding(findings: ReturnType) { const finding = findings.find( (entry) => entry.checkId === "security.trust_model.multi_user_heuristic", ); - expect(finding).toBeDefined(); if (!finding) { throw new Error("Expected multi-user heuristic finding"); } From 8d9d0038a9675b26d94d8846c811856ee7465a29 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:14:54 +0100 Subject: [PATCH 718/806] test: tighten node command assertions --- .../audit-node-command-findings.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/security/audit-node-command-findings.test.ts b/src/security/audit-node-command-findings.test.ts index 04fc4ab2b73..2197968ed0b 100644 --- a/src/security/audit-node-command-findings.test.ts +++ b/src/security/audit-node-command-findings.test.ts @@ -19,6 +19,21 @@ function expectDetailText(params: { } } +function requireFinding( + findings: ReturnType< + typeof collectNodeDenyCommandPatternFindings | typeof collectNodeDangerousAllowCommandFindings + >, + checkId: string, + label: string, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + expect(finding, label).toBeDefined(); + if (!finding) { + throw new Error(`Expected ${checkId} finding for ${label}`); + } + return finding; +} + describe("security audit node command findings", () => { it("evaluates ineffective gateway.nodes.denyCommands entries", () => { const cases = [ @@ -72,12 +87,14 @@ describe("security audit node command findings", () => { for (const testCase of cases) { const findings = collectNodeDenyCommandPatternFindings(testCase.cfg); - const finding = findings.find( - (entry) => entry.checkId === "gateway.nodes.deny_commands_ineffective", + const finding = requireFinding( + findings, + "gateway.nodes.deny_commands_ineffective", + testCase.name, ); - expect(finding?.severity, testCase.name).toBe("warn"); + expect(finding.severity, testCase.name).toBe("warn"); expectDetailText({ - detail: finding?.detail, + detail: finding.detail, name: testCase.name, includes: testCase.detailIncludes, excludes: "detailExcludes" in testCase ? testCase.detailExcludes : [], @@ -147,9 +164,14 @@ describe("security audit node command findings", () => { expect(finding, testCase.name).toBeUndefined(); continue; } - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + const dangerousFinding = requireFinding( + findings, + "gateway.nodes.allow_commands_dangerous", + testCase.name, + ); + expect(dangerousFinding.severity, testCase.name).toBe(testCase.expectedSeverity); expectDetailText({ - detail: finding?.detail, + detail: dangerousFinding.detail, name: testCase.name, includes: ["camera.snap", "screen.record"], }); From 61afdefe0c76c3a6d45e3a6209e59c35cf784096 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:15:31 +0100 Subject: [PATCH 719/806] test: tighten windows acl assertions --- src/security/windows-acl.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index 045c7f365e7..ed6c7109a24 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -85,6 +85,16 @@ function expectInspectSuccess( expect(result.entries).toHaveLength(expectedEntries); } +function expectIcaclsResetCommand( + result: ReturnType, +): NonNullable> { + expect(result).toBeDefined(); + if (!result) { + throw new Error("Expected icacls reset command"); + } + return result; +} + function expectSummaryCounts( entries: readonly WindowsAclEntry[], expected: { trusted?: number; untrustedWorld?: number; untrustedGroup?: number }, @@ -769,8 +779,9 @@ Successfully processed 1 files`; isDir: false, env: { SystemRoot: "D:\\Windows", USERNAME: "TestUser" }, }); + const command = expectIcaclsResetCommand(result); - expect(result?.command).toBe("D:\\Windows\\System32\\icacls.exe"); + expect(command.command).toBe("D:\\Windows\\System32\\icacls.exe"); }); it("returns command with system username when env is empty (falls back to os.userInfo)", () => { @@ -797,7 +808,8 @@ Successfully processed 1 files`; isDir: false, env, }); - expect(result?.display).toBe(expected); + const command = expectIcaclsResetCommand(result); + expect(command.display).toBe(expected); }); it("world SIDs in USERSID env are not added to trusted set", () => { From 78bbbdec4cb0990790e3da27e6750af5c20dbd45 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:16:07 +0100 Subject: [PATCH 720/806] test: tighten security fix account assertion --- src/security/fix.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts index eb33512c097..4250da21d0a 100644 --- a/src/security/fix.test.ts +++ b/src/security/fix.test.ts @@ -122,7 +122,12 @@ describe("security fix", () => { ) => { const whatsapp = channels.whatsapp; const accounts = whatsapp.accounts as Record>; - expect(accounts[accountId]?.groupPolicy).toBe(expectedPolicy); + const account = accounts[accountId]; + expect(account).toBeDefined(); + if (!account) { + throw new Error(`Expected WhatsApp account ${accountId}`); + } + expect(account.groupPolicy).toBe(expectedPolicy); return accounts; }; From 68f9710f47052e17048eb5a556548df5ec74bed6 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Sat, 9 May 2026 06:16:17 +1000 Subject: [PATCH 721/806] Relay ACP exec approval permissions * Relay ACP exec approval permissions * fix: relay ACP exec approvals before tool completion * fix: guard ACP approval relay retries * test: fix ACP permission relay mock typing * test: satisfy ACP permission relay lint --------- Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/acp.md | 3 + src/acp/permission-relay.test.ts | 148 +++++++ src/acp/permission-relay.ts | 166 +++++++ src/acp/server.startup.test.ts | 16 + src/acp/server.ts | 7 +- src/acp/translator.lifecycle.test.ts | 22 + src/acp/translator.permission-relay.test.ts | 453 ++++++++++++++++++++ src/acp/translator.test-helpers.ts | 11 +- src/acp/translator.ts | 244 ++++++++++- 10 files changed, 1068 insertions(+), 3 deletions(-) create mode 100644 src/acp/permission-relay.test.ts create mode 100644 src/acp/permission-relay.ts create mode 100644 src/acp/translator.permission-relay.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f931c532149..0894aca7fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash. - Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd. +- ACP bridge: relay Gateway exec approval prompts from active ACP turns to the ACP client's `session/request_permission` handler before resolving the Gateway approval. Thanks @amknight. - Codex/plugins: enable migrated source-installed `openai-curated` Codex plugins in the same Codex harness thread with explicit `codexPlugins` config, cached app readiness, and fail-closed destructive-action policy. Thanks @kevinslin. - Codex/plugins: enforce native plugin destructive-action policy with Codex app-level `destructive_enabled` config instead of OpenClaw-maintained per-tool deny lists, leave plugin app `open_world_enabled` on by default, and invalidate existing plugin app thread bindings so old generated app config is rebuilt. Thanks @kevinslin. - PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 09f1ad3006d..70044eaa411 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -49,6 +49,7 @@ Quick rule: | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | | Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | +| Exec approvals | Partial | Gateway exec approval prompts during active ACP prompt turns are relayed to the ACP client with `session/request_permission`. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -76,6 +77,8 @@ Quick rule: - Tool follow-along data is best-effort. The bridge can surface file paths that appear in known tool args/results, but it does not yet emit ACP terminals or structured file diffs. +- Exec approval relay is scoped to the active ACP prompt turn; approvals from + other Gateway sessions are ignored. ## Usage diff --git a/src/acp/permission-relay.test.ts b/src/acp/permission-relay.test.ts new file mode 100644 index 00000000000..17907feff61 --- /dev/null +++ b/src/acp/permission-relay.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + buildAcpPermissionOptions, + buildAcpPermissionRequest, + normalizeGatewayExecApprovalDecisions, + parseGatewayExecApprovalEventData, + parseGatewayExecApprovalRequestEventPayload, + resolveGatewayDecisionFromPermissionOutcome, +} from "./permission-relay.js"; + +describe("ACP permission relay helpers", () => { + it("maps Gateway exec approval decisions to ACP permission options", () => { + expect(buildAcpPermissionOptions(["allow-once", "allow-always", "deny"])).toEqual([ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + { + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }, + { + optionId: "deny", + name: "Deny", + kind: "reject_once", + }, + ]); + }); + + it("filters unknown decisions and falls back to allow-once plus deny", () => { + expect(normalizeGatewayExecApprovalDecisions(["allow-once", "bogus", "deny"])).toEqual([ + "allow-once", + "deny", + ]); + expect(normalizeGatewayExecApprovalDecisions(["bogus"])).toEqual(["allow-once", "deny"]); + expect(normalizeGatewayExecApprovalDecisions(undefined)).toEqual(["allow-once", "deny"]); + }); + + it("builds a request_permission payload from Gateway approval data", () => { + const event = parseGatewayExecApprovalEventData({ + phase: "requested", + kind: "exec", + status: "pending", + approvalId: "approval-1", + title: "Command approval requested", + toolCallId: "tool-1", + command: "echo stale", + host: "gateway", + }); + if (!event) { + throw new Error("approval event did not parse"); + } + + expect( + buildAcpPermissionRequest({ + sessionId: "session-1", + event, + details: { + allowedDecisions: ["allow-once", "allow-always", "deny"], + commandText: "echo ok", + host: "gateway", + }, + }), + ).toEqual({ + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Command approval requested", + kind: "execute", + status: "pending", + rawInput: { + name: "exec", + approvalId: "approval-1", + command: "echo ok", + host: "gateway", + }, + _meta: { + toolName: "exec", + approvalId: "approval-1", + }, + }, + options: [ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + { + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }, + { + optionId: "deny", + name: "Deny", + kind: "reject_once", + }, + ], + }); + }); + + it("parses Gateway exec.approval.requested payloads", () => { + expect( + parseGatewayExecApprovalRequestEventPayload({ + id: "approval-raw", + request: { + command: "echo raw", + host: "gateway", + sessionKey: "agent:main:main", + }, + }), + ).toEqual({ + approvalId: "approval-raw", + command: "echo raw", + host: "gateway", + }); + + expect(parseGatewayExecApprovalRequestEventPayload({ id: "approval-raw" })).toBeNull(); + expect( + parseGatewayExecApprovalRequestEventPayload({ + id: "approval-raw", + request: { command: "" }, + }), + ).toMatchObject({ approvalId: "approval-raw" }); + }); + + it("maps selected ACP outcomes back to Gateway decisions", () => { + const options = buildAcpPermissionOptions(["allow-once", "allow-always", "deny"]); + + expect( + resolveGatewayDecisionFromPermissionOutcome( + { outcome: { outcome: "selected", optionId: "allow-always" } }, + options, + ), + ).toBe("allow-always"); + expect( + resolveGatewayDecisionFromPermissionOutcome( + { outcome: { outcome: "selected", optionId: "missing" } }, + options, + ), + ).toBeUndefined(); + expect( + resolveGatewayDecisionFromPermissionOutcome({ outcome: { outcome: "cancelled" } }, options), + ).toBeUndefined(); + }); +}); diff --git a/src/acp/permission-relay.ts b/src/acp/permission-relay.ts new file mode 100644 index 00000000000..bf0873f4ab8 --- /dev/null +++ b/src/acp/permission-relay.ts @@ -0,0 +1,166 @@ +import type { + PermissionOption, + RequestPermissionRequest, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; + +export type GatewayExecApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export type GatewayExecApprovalEvent = { + approvalId: string; + command?: string; + host?: string; + title?: string; + toolCallId?: string; +}; + +export type GatewayExecApprovalDetails = { + allowedDecisions?: unknown; + commandPreview?: unknown; + commandText?: unknown; + host?: unknown; +}; + +const FALLBACK_EXEC_APPROVAL_DECISIONS = ["allow-once", "deny"] as const; + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeGatewayExecApprovalDecision( + value: unknown, +): GatewayExecApprovalDecision | undefined { + if (value === "allow-once" || value === "allow-always" || value === "deny") { + return value; + } + return undefined; +} + +export function normalizeGatewayExecApprovalDecisions( + value: unknown, +): GatewayExecApprovalDecision[] { + const normalized = Array.isArray(value) + ? value + .map(normalizeGatewayExecApprovalDecision) + .filter((decision): decision is GatewayExecApprovalDecision => Boolean(decision)) + : []; + return normalized.length > 0 ? normalized : [...FALLBACK_EXEC_APPROVAL_DECISIONS]; +} + +export function buildAcpPermissionOptions( + decisions: readonly GatewayExecApprovalDecision[], +): PermissionOption[] { + const unique = new Set(decisions); + const options: PermissionOption[] = []; + if (unique.has("allow-once")) { + options.push({ + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }); + } + if (unique.has("allow-always")) { + options.push({ + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }); + } + if (unique.has("deny")) { + options.push({ + optionId: "deny", + name: "Deny", + kind: "reject_once", + }); + } + return options.length > 0 ? options : buildAcpPermissionOptions(FALLBACK_EXEC_APPROVAL_DECISIONS); +} + +export function parseGatewayExecApprovalEventData( + data: Record, +): GatewayExecApprovalEvent | null { + if (data.phase !== "requested" || data.kind !== "exec" || data.status !== "pending") { + return null; + } + const approvalId = readNonEmptyString(data.approvalId); + if (!approvalId) { + return null; + } + return { + approvalId, + command: readNonEmptyString(data.command), + host: readNonEmptyString(data.host), + title: readNonEmptyString(data.title), + toolCallId: readNonEmptyString(data.toolCallId), + }; +} + +export function parseGatewayExecApprovalRequestEventPayload( + payload: Record, +): GatewayExecApprovalEvent | null { + const approvalId = readNonEmptyString(payload.id); + const request = payload.request; + if (!approvalId || !request || typeof request !== "object" || Array.isArray(request)) { + return null; + } + const requestRecord = request as Record; + return { + approvalId, + command: + readNonEmptyString(requestRecord.command) ?? readNonEmptyString(requestRecord.commandPreview), + host: readNonEmptyString(requestRecord.host), + }; +} + +export function buildAcpPermissionRequest(params: { + sessionId: string; + event: GatewayExecApprovalEvent; + details?: GatewayExecApprovalDetails | null; +}): RequestPermissionRequest { + const command = + readNonEmptyString(params.details?.commandText) ?? + readNonEmptyString(params.details?.commandPreview) ?? + params.event.command; + const host = readNonEmptyString(params.details?.host) ?? params.event.host; + const decisions = normalizeGatewayExecApprovalDecisions(params.details?.allowedDecisions); + const rawInput: Record = { + name: "exec", + approvalId: params.event.approvalId, + }; + if (command) { + rawInput.command = command; + } + if (host) { + rawInput.host = host; + } + + return { + sessionId: params.sessionId, + toolCall: { + // Raw approval events can arrive before Gateway emits a tool call id; the + // approval id remains the stable correlation key for those early prompts. + toolCallId: params.event.toolCallId ?? `exec:${params.event.approvalId}`, + title: params.event.title ?? "Command approval requested", + kind: "execute", + status: "pending", + rawInput, + _meta: { + toolName: "exec", + approvalId: params.event.approvalId, + }, + }, + options: buildAcpPermissionOptions(decisions), + }; +} + +export function resolveGatewayDecisionFromPermissionOutcome( + response: RequestPermissionResponse | undefined, + options: readonly PermissionOption[], +): GatewayExecApprovalDecision | undefined { + const outcome = response?.outcome; + if (!outcome || outcome.outcome !== "selected") { + return undefined; + } + const selected = options.find((option) => option.optionId === outcome.optionId); + return normalizeGatewayExecApprovalDecision(selected?.optionId); +} diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 7c10f761c3d..1e0bfe25d24 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -17,6 +17,7 @@ type ResolveGatewayClientBootstrap = (params: unknown) => Promise<{ }>; type GatewayClientOptions = GatewayClientCallbacks & GatewayClientAuth & { + caps?: string[]; url?: string; }; @@ -236,6 +237,21 @@ describe("serveAcpGateway startup", () => { } }); + it("subscribes the Gateway client to run-scoped tool events", async () => { + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); + + try { + const servePromise = serveAcpGateway({}); + await emitHelloAndWaitForAgentSideConnection(); + + expect(mockState.gatewayOptions[0]?.caps).toEqual(["tool-events"]); + + await stopServeWithSigint(signalHandlers, servePromise); + } finally { + onceSpy.mockRestore(); + } + }); + it("routes logs to stderr before loading gateway config", async () => { const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); diff --git a/src/acp/server.ts b/src/acp/server.ts index f6759e080ad..676f2bc9f83 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -6,7 +6,11 @@ import { getRuntimeConfig } from "../config/config.js"; import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { GatewayClient } from "../gateway/client.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../gateway/protocol/client-info.js"; import { isMainModule } from "../infra/is-main.js"; import { routeLogsToStderr } from "../logging/console.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -65,6 +69,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { void agent?.handleGatewayEvent(evt); }, diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 1b4b3bb2745..af2c2346851 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -153,6 +153,28 @@ describe("acp translator stable lifecycle handlers", () => { sessionStore.clearAllSessionsForTest(); }); + it("captures ACP client capabilities during initialize", async () => { + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway()); + + expect(agent.supportsClientReadTextFile()).toBe(false); + expect(agent.supportsClientWriteTextFile()).toBe(false); + expect(agent.supportsClientTerminal()).toBe(false); + + await agent.initialize({ + ...createInitializeRequest(), + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: false }, + terminal: true, + }, + clientInfo: { name: "test-client", version: "1.2.3" }, + } as InitializeRequest); + + expect(agent.supportsClientReadTextFile()).toBe(true); + expect(agent.supportsClientWriteTextFile()).toBe(false); + expect(agent.supportsClientTerminal()).toBe(true); + expect(agent.getClientInfo()).toEqual({ name: "test-client", version: "1.2.3" }); + }); + it("lists Gateway sessions through the stable handler with opaque cursors and cwd filtering", async () => { const allRows = [ createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }), diff --git a/src/acp/translator.permission-relay.test.ts b/src/acp/translator.permission-relay.test.ts new file mode 100644 index 00000000000..b1c9cf4b5ca --- /dev/null +++ b/src/acp/translator.permission-relay.test.ts @@ -0,0 +1,453 @@ +import type { CancelNotification } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { promptAgent } from "./translator.prompt-harness.test-support.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +vi.mock("./commands.js", () => ({ + getAvailableCommands: () => [], +})); + +const SESSION_ID = "session-1"; +const SECOND_SESSION_ID = "session-2"; +const SESSION_KEY = "agent:main:main"; + +type Harness = { + agent: AcpGatewayAgent; + connection: ReturnType; + promptPromise: ReturnType; + request: ReturnType; + requestPermission: ReturnType; + runId: string; + sessionStore: ReturnType; +}; + +function createApprovalEvent(params: { + approvalId?: string; + runId: string; + sessionKey?: string; + toolCallId?: string; +}): EventFrame { + return { + type: "event", + event: "agent", + payload: { + runId: params.runId, + sessionKey: params.sessionKey ?? SESSION_KEY, + stream: "approval", + data: { + phase: "requested", + kind: "exec", + status: "pending", + title: "Command approval requested", + approvalId: params.approvalId ?? "approval-1", + toolCallId: params.toolCallId, + command: "echo event", + host: "gateway", + }, + }, + } as EventFrame; +} + +function createApprovalRequestEvent(params: { + approvalId?: string; + sessionKey?: string; + command?: string; +}): EventFrame { + return { + type: "event", + event: "exec.approval.requested", + payload: { + id: params.approvalId ?? "approval-1", + createdAtMs: 1, + expiresAtMs: 2, + request: { + command: params.command ?? "echo raw", + host: "gateway", + sessionKey: params.sessionKey ?? SESSION_KEY, + }, + }, + } as EventFrame; +} + +async function createHarness( + params: { + allowedDecisions?: string[]; + requestPermission?: ReturnType; + resolveApproval?: (requestParams?: Record) => unknown; + } = {}, +): Promise { + let runId: string | undefined; + const request = vi.fn(async (method: string, requestParams?: Record) => { + if (method === "chat.send") { + runId = requestParams?.idempotencyKey as string | undefined; + return { status: "started", runId }; + } + if (method === "exec.approval.get") { + return { + id: requestParams?.id, + commandText: "echo hydrated", + allowedDecisions: params.allowedDecisions ?? ["allow-once", "allow-always", "deny"], + host: "gateway", + }; + } + if (method === "exec.approval.resolve" && params.resolveApproval) { + return params.resolveApproval(requestParams); + } + return {}; + }) as ReturnType & GatewayClient["request"]; + const requestPermission = + params.requestPermission ?? + vi.fn(async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } })); + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + const connection = createAcpConnection({ requestPermission }); + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { sessionStore }); + const promptPromise = promptAgent(agent, SESSION_ID); + + await vi.waitFor(() => { + expect(runId).toBeDefined(); + }); + + return { + agent, + connection, + promptPromise, + request, + requestPermission, + runId: runId!, + sessionStore, + }; +} + +async function cleanupHarness(harness: Harness): Promise { + await harness.agent.cancel({ sessionId: SESSION_ID } as CancelNotification); + await harness.promptPromise; + harness.sessionStore.clearAllSessionsForTest(); +} + +function approvalResolveCalls(request: ReturnType) { + return request.mock.calls.filter(([method]) => method === "exec.approval.resolve"); +} + +describe("ACP translator permission relay", () => { + it.each([ + ["allow-once", "allow-once"], + ["allow-always", "allow-always"], + ["deny", "deny"], + ])("relays selected %s decisions to Gateway approval resolution", async (optionId, decision) => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => ({ + outcome: { outcome: "selected", optionId }, + })), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + expect(harness.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + toolCall: expect.objectContaining({ + toolCallId: "exec:approval-1", + kind: "execute", + rawInput: expect.objectContaining({ + name: "exec", + command: "echo hydrated", + approvalId: "approval-1", + }), + }), + }), + ); + expect(harness.request).toHaveBeenCalledWith("exec.approval.get", { id: "approval-1" }); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision, + }); + + await cleanupHarness(harness); + }); + + it("dedupes repeated approval events for the same approval id", async () => { + const harness = await createHarness(); + const event = createApprovalEvent({ runId: harness.runId, approvalId: "approval-dup" }); + + await harness.agent.handleGatewayEvent(event); + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + await cleanupHarness(harness); + }); + + it("relays exec approval request events before the later agent approval event", async () => { + const harness = await createHarness(); + const approvalId = "approval-raw"; + + await harness.agent.handleGatewayEvent( + createApprovalRequestEvent({ approvalId, command: "echo raw" }), + ); + await harness.agent.handleGatewayEvent( + createApprovalEvent({ runId: harness.runId, approvalId, toolCallId: "tool-late" }), + ); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + expect(harness.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + toolCallId: "exec:approval-raw", + rawInput: expect.objectContaining({ + approvalId, + command: "echo hydrated", + }), + }), + }), + ); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: approvalId, + decision: "allow-once", + }); + + await cleanupHarness(harness); + }); + + it("does not bind session-only approval events when multiple prompts share the session key", async () => { + const runIds: string[] = []; + const request = vi.fn(async (method: string, requestParams?: Record) => { + if (method === "chat.send") { + const runId = requestParams?.idempotencyKey as string; + runIds.push(runId); + return { status: "started", runId }; + } + if (method === "exec.approval.get") { + return { + id: requestParams?.id, + commandText: "echo hydrated", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + }; + } + return {}; + }) as ReturnType & GatewayClient["request"]; + const requestPermission = vi.fn(async () => ({ + outcome: { outcome: "selected", optionId: "allow-once" }, + })); + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + sessionStore.createSession({ + sessionId: SECOND_SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + const connection = createAcpConnection({ requestPermission }); + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { sessionStore }); + const firstPrompt = promptAgent(agent, SESSION_ID, "first prompt"); + const secondPrompt = promptAgent(agent, SECOND_SESSION_ID, "second prompt"); + + await vi.waitFor(() => { + expect(runIds).toHaveLength(2); + }); + + const approvalId = "approval-shared"; + await agent.handleGatewayEvent(createApprovalRequestEvent({ approvalId })); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(requestPermission).not.toHaveBeenCalled(); + expect(approvalResolveCalls(request)).toHaveLength(0); + + await agent.handleGatewayEvent(createApprovalEvent({ runId: runIds[1], approvalId })); + + await vi.waitFor(() => { + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(request)).toHaveLength(1); + }); + + expect(requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SECOND_SESSION_ID, + }), + ); + expect(request).toHaveBeenCalledWith("exec.approval.resolve", { + id: approvalId, + decision: "allow-once", + }); + + await agent.cancel({ sessionId: SESSION_ID } as CancelNotification); + await agent.cancel({ sessionId: SECOND_SESSION_ID } as CancelNotification); + await Promise.all([firstPrompt, secondPrompt]); + sessionStore.clearAllSessionsForTest(); + }); + + it("allows approval relay retry when Gateway resolution fails", async () => { + const resolveApproval = vi + .fn() + .mockRejectedValueOnce(new Error("gateway not connected")) + .mockResolvedValueOnce({}); + const harness = await createHarness({ resolveApproval }); + const event = createApprovalEvent({ runId: harness.runId, approvalId: "approval-retry" }); + + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(resolveApproval).toHaveBeenCalledTimes(1); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(2); + expect(resolveApproval).toHaveBeenCalledTimes(2); + }); + expect(harness.request).toHaveBeenLastCalledWith("exec.approval.resolve", { + id: "approval-retry", + decision: "allow-once", + }); + + await cleanupHarness(harness); + }); + + it("ignores approval events outside the active ACP run", async () => { + const harness = await createHarness(); + + await harness.agent.handleGatewayEvent( + createApprovalEvent({ + runId: "other-run", + sessionKey: "agent:main:other", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(harness.requestPermission).not.toHaveBeenCalled(); + expect(approvalResolveCalls(harness.request)).toHaveLength(0); + + await cleanupHarness(harness); + }); + + it.each([ + { outcome: { outcome: "cancelled" } }, + { outcome: { outcome: "selected", optionId: "not-a-real-option" } }, + ])("denies cancelled and invalid ACP permission outcomes", async (outcome) => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => outcome), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision: "deny", + }); + }); + + await cleanupHarness(harness); + }); + + it("denies when the ACP client permission request throws", async () => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => { + throw new Error("client closed"); + }), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision: "deny", + }); + }); + + await cleanupHarness(harness); + }); + + it("does not allow execution when the prompt is cancelled during client permission UI", async () => { + let resolvePermission!: (value: unknown) => void; + const harness = await createHarness({ + requestPermission: vi.fn( + () => + new Promise((resolve) => { + resolvePermission = resolve; + }), + ), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + }); + + await cleanupHarness(harness); + resolvePermission({ outcome: { outcome: "selected", optionId: "allow-once" } }); + + await vi.waitFor(() => { + const decisions = approvalResolveCalls(harness.request).map( + ([, params]) => (params as { decision?: string }).decision, + ); + expect(decisions).toContain("deny"); + expect(decisions).not.toContain("allow-once"); + }); + }); + + it("keeps existing tool streaming behavior unchanged", async () => { + const harness = await createHarness(); + + await harness.agent.handleGatewayEvent({ + type: "event", + event: "agent", + payload: { + runId: harness.runId, + sessionKey: SESSION_KEY, + stream: "tool", + data: { + phase: "start", + name: "exec", + toolCallId: "tool-1", + args: { command: "echo ok" }, + }, + }, + } as EventFrame); + + expect(harness.requestPermission).not.toHaveBeenCalled(); + expect(harness.connection.__sessionUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + status: "in_progress", + }), + }), + ); + + await cleanupHarness(harness); + }); +}); diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index 222ee0ef59b..7ea97fa7d7f 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -3,13 +3,22 @@ import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; type TestAcpConnection = AgentSideConnection & { + __requestPermissionMock: ReturnType; __sessionUpdateMock: ReturnType; }; -export function createAcpConnection(): TestAcpConnection { +export function createAcpConnection( + params: { + requestPermission?: ReturnType; + } = {}, +): TestAcpConnection { + const requestPermission = + params.requestPermission ?? vi.fn(async () => ({ outcome: { outcome: "cancelled" } })); const sessionUpdate = vi.fn(async () => {}); return { + requestPermission, sessionUpdate, + __requestPermissionMock: requestPermission, __sessionUpdateMock: sessionUpdate, } as unknown as TestAcpConnection; } diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 27fcb73928f..51a1c057779 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -57,6 +57,15 @@ import { inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; +import { + buildAcpPermissionRequest, + parseGatewayExecApprovalEventData, + parseGatewayExecApprovalRequestEventPayload, + resolveGatewayDecisionFromPermissionOutcome, + type GatewayExecApprovalDecision, + type GatewayExecApprovalDetails, + type GatewayExecApprovalEvent, +} from "./permission-relay.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js"; import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; @@ -116,6 +125,20 @@ type PendingPrompt = { toolCalls?: Map; }; +type ClientCapabilityState = { + readTextFile: boolean; + writeTextFile: boolean; + terminal: boolean; +}; + +type PendingApprovalRelay = { + approvalId: string; + runId: string; + sessionId: string; + sessionKey: string; + state: "active" | "completed"; +}; + type PendingToolCall = { kind: ToolKind; locations?: ToolCallLocation[]; @@ -164,6 +187,16 @@ type SessionUsageSnapshot = { used: number; }; +function normalizeClientCapabilities( + capabilities: InitializeRequest["clientCapabilities"] | undefined, +): ClientCapabilityState { + return { + readTextFile: capabilities?.fs?.readTextFile === true, + writeTextFile: capabilities?.fs?.writeTextFile === true, + terminal: capabilities?.terminal === true, + }; +} + function isAdminScopeProvenanceRejection(err: unknown): boolean { if (!(err instanceof Error)) { return false; @@ -531,6 +564,9 @@ export class AcpGatewayAgent implements Agent { private eventLedger: AcpEventLedger; private sessionCreateRateLimiter: FixedWindowRateLimiter; private pendingPrompts = new Map(); + private approvalRelays = new Map(); + private clientCapabilities: ClientCapabilityState = normalizeClientCapabilities(undefined); + private clientInfo: InitializeRequest["clientInfo"] = null; private disconnectTimer: NodeJS.Timeout | null = null; private activeDisconnectContext: DisconnectContext | null = null; private disconnectGeneration = 0; @@ -570,6 +606,22 @@ export class AcpGatewayAgent implements Agent { this.log("ready"); } + supportsClientReadTextFile(): boolean { + return this.clientCapabilities.readTextFile; + } + + supportsClientWriteTextFile(): boolean { + return this.clientCapabilities.writeTextFile; + } + + supportsClientTerminal(): boolean { + return this.clientCapabilities.terminal; + } + + getClientInfo(): InitializeRequest["clientInfo"] { + return this.clientInfo; + } + handleGatewayReconnect(): void { this.log("gateway reconnected"); const disconnectContext = this.activeDisconnectContext; @@ -602,12 +654,18 @@ export class AcpGatewayAgent implements Agent { await this.handleChatEvent(evt); return; } + if (evt.event === "exec.approval.requested") { + this.handleExecApprovalRequestEvent(evt); + return; + } if (evt.event === "agent") { await this.handleAgentEvent(evt); } } - async initialize(_params: InitializeRequest): Promise { + async initialize(params: InitializeRequest): Promise { + this.clientCapabilities = normalizeClientCapabilities(params.clientCapabilities); + this.clientInfo = params.clientInfo ?? null; return { protocolVersion: await getAcpProtocolVersion(), agentCapabilities: { @@ -995,6 +1053,7 @@ export class AcpGatewayAgent implements Agent { if (isGatewayCloseError(err) && this.getPendingPrompt(params.sessionId, runId)) { return; } + this.clearApprovalRelaysForPrompt(params.sessionId, runId, { denyActive: true }); this.pendingPrompts.delete(params.sessionId); this.sessionStore.clearActiveRun(params.sessionId); if (this.pendingPrompts.size === 0) { @@ -1045,6 +1104,11 @@ export class AcpGatewayAgent implements Agent { return; } + if (stream === "approval") { + await this.handleApprovalEvent({ sessionKey, runId, data }); + return; + } + if (stream !== "tool") { return; } @@ -1139,6 +1203,163 @@ export class AcpGatewayAgent implements Agent { } } + private async handleApprovalEvent(params: { + sessionKey: string; + runId?: string; + data: Record; + }): Promise { + const approvalEvent = parseGatewayExecApprovalEventData(params.data); + if (!approvalEvent) { + return; + } + this.startApprovalRelay({ + sessionKey: params.sessionKey, + runId: params.runId, + approvalEvent, + }); + } + + private handleExecApprovalRequestEvent(evt: EventFrame): void { + const payload = evt.payload as Record | undefined; + if (!payload) { + return; + } + const approvalEvent = parseGatewayExecApprovalRequestEventPayload(payload); + if (!approvalEvent) { + return; + } + const request = payload.request as Record | undefined; + const sessionKey = normalizeOptionalString(request?.sessionKey); + if (!sessionKey) { + return; + } + this.startApprovalRelay({ sessionKey, approvalEvent }); + } + + private startApprovalRelay(params: { + sessionKey: string; + runId?: string; + approvalEvent: GatewayExecApprovalEvent; + }): void { + const approvalEvent = params.approvalEvent; + if (this.approvalRelays.has(approvalEvent.approvalId)) { + return; + } + + const pending = params.runId + ? this.findPendingBySessionKey(params.sessionKey, params.runId) + : this.findUniquePendingBySessionKey(params.sessionKey); + if (!pending) { + return; + } + + const relay: PendingApprovalRelay = { + approvalId: approvalEvent.approvalId, + runId: pending.idempotencyKey, + sessionId: pending.sessionId, + sessionKey: pending.sessionKey, + state: "active", + }; + this.approvalRelays.set(relay.approvalId, relay); + void this.runApprovalRelay(relay, approvalEvent); + } + + private async runApprovalRelay( + relay: PendingApprovalRelay, + approvalEvent: GatewayExecApprovalEvent, + ): Promise { + let resolved = false; + try { + const details = await this.getGatewayApprovalDetails(relay.approvalId); + if (!this.isApprovalRelayActive(relay)) { + resolved = await this.resolveGatewayApproval(relay.approvalId, "deny"); + return; + } + + const request = buildAcpPermissionRequest({ + sessionId: relay.sessionId, + event: approvalEvent, + details, + }); + let decision: GatewayExecApprovalDecision | undefined; + try { + const response = await this.connection.requestPermission(request); + decision = resolveGatewayDecisionFromPermissionOutcome(response, request.options); + } catch (err) { + this.log(`approval relay request failed for ${relay.approvalId}: ${String(err)}`); + } + + const selectedDecision = this.isApprovalRelayActive(relay) && decision ? decision : "deny"; + resolved = await this.resolveGatewayApproval(relay.approvalId, selectedDecision); + } finally { + const current = this.approvalRelays.get(relay.approvalId); + if (current === relay && current.state === "active") { + if (resolved) { + // Keep completed relays until prompt cleanup as replay/dedup sentinels. + current.state = "completed"; + } else { + this.approvalRelays.delete(relay.approvalId); + } + } + } + } + + private async getGatewayApprovalDetails( + approvalId: string, + ): Promise { + try { + return await this.gateway.request("exec.approval.get", { + id: approvalId, + }); + } catch (err) { + this.log(`approval relay hydrate failed for ${approvalId}: ${String(err)}`); + return null; + } + } + + private async resolveGatewayApproval( + approvalId: string, + decision: GatewayExecApprovalDecision, + ): Promise { + try { + await this.gateway.request("exec.approval.resolve", { + id: approvalId, + decision, + }); + return true; + } catch (err) { + this.log(`approval relay resolve failed for ${approvalId}: ${String(err)}`); + return false; + } + } + + private isApprovalRelayActive(relay: PendingApprovalRelay): boolean { + return ( + this.approvalRelays.get(relay.approvalId) === relay && + relay.state === "active" && + this.getPendingPrompt(relay.sessionId, relay.runId) !== undefined + ); + } + + private clearApprovalRelaysForPrompt( + sessionId: string, + runId?: string, + opts: { denyActive?: boolean } = {}, + ): void { + for (const [approvalId, relay] of this.approvalRelays) { + if (relay.sessionId !== sessionId) { + continue; + } + if (runId && relay.runId !== runId) { + continue; + } + this.approvalRelays.delete(approvalId); + if (opts.denyActive && relay.state === "active") { + void this.resolveGatewayApproval(approvalId, "deny"); + } + } + } + private async handleChatEvent(evt: EventFrame): Promise { const payload = evt.payload as Record | undefined; if (!payload) { @@ -1250,6 +1471,7 @@ export class AcpGatewayAgent implements Agent { pending: PendingPrompt, stopReason: StopReason, ): Promise { + this.clearApprovalRelaysForPrompt(sessionId, pending.idempotencyKey, { denyActive: true }); this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); if (this.pendingPrompts.size === 0) { @@ -1298,6 +1520,20 @@ export class AcpGatewayAgent implements Agent { return undefined; } + private findUniquePendingBySessionKey(sessionKey: string): PendingPrompt | undefined { + let match: PendingPrompt | undefined; + for (const pending of this.pendingPrompts.values()) { + if (pending.sessionKey !== sessionKey) { + continue; + } + if (match) { + return undefined; + } + match = pending; + } + return match; + } + private reconcilePendingSessionKey(pending: PendingPrompt, sessionKey: string): void { if (pending.sessionKey === sessionKey) { return; @@ -1332,6 +1568,9 @@ export class AcpGatewayAgent implements Agent { if (currentPending !== pending) { return; } + this.clearApprovalRelaysForPrompt(pending.sessionId, pending.idempotencyKey, { + denyActive: true, + }); this.pendingPrompts.delete(pending.sessionId); this.sessionStore.clearActiveRun(pending.sessionId); if (this.pendingPrompts.size === 0) { @@ -1678,6 +1917,9 @@ export class AcpGatewayAgent implements Agent { } if (pending) { + this.clearApprovalRelaysForPrompt(session.sessionId, pending.idempotencyKey, { + denyActive: true, + }); this.pendingPrompts.delete(session.sessionId); if (this.pendingPrompts.size === 0) { this.clearDisconnectTimer(); From e7b429436b19fa7fa75d5088d1b311377e99852f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:16:27 +0100 Subject: [PATCH 722/806] test: tighten security plugin helpers --- src/security/audit-plugins-trust.test.ts | 1 - src/security/audit-sandbox-browser.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index f00932a1b5f..2071432700b 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -170,7 +170,6 @@ describe("security audit install metadata findings", () => { checkId: string, ) => { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding`); } diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index fa9b48ca742..27f16f8cb57 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -19,7 +19,6 @@ function requireFinding( findings: Array<{ checkId: string; severity: string; detail: string }>, ) { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding`); } From 6bafd975e2ff3b381b836130d3c1cddd747ec714 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:16:57 +0100 Subject: [PATCH 723/806] test: tighten unit coverage assertions --- test/vitest-unit-config.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 955738be0de..0849d698ecb 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -134,14 +134,15 @@ describe("unit vitest config", () => { it("scopes default coverage to source files owned by the unit lane", () => { const unitConfig = createUnitVitestConfig({}); const testConfig = requireTestConfig(unitConfig); - expect(testConfig.coverage?.include).toEqual( + const coverageInclude = testConfig.coverage?.include; + expect(coverageInclude).toEqual( expect.arrayContaining([ "src/commitments/runtime.ts", "src/media-generation/runtime-shared.ts", "src/web-search/runtime.ts", ]), ); - expect(testConfig.coverage?.include).not.toEqual( + expect(coverageInclude).not.toEqual( expect.arrayContaining(["src/markdown/render.ts", "src/security/audit-workspace-skills.ts"]), ); }); From d7f4c8b4372312f41ddd8a1bd80c083a68d8fe18 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:17:37 +0100 Subject: [PATCH 724/806] test: tighten docker e2e lane assertion --- test/scripts/docker-e2e-plan.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 6b44bbc3802..a7454ebdedd 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -30,6 +30,15 @@ function planFor( }).plan; } +function requireFirstLane(plan: ReturnType) { + const [lane] = plan.lanes; + expect(lane).toBeDefined(); + if (!lane) { + throw new Error("Expected at least one Docker E2E lane"); + } + return lane; +} + describe("scripts/lib/docker-e2e-plan", () => { it("plans the full release path against package-backed e2e images", () => { const plan = planFor({ @@ -342,7 +351,7 @@ describe("scripts/lib/docker-e2e-plan", () => { ), }), ]); - expect(plan.lanes[0]?.command).toContain( + expect(requireFirstLane(plan).command).toContain( 'OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/published-upgrade-survivor-2026.4.29"', ); }); From daa48e8681e2e27686dfd0a9cf7ec4cd254cc00b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:17:59 +0100 Subject: [PATCH 725/806] test: tighten acp security helpers --- src/acp/translator.permission-relay.test.ts | 4 +++- src/security/audit-node-command-findings.test.ts | 1 - src/security/fix.test.ts | 1 - src/security/windows-acl.test.ts | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/acp/translator.permission-relay.test.ts b/src/acp/translator.permission-relay.test.ts index b1c9cf4b5ca..e61b76445ca 100644 --- a/src/acp/translator.permission-relay.test.ts +++ b/src/acp/translator.permission-relay.test.ts @@ -113,7 +113,9 @@ async function createHarness( const promptPromise = promptAgent(agent, SESSION_ID); await vi.waitFor(() => { - expect(runId).toBeDefined(); + if (!runId) { + throw new Error("expected ACP permission relay run id"); + } }); return { diff --git a/src/security/audit-node-command-findings.test.ts b/src/security/audit-node-command-findings.test.ts index 2197968ed0b..7247b2608ec 100644 --- a/src/security/audit-node-command-findings.test.ts +++ b/src/security/audit-node-command-findings.test.ts @@ -27,7 +27,6 @@ function requireFinding( label: string, ) { const finding = findings.find((entry) => entry.checkId === checkId); - expect(finding, label).toBeDefined(); if (!finding) { throw new Error(`Expected ${checkId} finding for ${label}`); } diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts index 4250da21d0a..0f85dce98f0 100644 --- a/src/security/fix.test.ts +++ b/src/security/fix.test.ts @@ -123,7 +123,6 @@ describe("security fix", () => { const whatsapp = channels.whatsapp; const accounts = whatsapp.accounts as Record>; const account = accounts[accountId]; - expect(account).toBeDefined(); if (!account) { throw new Error(`Expected WhatsApp account ${accountId}`); } diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index ed6c7109a24..1cdf5096ca8 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -88,7 +88,6 @@ function expectInspectSuccess( function expectIcaclsResetCommand( result: ReturnType, ): NonNullable> { - expect(result).toBeDefined(); if (!result) { throw new Error("Expected icacls reset command"); } From e54f392b840a9f32d7d4cdeaeab9b946fd121745 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:18:07 +0100 Subject: [PATCH 726/806] test: tighten topology record assertions --- test/scripts/ts-topology.test.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/test/scripts/ts-topology.test.ts b/test/scripts/ts-topology.test.ts index 70dfa32d266..6011590dd84 100644 --- a/test/scripts/ts-topology.test.ts +++ b/test/scripts/ts-topology.test.ts @@ -34,11 +34,20 @@ function deriveReportEnvelope(report: Parameters[ const singleOwnerEnvelope = deriveReportEnvelope("single-owner-shared"); const unusedEnvelope = deriveReportEnvelope("unused-public-surface"); +function requireRecordByExport(exportName: string) { + const record = publicSurfaceEnvelope.records.find((entry) => + entry.exportNames.includes(exportName), + ); + expect(record).toBeDefined(); + if (!record) { + throw new Error(`Expected topology record for ${exportName}`); + } + return record; +} + describe("ts-topology", () => { it("collapses canonical symbols exported by multiple public subpaths", () => { - const sharedThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("sharedThing"), - ); + const sharedThing = requireRecordByExport("sharedThing"); expect(sharedThing).toMatchObject({ declarationPath: "src/lib/shared.ts", @@ -47,21 +56,15 @@ describe("ts-topology", () => { productionPackages: ["src"], productionOwners: ["extension:alpha", "extension:beta", "src"], }); - expect(sharedThing?.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]); + expect(sharedThing.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]); }); it("counts renamed imports, namespace imports, type-only imports, and test-only consumers", () => { - const aliasedThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("aliasedThing"), - ); - const sharedType = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("SharedType"), - ); - const testOnlyThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("testOnlyThing"), - ); + const aliasedThing = requireRecordByExport("aliasedThing"); + const sharedType = requireRecordByExport("SharedType"); + const testOnlyThing = requireRecordByExport("testOnlyThing"); - expect(aliasedThing?.productionRefCount).toBe(1); + expect(aliasedThing.productionRefCount).toBe(1); expect(sharedType).toMatchObject({ isTypeOnlyCandidate: true, productionExtensions: ["alpha", "beta"], From 2ccc08851d5a8357a7de1d33c3713014d332497b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:18:44 +0100 Subject: [PATCH 727/806] test: tighten cross os release assertion --- test/scripts/openclaw-cross-os-release-checks.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index cedf8384ee3..35ace6d23ae 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -268,9 +268,8 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(source).toContain(providerOverride); expect(source).not.toContain("models.providers.${params.providerConfig.extensionId}.baseUrl"); expect(source).toContain('"--timeout",\n String(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS)'); - expect(source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g)?.length).toBeGreaterThanOrEqual( - 2, - ); + const agentTurnArgCalls = source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g) ?? []; + expect(agentTurnArgCalls.length).toBeGreaterThanOrEqual(2); }); it("treats explicit empty-string args as values instead of boolean flags", () => { From 03d6a5a6dc5f454814c7cbc11c4e962811b4877e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:19:11 +0100 Subject: [PATCH 728/806] test: tighten docker e2e helper --- test/scripts/docker-e2e-plan.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index a7454ebdedd..0d53e3b99d3 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -32,7 +32,6 @@ function planFor( function requireFirstLane(plan: ReturnType) { const [lane] = plan.lanes; - expect(lane).toBeDefined(); if (!lane) { throw new Error("Expected at least one Docker E2E lane"); } From 4f02ef9cc284e11e4530361d22de9c1adbaeefe2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:19:19 +0100 Subject: [PATCH 729/806] test: tighten plugin boundary assertions --- test/scripts/plugin-boundary-report.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts index 2907d589b69..7dbfb56e64e 100644 --- a/test/scripts/plugin-boundary-report.test.ts +++ b/test/scripts/plugin-boundary-report.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vitest"; import { createPluginBoundaryReport } from "../../scripts/plugin-boundary-report.js"; +function requirePluginSdkSummary(summary: { + pluginSdk?: { + crossOwnerReservedImportCount?: unknown; + unusedReservedCount?: unknown; + }; +}) { + expect(summary.pluginSdk).toBeDefined(); + if (!summary.pluginSdk) { + throw new Error("Expected plugin SDK summary"); + } + return summary.pluginSdk; +} + describe("plugin-boundary-report", () => { it("emits compact CI-safe summary JSON", () => { const result = createPluginBoundaryReport([ @@ -20,8 +33,9 @@ describe("plugin-boundary-report", () => { }; expect(result).toMatchObject({ exitCode: 0, stderr: "" }); - expect(summary.pluginSdk?.crossOwnerReservedImportCount).toBe(0); - expect(summary.pluginSdk?.unusedReservedCount).toBe(0); + const pluginSdk = requirePluginSdkSummary(summary); + expect(pluginSdk.crossOwnerReservedImportCount).toBe(0); + expect(pluginSdk.unusedReservedCount).toBe(0); expect(["private-core-bridge", "private-package-core-integrated"]).toContain( summary.memoryHostSdk?.implementation, ); From 431d478e5cf1e01b5594de7f5e80f076c3701467 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:19:51 +0100 Subject: [PATCH 730/806] test: tighten release check assertions --- test/scripts/release-check.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/scripts/release-check.test.ts b/test/scripts/release-check.test.ts index d759dae3ae4..5e2aa6d72cd 100644 --- a/test/scripts/release-check.test.ts +++ b/test/scripts/release-check.test.ts @@ -4,6 +4,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { writePackedBundledPluginActivationConfig } from "../../scripts/release-check.ts"; +function requirePluginEntries(config: { plugins?: { entries?: Record } }) { + expect(config.plugins?.entries).toBeDefined(); + if (!config.plugins?.entries) { + throw new Error("Expected plugin entries in packaged activation config"); + } + return config.plugins.entries; +} + describe("release-check", () => { it("seeds packaged activation smoke with an included channel plugin", () => { const homeDir = mkdtempSync(join(tmpdir(), "openclaw-release-check-test-")); @@ -17,9 +25,10 @@ describe("release-check", () => { }; expect(config.channels).toHaveProperty("matrix"); - expect(config.plugins?.entries).toHaveProperty("matrix"); + const pluginEntries = requirePluginEntries(config); + expect(pluginEntries).toHaveProperty("matrix"); expect(config.channels).not.toHaveProperty("feishu"); - expect(config.plugins?.entries).not.toHaveProperty("feishu"); + expect(pluginEntries).not.toHaveProperty("feishu"); } finally { rmSync(homeDir, { recursive: true, force: true }); } From b415efa2e9a08c0658ca15ef17ef2058b8da316e Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:20:15 +0100 Subject: [PATCH 731/806] test: tighten root override assertion --- test/scripts/root-package-overrides.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/scripts/root-package-overrides.test.ts b/test/scripts/root-package-overrides.test.ts index de7498b539f..f2d0830f751 100644 --- a/test/scripts/root-package-overrides.test.ts +++ b/test/scripts/root-package-overrides.test.ts @@ -30,8 +30,9 @@ describe("root package override guardrails", () => { it("pins the node-domexception alias exactly in npm and pnpm overrides", () => { const manifest = readRootManifest(); const pnpmOverride = manifest.pnpm?.overrides?.["node-domexception"]; + const npmOverride = manifest.overrides?.["node-domexception"]; expect(pnpmOverride).toBe("npm:@nolyfill/domexception@1.0.28"); - expect(manifest.overrides?.["node-domexception"]).toBe(pnpmOverride); + expect(npmOverride).toBe(pnpmOverride); }); }); From 7a39b855d3de6593ef77631d5ab8233ff01e9b07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:20:17 +0100 Subject: [PATCH 732/806] test: tighten topology helper --- test/scripts/ts-topology.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/scripts/ts-topology.test.ts b/test/scripts/ts-topology.test.ts index 6011590dd84..eb56c297754 100644 --- a/test/scripts/ts-topology.test.ts +++ b/test/scripts/ts-topology.test.ts @@ -38,7 +38,6 @@ function requireRecordByExport(exportName: string) { const record = publicSurfaceEnvelope.records.find((entry) => entry.exportNames.includes(exportName), ); - expect(record).toBeDefined(); if (!record) { throw new Error(`Expected topology record for ${exportName}`); } From dca7b1815597c08a2a65e3256c27c44dac0710de Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:20:58 +0100 Subject: [PATCH 733/806] test: tighten sdk transport assertions --- packages/sdk/src/index.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index cf1735a0ac5..929cff0c2da 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -53,6 +53,15 @@ class FakeTransport implements OpenClawTransport { } } +function requireTransportCall(calls: readonly RequestCall[], index: number): RequestCall { + const call = calls[index]; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`Expected transport call ${index}`); + } + return call; +} + describe("OpenClaw SDK", () => { it("runs an agent through the Gateway agent method", async () => { const transport = new FakeTransport({ @@ -423,8 +432,10 @@ describe("OpenClaw SDK", () => { "sessions.abort", "models.authStatus", ]); - expect(transport.calls[1]?.params).toEqual({ runId: "run_without_session" }); - expect(transport.calls[2]?.params).toEqual({ probe: false }); + expect(requireTransportCall(transport.calls, 1).params).toEqual({ + runId: "run_without_session", + }); + expect(requireTransportCall(transport.calls, 2).params).toEqual({ probe: false }); }); it("replays fast run events emitted before the caller starts iterating", async () => { From 34f515429ac4efc4a9a317ad3f3d2a5ccfd5272c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:21:54 +0100 Subject: [PATCH 734/806] test: tighten plugin hook assertions --- src/hooks/plugin-hooks.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/hooks/plugin-hooks.test.ts b/src/hooks/plugin-hooks.test.ts index 89157df925b..58656aaf6ea 100644 --- a/src/hooks/plugin-hooks.test.ts +++ b/src/hooks/plugin-hooks.test.ts @@ -98,6 +98,15 @@ describe("bundle plugin hooks", () => { }; } + function requireOnlyHookEntry(entries: ReturnType) { + expect(entries).toHaveLength(1); + const [entry] = entries; + if (!entry) { + throw new Error("Expected bundled hook entry"); + } + return entry; + } + it("exposes enabled bundle hook dirs as plugin-managed hook entries", async () => { const bundleRoot = await writeBundleHookFixture(); @@ -105,14 +114,14 @@ describe("bundle plugin hooks", () => { config: createConfig(true), }); - expect(entries).toHaveLength(1); - expect(entries[0]?.hook.name).toBe("bundle-hook"); - expect(entries[0]?.hook.source).toBe("openclaw-plugin"); - expect(entries[0]?.hook.pluginId).toBe("sample-bundle"); - expect(entries[0]?.hook.baseDir).toBe( + const entry = requireOnlyHookEntry(entries); + expect(entry.hook.name).toBe("bundle-hook"); + expect(entry.hook.source).toBe("openclaw-plugin"); + expect(entry.hook.pluginId).toBe("sample-bundle"); + expect(entry.hook.baseDir).toBe( fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")), ); - expect(entries[0]?.metadata?.events).toEqual(["command:new"]); + expect(entry.metadata?.events).toEqual(["command:new"]); }); it("loads and executes enabled bundle hooks through the internal hook loader", async () => { From 32ec6c2ba78c62dc9606ca5d77ac029d159062ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:21:57 +0100 Subject: [PATCH 735/806] test: tighten release tooling helpers --- test/scripts/plugin-boundary-report.test.ts | 1 - test/scripts/release-check.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts index 7dbfb56e64e..4bb80143f24 100644 --- a/test/scripts/plugin-boundary-report.test.ts +++ b/test/scripts/plugin-boundary-report.test.ts @@ -7,7 +7,6 @@ function requirePluginSdkSummary(summary: { unusedReservedCount?: unknown; }; }) { - expect(summary.pluginSdk).toBeDefined(); if (!summary.pluginSdk) { throw new Error("Expected plugin SDK summary"); } diff --git a/test/scripts/release-check.test.ts b/test/scripts/release-check.test.ts index 5e2aa6d72cd..7fe3776aa0f 100644 --- a/test/scripts/release-check.test.ts +++ b/test/scripts/release-check.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"; import { writePackedBundledPluginActivationConfig } from "../../scripts/release-check.ts"; function requirePluginEntries(config: { plugins?: { entries?: Record } }) { - expect(config.plugins?.entries).toBeDefined(); if (!config.plugins?.entries) { throw new Error("Expected plugin entries in packaged activation config"); } From f49beec09aa9b145da1222f3be7df93a4769383c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 21:22:32 +0100 Subject: [PATCH 736/806] test: tighten compaction hook assertion --- src/plugins/wired-hooks-compaction.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index bffc84fc075..3cd43f5f11b 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -101,7 +101,11 @@ describe("compaction hook wiring", () => { }) { expect(params.call.event).toEqual(expect.objectContaining(params.expectedEvent)); if (params.expectedSessionKey !== undefined) { - expect(params.call.hookCtx?.sessionKey).toBe(params.expectedSessionKey); + expect(params.call.hookCtx).toBeDefined(); + if (!params.call.hookCtx) { + throw new Error("Expected compaction hook context"); + } + expect(params.call.hookCtx.sessionKey).toBe(params.expectedSessionKey); } } From 02ea672dd63864bae5fd35be0b6e7270ed656100 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:23:20 +0100 Subject: [PATCH 737/806] test: tighten sdk transport helper --- packages/sdk/src/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 929cff0c2da..66b1ccaa2eb 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -55,7 +55,6 @@ class FakeTransport implements OpenClawTransport { function requireTransportCall(calls: readonly RequestCall[], index: number): RequestCall { const call = calls[index]; - expect(call).toBeDefined(); if (!call) { throw new Error(`Expected transport call ${index}`); } From c7a0a7af7bb0b27e9604b241c4d8e3f9a615c4f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:24:32 +0100 Subject: [PATCH 738/806] test: tighten compaction hook helper --- src/plugins/wired-hooks-compaction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 3cd43f5f11b..1b68aed7059 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -101,7 +101,6 @@ describe("compaction hook wiring", () => { }) { expect(params.call.event).toEqual(expect.objectContaining(params.expectedEvent)); if (params.expectedSessionKey !== undefined) { - expect(params.call.hookCtx).toBeDefined(); if (!params.call.hookCtx) { throw new Error("Expected compaction hook context"); } From 9bc8237f7bdb23d87edeb900e39f0293b0959468 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:26:50 +0100 Subject: [PATCH 739/806] test: avoid filter allocation assertions --- src/agents/sandbox/fs-bridge.shell.test.ts | 8 ++++---- src/channels/status-reactions.test.ts | 2 +- src/infra/restart-stale-pids.test.ts | 2 +- src/secrets/apply.test.ts | 2 +- test/scripts/bundled-plugin-build-entries.test.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index 1fd440266c9..db29883896c 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -13,11 +13,11 @@ import { } from "./fs-bridge.test-helpers.js"; function expectNoScriptsContaining(scripts: string[], needle: string) { - expect(scripts.filter((script) => script.includes(needle))).toEqual([]); + expect(scripts.some((script) => script.includes(needle))).toBe(false); } function expectSomeScriptContaining(scripts: string[], needle: string) { - expect(scripts.filter((script) => script.includes(needle)).length).toBeGreaterThan(0); + expect(scripts.some((script) => script.includes(needle))).toBe(true); } describe("sandbox fs bridge shell compatibility", () => { @@ -49,8 +49,8 @@ describe("sandbox fs bridge shell compatibility", () => { const scripts = getScriptsFromCalls(); const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); - expect(executables.filter((shell) => shell !== "sh")).toEqual([]); - expect(scripts.filter((script) => !/set -eu[;\n]/.test(script))).toEqual([]); + expect(executables.every((shell) => shell === "sh")).toBe(true); + expect(scripts.every((script) => /set -eu[;\n]/.test(script))).toBe(true); expectNoScriptsContaining(scripts, "pipefail"); }); }); diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 884a1c2a9ed..d5832699d64 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -373,7 +373,7 @@ describe("createStatusReactionController", () => { // Should only have set calls, no remove const removeCalls = calls.filter((c) => c.method === "remove"); expect(removeCalls).toHaveLength(0); - expect(calls.filter((c) => c.method === "set").length).toBeGreaterThan(0); + expect(calls.some((c) => c.method === "set")).toBe(true); }); it("should clear all known emojis when adapter supports removeReaction", async () => { diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index aab2ab6c84f..5b938a1e0be 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -759,7 +759,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { cleanStaleGatewayProcessesSync(); expect(events).toContain("port-free"); - expect(events.filter((e) => e.startsWith("busy-poll")).length).toBeGreaterThan(0); + expect(events.some((e) => e.startsWith("busy-poll"))).toBe(true); }); it("bails immediately when lsof is permanently unavailable (ENOENT) — Greptile edge case", () => { diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 2ec760ed4b4..b7355d02148 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -385,7 +385,7 @@ describe("secrets apply", () => { expect(dryRunAllowed.mode).toBe("dry-run"); expect(dryRunAllowed.skippedExecRefs).toBe(0); const callLog = await fs.readFile(execLogPath, "utf8"); - expect(callLog.split("\n").filter((line) => line.trim().length > 0).length).toBeGreaterThan(0); + expect(callLog.split("\n").some((line) => line.trim().length > 0)).toBe(true); }); it("ignores unrelated auth-profile store refs during allowExec dry-run preflight", async () => { diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 18fbaa7d19c..27208097301 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -8,11 +8,11 @@ import { } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; function expectNoPrefixMatches(values: string[], prefix: string) { - expect(values.filter((value) => value.startsWith(prefix))).toEqual([]); + expect(values.some((value) => value.startsWith(prefix))).toBe(false); } function expectSomePrefixMatch(values: string[], prefix: string) { - expect(values.filter((value) => value.startsWith(prefix)).length).toBeGreaterThan(0); + expect(values.some((value) => value.startsWith(prefix))).toBe(true); } describe("bundled plugin build entries", () => { From 70723b306dc31f8fc121bf6354bfe6e393a4e8d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:32:07 +0100 Subject: [PATCH 740/806] fix: canonicalize nested gemini catalog ids --- CHANGELOG.md | 1 + docs/providers/kilocode.md | 14 +++++++------- extensions/kilocode/index.test.ts | 2 +- src/plugin-sdk/provider-catalog-shared.test.ts | 4 ++-- src/plugin-sdk/provider-catalog-shared.ts | 18 ++++++++++++++++-- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0894aca7fda..eeac2f8b820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. - Google/Gemini: emit canonical `google/gemini-3.1-pro-preview` ids from configured provider catalog rows so model list and selection paths can test Gemini 3.1 instead of retired Gemini 3 Pro. +- Google/Gemini: normalize nested proxy-provider catalog ids like `google/gemini-3-pro-preview` to `google/gemini-3.1-pro-preview`, so Kilo-style configured catalogs test Gemini 3.1 instead of the retired Gemini 3 Pro id. - Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index 217750c342c..f62225610c2 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -60,13 +60,13 @@ OpenClaw dynamically discovers available models from the Kilo Gateway at startup Any model available on the gateway can be used with the `kilocode/` prefix: -| Model ref | Notes | -| -------------------------------------- | ---------------------------------- | -| `kilocode/kilo/auto` | Default — smart routing | -| `kilocode/anthropic/claude-sonnet-4` | Anthropic via Kilo | -| `kilocode/openai/gpt-5.5` | OpenAI via Kilo | -| `kilocode/google/gemini-3-pro-preview` | Google via Kilo | -| ...and many more | Use `/models kilocode` to list all | +| Model ref | Notes | +| ---------------------------------------- | ---------------------------------- | +| `kilocode/kilo/auto` | Default — smart routing | +| `kilocode/anthropic/claude-sonnet-4` | Anthropic via Kilo | +| `kilocode/openai/gpt-5.5` | OpenAI via Kilo | +| `kilocode/google/gemini-3.1-pro-preview` | Google via Kilo | +| ...and many more | Use `/models kilocode` to list all | At startup, OpenClaw queries `GET https://api.kilo.ai/api/gateway/models` and merges diff --git a/extensions/kilocode/index.test.ts b/extensions/kilocode/index.test.ts index 1f7ce78b1bf..509f3083907 100644 --- a/extensions/kilocode/index.test.ts +++ b/extensions/kilocode/index.test.ts @@ -95,7 +95,7 @@ describe("kilocode provider plugin", () => { ).toEqual([ { provider: "kilocode", - id: "google/gemini-3-pro-preview", + id: "google/gemini-3.1-pro-preview", name: "Gemini 3 Pro Preview", input: ["text", "image"], reasoning: true, diff --git a/src/plugin-sdk/provider-catalog-shared.test.ts b/src/plugin-sdk/provider-catalog-shared.test.ts index 1a39fcfc56a..1eb68331314 100644 --- a/src/plugin-sdk/provider-catalog-shared.test.ts +++ b/src/plugin-sdk/provider-catalog-shared.test.ts @@ -59,7 +59,7 @@ describe("provider-catalog-shared native streaming usage compat", () => { }); describe("provider-catalog-shared configured catalog entries", () => { - it("preserves configured audio and video input modalities", () => { + it("preserves configured audio and video input modalities while normalizing nested Gemini ids", () => { expect( readConfiguredProviderCatalogEntries({ providerId: "kilocode", @@ -88,7 +88,7 @@ describe("provider-catalog-shared configured catalog entries", () => { ).toEqual([ { provider: "kilocode", - id: "google/gemini-3-pro-preview", + id: "google/gemini-3.1-pro-preview", name: "Gemini 3 Pro Preview", input: ["text", "image", "video", "audio"], reasoning: true, diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 850178720e6..47c1bc121c8 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -13,6 +13,7 @@ import type { ModelCatalogModel, ModelCatalogTieredCost, } from "../model-catalog/types.js"; +import { normalizeGooglePreviewModelId } from "./provider-model-id-normalize.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; @@ -158,6 +159,17 @@ function resolveConfiguredProviderModels( return Array.isArray(providerConfig.models) ? providerConfig.models : []; } +function normalizeConfiguredProviderCatalogModelId(id: string): string { + const trimmed = id.trim(); + const googlePrefix = "google/"; + if (!trimmed.startsWith(googlePrefix)) { + return trimmed; + } + const modelId = trimmed.slice(googlePrefix.length); + const normalizedModelId = normalizeGooglePreviewModelId(modelId); + return normalizedModelId === modelId ? trimmed : `${googlePrefix}${normalizedModelId}`; +} + export function readConfiguredProviderCatalogEntries(params: { config?: OpenClawConfig; providerId: string; @@ -174,7 +186,9 @@ export function readConfiguredProviderCatalogEntries(params: { if (!id) { continue; } - const name = (typeof model.name === "string" ? model.name : id).trim() || id; + const normalizedId = normalizeConfiguredProviderCatalogModelId(id); + const name = + (typeof model.name === "string" ? model.name : normalizedId).trim() || normalizedId; const contextWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow @@ -183,7 +197,7 @@ export function readConfiguredProviderCatalogEntries(params: { const input = normalizeConfiguredCatalogModelInput(model.input); entries.push({ provider, - id, + id: normalizedId, name, ...(contextWindow ? { contextWindow } : {}), ...(reasoning !== undefined ? { reasoning } : {}), From a899f8192178b501a838bfc79532acbb82525a99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:33:48 +0100 Subject: [PATCH 741/806] test: avoid more filter allocation assertions --- extensions/mattermost/src/mattermost/model-picker.test.ts | 2 +- extensions/nvidia/index.test.ts | 2 +- test/plugin-npm-runtime-build.test.ts | 2 +- test/scripts/plugin-prerelease-test-plan.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index c3c37e94dcc..1c489947a76 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -112,7 +112,7 @@ describe("Mattermost model picker", () => { }); const ids = modelsView.buttons.flat().map((button) => button.id); - expect(ids.filter((id) => typeof id !== "string" || !/^[a-z0-9]+$/.test(id))).toEqual([]); + expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true); expect(new Set(ids).size).toBe(ids.length); }); diff --git a/extensions/nvidia/index.test.ts b/extensions/nvidia/index.test.ts index 6d73f429115..75a49b465f4 100644 --- a/extensions/nvidia/index.test.ts +++ b/extensions/nvidia/index.test.ts @@ -129,7 +129,7 @@ describe("nvidia provider hooks", () => { "minimaxai/minimax-m2.5", "z-ai/glm5", ]); - expect(entries?.filter((entry) => entry.provider !== "nvidia")).toEqual([]); + expect(entries?.every((entry) => entry.provider === "nvidia")).toBe(true); }); it("opts into literal provider-prefix preservation", async () => { diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index e154b0ee2ea..637a18abe40 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -10,7 +10,7 @@ const repoRoot = path.resolve(import.meta.dirname, ".."); type PluginNpmRuntimeBuildPlan = NonNullable>; function expectDistRelativePaths(paths: string[]) { - expect(paths.filter((entry) => !entry.startsWith("./dist/"))).toEqual([]); + expect(paths.every((entry) => entry.startsWith("./dist/"))).toBe(true); } function expectPluginNpmRuntimeBuildPlan( diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 62d826f9459..6261ce3198b 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -71,7 +71,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { const plan = createPluginPrereleaseTestPlan(); expect(plan.dockerLanes).not.toContain("openai-web-search-minimal"); - expect(plan.dockerLanes.filter((lane) => lane.startsWith("live-"))).toEqual([]); + expect(plan.dockerLanes.some((lane) => lane.startsWith("live-"))).toBe(false); expect(plan.staticChecks).toContainEqual({ check: "live-ish-availability", checkName: "checks-plugin-prerelease-live-ish-availability", From c33d71c6b859b92b4e47b94db3ae5142c9ec1164 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:35:49 +0100 Subject: [PATCH 742/806] test: avoid extension filter allocation assertions --- .../discord/src/monitor/agent-components.wildcard.test.ts | 2 +- extensions/memory-wiki/src/claim-health.test.ts | 2 +- extensions/memory-wiki/src/lint.test.ts | 2 +- extensions/microsoft-foundry/index.test.ts | 2 +- extensions/ollama/src/setup.test.ts | 4 ++-- extensions/slack/src/action-runtime.test.ts | 2 +- extensions/slack/src/directory-contract.test.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts index fb7cdfa1cad..3071c5fde01 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard.test.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard.test.ts @@ -50,7 +50,7 @@ describe("discord wildcard component registration ids", () => { const components = createWildcardComponents(); const customIds = components.map((component) => component.customId); - expect(customIds.filter((id) => id === "*")).toEqual([]); + expect(customIds.some((id) => id === "*")).toBe(false); expect(new Set(customIds).size).toBe(customIds.length); }); diff --git a/extensions/memory-wiki/src/claim-health.test.ts b/extensions/memory-wiki/src/claim-health.test.ts index fbe3eedac32..0442849dde2 100644 --- a/extensions/memory-wiki/src/claim-health.test.ts +++ b/extensions/memory-wiki/src/claim-health.test.ts @@ -59,6 +59,6 @@ describe("buildPageContradictionClusters", () => { expect(clusters).toHaveLength(2); expect(clusters.map((cluster) => cluster.key).toSorted()).toEqual(["किताब", "कीताब"]); - expect(clusters.filter((cluster) => !cluster.entries)).toEqual([]); + expect(clusters.every((cluster) => cluster.entries)).toBe(true); }); }); diff --git a/extensions/memory-wiki/src/lint.test.ts b/extensions/memory-wiki/src/lint.test.ts index 35d1f5c9602..c2e5415f3a3 100644 --- a/extensions/memory-wiki/src/lint.test.ts +++ b/extensions/memory-wiki/src/lint.test.ts @@ -47,7 +47,7 @@ describe("lintMemoryWikiVault", () => { const result = await lintMemoryWikiVault(config); - expect(result.issues.filter((issue) => issue.code === "broken-wikilink")).toEqual([]); + expect(result.issues.some((issue) => issue.code === "broken-wikilink")).toBe(false); }); it("detects duplicate ids, provenance gaps, contradictions, and open questions", async () => { diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 66ea188bbfd..5fe0508b032 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -359,7 +359,7 @@ describe("microsoft-foundry plugin", () => { prepareRuntimeAuth(runtimeContext), prepareRuntimeAuth(runtimeContext), ]); - expect(failed.filter((result) => result.status !== "rejected")).toEqual([]); + expect(failed.every((result) => result.status === "rejected")).toBe(true); expect(execFileMock).toHaveBeenCalledTimes(1); const [first, second] = await Promise.all([ diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 30f066d49e7..d7b11383fb4 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -429,7 +429,7 @@ describe("ollama setup", () => { "gpt-oss:120b-cloud", ]); const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); - expect(requestUrls.filter((url) => url.endsWith("/api/show"))).toEqual([]); + expect(requestUrls.some((url) => url.endsWith("/api/show"))).toBe(false); expect(requestUrls).toContain("https://ollama.com/api/tags"); }); @@ -697,7 +697,7 @@ describe("ollama setup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); - expect(requestUrls.filter((url) => url.endsWith("/api/pull"))).toEqual([]); + expect(requestUrls.some((url) => url.endsWith("/api/pull"))).toBe(false); expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([ "gemma4:latest", ]); diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index e232efea943..ff7a8423cea 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -290,7 +290,7 @@ describe("handleSlackAction", () => { text: expect.stringContaining("/tmp/openclaw-media/report.pdf"), }), ); - expect(result.content.filter((entry) => entry.type === "image")).toEqual([]); + expect(result.content.some((entry) => entry.type === "image")).toBe(false); expect(result.details).toEqual( expect.objectContaining({ ok: true, diff --git a/extensions/slack/src/directory-contract.test.ts b/extensions/slack/src/directory-contract.test.ts index 6e8359db289..3e0a73888b9 100644 --- a/extensions/slack/src/directory-contract.test.ts +++ b/extensions/slack/src/directory-contract.test.ts @@ -93,7 +93,7 @@ describe("Slack directory contract", () => { limit: 2, }); expect(peers).toHaveLength(2); - expect(peers.filter((entry) => !entry.id.startsWith("user:u"))).toEqual([]); + expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true); }); it("resolves current Slack account identity from live auth", async () => { From b2808ac7127d4d97c3949cd03ce1cf41625d0ca5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:37:19 +0100 Subject: [PATCH 743/806] test: avoid core filter allocation assertions --- src/config/io.best-effort.test.ts | 2 +- src/gateway/server-methods/commands.test.ts | 2 +- src/gateway/server-methods/tools-catalog.test.ts | 2 +- src/infra/push-web.test.ts | 2 +- src/markdown/render-aware-chunking.test.ts | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index 78894b43b28..d60a3069854 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -24,7 +24,7 @@ describe("readBestEffortConfig", () => { expect(snapshot.sourceConfig).toEqual({ update: { channel: "beta" } }); expect(await fs.readFile(configPath, "utf-8")).toBe(directEditRaw); const entries = await fs.readdir(`${home}/.openclaw`); - expect(entries.filter((entry) => entry.startsWith("openclaw.json.clobbered."))).toEqual([]); + expect(entries.some((entry) => entry.startsWith("openclaw.json.clobbered."))).toBe(false); }); }); diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 56cc0482984..3f5614a714c 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -325,7 +325,7 @@ describe("commands.list handler", () => { it("omits plugin commands when provider lacks nativeCommandsAutoEnabled", () => { const { payload } = callHandler({ provider: "whatsapp" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - expect(commands.filter((c) => c.source === "plugin")).toEqual([]); + expect(commands.some((c) => c.source === "plugin")).toBe(false); }); it("uses text-surface names when scope=text even with provider-native aliases", () => { diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index c4310c13e80..2b34ae265ee 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -95,7 +95,7 @@ describe("tools.catalog handler", () => { | undefined; expect(payload?.agentId).toBe("main"); const groups = payload?.groups ?? []; - expect(groups.filter((group) => group.source === "plugin")).toEqual([]); + expect(groups.some((group) => group.source === "plugin")).toBe(false); const media = groups.find((group) => group.id === "media"); expect(media?.tools.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain("core:tts"); }); diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index 2c32d1f8d24..3aaffe139ae 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -233,7 +233,7 @@ describe("sending", () => { const results = await broadcastWebPush({ title: "Broadcast" }, tmpDir); expect(results).toHaveLength(2); - expect(results.filter((result) => !result.ok)).toEqual([]); + expect(results.every((result) => result.ok)).toBe(true); expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1); expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2); }); diff --git a/src/markdown/render-aware-chunking.test.ts b/src/markdown/render-aware-chunking.test.ts index 5481f839554..92b66ca89bc 100644 --- a/src/markdown/render-aware-chunking.test.ts +++ b/src/markdown/render-aware-chunking.test.ts @@ -31,7 +31,7 @@ describe("renderMarkdownIRChunksWithinLimit", () => { expect(chunks.map((chunk) => chunk.source.text)).toEqual(["alpha ", "<<"]); expect(chunks.map((chunk) => chunk.source.text).join("")).toBe("alpha <<"); - expect(chunks.filter((chunk) => chunk.rendered.length > 8)).toEqual([]); + expect(chunks.every((chunk) => chunk.rendered.length <= 8)).toBe(true); }); it("preserves formatting when a rendered chunk is re-split", () => { @@ -46,8 +46,8 @@ describe("renderMarkdownIRChunksWithinLimit", () => { }); expect(chunks.map((chunk) => chunk.source.text)).toEqual(["Which of ", "these"]); - expect(chunks.filter((chunk) => !chunk.rendered.startsWith(""))).toEqual([]); - expect(chunks.filter((chunk) => !chunk.rendered.endsWith(""))).toEqual([]); + expect(chunks.every((chunk) => chunk.rendered.startsWith(""))).toBe(true); + expect(chunks.every((chunk) => chunk.rendered.endsWith(""))).toBe(true); }); it("checks exact candidates instead of assuming rendered length is monotonic", () => { From aa8b23394248b9d05c1e94ddcae3c2c5532bf6fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:38:37 +0100 Subject: [PATCH 744/806] test: avoid command filter allocation assertions --- src/cli/command-secret-targets.test.ts | 6 +++--- src/commands/agent.acp.test.ts | 2 +- src/commands/gateway-install-token.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 355593e7a53..a2812b12b41 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -105,8 +105,8 @@ describe("command secret target ids", () => { expect(scoped.targetIds.size).toBeGreaterThan(0); const targetIds = [...scoped.targetIds]; - expect(targetIds.filter((id) => !id.startsWith("channels.discord."))).toEqual([]); - expect(targetIds.filter((id) => id.startsWith("channels.telegram."))).toEqual([]); + expect(targetIds.every((id) => id.startsWith("channels.discord."))).toBe(true); + expect(targetIds.some((id) => id.startsWith("channels.telegram."))).toBe(false); }); it("does not coerce missing accountId to default when channel is scoped", () => { @@ -128,7 +128,7 @@ describe("command secret target ids", () => { expect(scoped.allowedPaths).toBeUndefined(); expect(scoped.targetIds.size).toBeGreaterThan(0); - expect([...scoped.targetIds].filter((id) => !id.startsWith("channels.discord."))).toEqual([]); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); }); it("scopes allowed paths to channel globals + selected account", () => { diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index ca496af5dae..5da295c83cf 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -382,7 +382,7 @@ describe("agentCommand ACP runtime routing", () => { await withAcpSessionEnv(async () => { const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents(["NO_REPLY"]); - expect(assistantEvents.map((event) => event.text).filter(Boolean)).toEqual([]); + expect(assistantEvents.every((event) => !event.text)).toBe(true); expect(logLines).not.toEqual(expect.arrayContaining([expect.stringContaining("NO_REPLY")])); expect(logLines).toEqual([]); }); diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 144e54818da..2c9e17b551a 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -268,7 +268,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); @@ -300,7 +300,7 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); From a0dd3ac65c1981d2c5be6d95be64c5126e0f3ec6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:40:05 +0100 Subject: [PATCH 745/806] test: avoid messaging filter allocation assertions --- extensions/discord/src/channel.test.ts | 2 +- extensions/matrix/src/resolve-targets.test.ts | 4 ++-- extensions/msteams/src/attachments.test.ts | 2 +- extensions/telegram/src/bot.command-menu.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index d08bffa8bc8..7401cebb298 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -503,7 +503,7 @@ describe("discordPlugin outbound", () => { includeApplication: true, }), ); - expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]); + expect(statusPatches.some((patch) => "bot" in patch || "application" in patch)).toBe(false); if (!resolveProbe) { throw new Error("Expected Discord startup probe resolver to be initialized"); diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index ca28393fd1e..6db1ed613c1 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -140,8 +140,8 @@ describe("resolveMatrixTargets (users)", () => { kind: "group", }); - expect(userResults.filter((entry) => !entry.resolved)).toEqual([]); - expect(groupResults.filter((entry) => !entry.resolved)).toEqual([]); + expect(userResults.every((entry) => entry.resolved)).toBe(true); + expect(groupResults.every((entry) => entry.resolved)).toBe(true); expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); }); diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ace547990..308895a744f 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -639,7 +639,7 @@ describe("msteams attachments", () => { }); // Should have hit the original host, NOT graph shares. expect(calledUrls).toContain(directUrl); - expect(calledUrls.filter((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toEqual([]); + expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false); }); }); diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts index b19c3de45fc..288c38b302b 100644 --- a/extensions/telegram/src/bot.command-menu.test.ts +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -214,6 +214,6 @@ describe("createTelegramBot command menu", () => { { command: "custom_generate", description: "Create an image" }, ]); const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); - expect(registered.filter((command) => reserved.has(command.command))).toEqual([]); + expect(registered.some((command) => reserved.has(command.command))).toBe(false); }); }); From 849f499e38d57cca713383b06cb08a2fc20f4082 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:41:59 +0100 Subject: [PATCH 746/806] test: avoid telegram filter allocation assertions --- extensions/telegram/src/accounts.test.ts | 2 +- extensions/telegram/src/format.test.ts | 4 ++-- extensions/telegram/src/send.test.ts | 12 +++++------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 3789536e341..5c8ddbe33bf 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -23,7 +23,7 @@ function warningLines(): string[] { } function expectNoMissingDefaultWarning() { - expect(warningLines().filter((line) => line.includes("accounts.default is missing"))).toEqual([]); + expect(warningLines().some((line) => line.includes("accounts.default is missing"))).toBe(false); } function resolveAccountWithEnv( diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 4d6350892e1..2fcd06663e0 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -116,7 +116,7 @@ describe("markdownToTelegramHtml", () => { it("splits long multiline html text without breaking balanced tags", () => { const chunks = splitTelegramHtmlChunks(`${"A\n".repeat(2500)}`, 4000); expect(chunks.length).toBeGreaterThan(1); - expect(chunks.filter((chunk) => chunk.length > 4000)).toEqual([]); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); expect(chunks[0]).toMatch(/^[\s\S]*<\/b>$/); expect(chunks[1]).toMatch(/^[\s\S]*<\/b>$/); }); @@ -128,7 +128,7 @@ describe("markdownToTelegramHtml", () => { it("treats malformed leading ampersands as plain text when chunking html", () => { const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000); expect(chunks.length).toBeGreaterThan(1); - expect(chunks.filter((chunk) => chunk.length > 4000)).toEqual([]); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); }); it("fails loudly when tag overhead leaves no room for text", () => { diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 2c7b43d7ba9..81dcb6f4bde 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -884,10 +884,8 @@ describe("sendMessageTelegram", () => { caption: undefined, }); expect(sendMessage).toHaveBeenCalledTimes(2); - expect(sendMessage.mock.calls.filter((call) => call[2]?.parse_mode !== "HTML")).toEqual([]); - expect(sendMessage.mock.calls.filter((call) => String(call[1] ?? "").length > 4000)).toEqual( - [], - ); + expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true); + expect(sendMessage.mock.calls.every((call) => String(call[1] ?? "").length <= 4000)).toBe(true); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain(""); expect(res.messageId).toBe("74"); }); @@ -2004,7 +2002,7 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(4); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); - expect(plainFallbackCalls.filter((call) => String(call?.[1] ?? "").includes("<"))).toEqual([]); + expect(plainFallbackCalls.some((call) => String(call?.[1] ?? "").includes("<"))).toBe(false); expect(res.messageId).toBe("91"); }); @@ -2035,7 +2033,7 @@ describe("sendMessageTelegram", () => { expect(String(sendMessage.mock.calls[0]?.[1] ?? "")).toMatch(/^&/); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); - expect(plainFallbackCalls.filter((call) => String(call?.[1] ?? "").length === 0)).toEqual([]); + expect(plainFallbackCalls.every((call) => String(call?.[1] ?? "").length > 0)).toBe(true); expect(res.messageId).toBe("93"); }); @@ -2059,7 +2057,7 @@ describe("sendMessageTelegram", () => { }); expect(sendMessage).toHaveBeenCalledTimes(3); - expect(sendMessage.mock.calls.filter((call) => call[2]?.parse_mode !== undefined)).toEqual([]); + expect(sendMessage.mock.calls.some((call) => call[2]?.parse_mode !== undefined)).toBe(false); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); expect(res.messageId).toBe("96"); }); From 3653127e053cdd215f1961f44f0b2b8432381838 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:43:27 +0100 Subject: [PATCH 747/806] test: avoid core utility filter allocation assertions --- src/commands/doctor-config-flow.test.ts | 4 ++-- src/gateway/session-compaction-checkpoints.test.ts | 2 +- src/infra/provider-usage.fetch.claude.test.ts | 2 +- src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 969f7305354..0c28f39b1db 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2024,8 +2024,8 @@ describe("doctor config flow", () => { .filter((call) => call[1] === "Doctor warnings" || call[1] === "Doctor changes") .map((call) => call[0]); const joinedOutputs = outputs.join("\n"); - expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]); - expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]); + expect(outputs.some((line) => line.includes("\u001b"))).toBe(false); + expect(outputs.some((line) => line.includes("\nforged"))).toBe(false); expect(joinedOutputs).toContain('channels.slack.accounts.opsopen.allowFrom: set to ["*"]'); expect(joinedOutputs).toContain('required by dmPolicy="open"'); expect( diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index f865aa711c7..39ca826b051 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -158,7 +158,7 @@ describe("session-compaction-checkpoints", () => { expect(snapshot).toBeNull(); expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(MAX_COMPACTION_CHECKPOINT_SNAPSHOT_BYTES).toBeGreaterThan(64); - expect(fsSync.readdirSync(dir).filter((file) => file.includes(".checkpoint."))).toEqual([]); + expect(fsSync.readdirSync(dir).some((file) => file.includes(".checkpoint."))).toBe(false); } finally { copyFileSyncSpy.mockRestore(); } diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts index 3bde3c4fa90..e9b82c9ad4f 100644 --- a/src/infra/provider-usage.fetch.claude.test.ts +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -41,7 +41,7 @@ async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) expectMissingScopeError(result); const calledUrls = mockFetch.mock.calls.map(([input]) => toRequestUrl(input)); expect(calledUrls.length).toBeGreaterThan(0); - expect(calledUrls.filter((url) => !url.includes("/api/oauth/usage"))).toEqual([]); + expect(calledUrls.every((url) => url.includes("/api/oauth/usage"))).toBe(true); } function makeOrgAResponse() { diff --git a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts index f81b5691989..f4c270b12de 100644 --- a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts @@ -156,7 +156,7 @@ describe("wave-15 jsonc byte-fidelity", () => { const root = assertParseable(raw); if (root.kind === "array") { expect(root.items).toHaveLength(7); - expect(root.items.filter((item) => item.kind !== "number")).toEqual([]); + expect(root.items.every((item) => item.kind === "number")).toBe(true); } }); From 66d2825841643b3dc0289c3f2815475cedc9d073 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:44:50 +0100 Subject: [PATCH 748/806] test: avoid agent filter allocation assertions --- src/agents/openclaw-tools.sessions.test.ts | 2 +- ....filters-final-suppresses-output-without-start-tag.test.ts | 2 +- src/agents/pi-hooks/context-pruning.test.ts | 2 +- src/agents/pi-tools.create-openclaw-coding-tools.test.ts | 4 ++-- src/agents/session-transcript-repair.test.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 1e5fd27fa1a..aa20da0f226 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1325,7 +1325,7 @@ describe("sessions tools", () => { ), ); expect(replyPromptAgentCalls).toEqual([]); - expect(calls.filter((call) => call.method === "send")).toEqual([]); + expect(calls.some((call) => call.method === "send")).toBe(false); }); it("sessions_send preserves threadId when announce target is hydrated via sessions.list", async () => { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts index eb68426e9c6..2ca639d30e7 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts @@ -101,7 +101,7 @@ describe("subscribeEmbeddedPiSession", () => { expect(streamedText).toBe("Title\nLine one\nLine two"); expect(streamedText).not.toContain("<"); expect(streamedText).not.toContain("final>"); - expect(payloads.filter((payload) => payload.replace)).toEqual([]); + expect(payloads.some((payload) => payload.replace)).toBe(false); }); it("preserves final content when enforced final tags are split across streamed deltas", () => { diff --git a/src/agents/pi-hooks/context-pruning.test.ts b/src/agents/pi-hooks/context-pruning.test.ts index a2da89bb330..e45d15d2644 100644 --- a/src/agents/pi-hooks/context-pruning.test.ts +++ b/src/agents/pi-hooks/context-pruning.test.ts @@ -405,7 +405,7 @@ describe("context-pruning", () => { }); const tool = findToolResult(next, "t1"); - expect(tool.content.filter((block) => block.type === "image")).toEqual([]); + expect(tool.content.some((block) => block.type === "image")).toBe(false); expect(toolText(tool)).toContain("[image removed during context pruning]"); expect(toolText(tool)).toContain("visible tool text"); }); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 4b31d89364b..5896a697a87 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -855,7 +855,7 @@ describe("createOpenClawCodingTools", () => { const imageText = imageTextBlocks?.map((block) => block.text ?? "").join("\n") ?? ""; expect(imageText).toContain("Read image file [image/png]"); if ((imageBlocks?.length ?? 0) > 0) { - expect(imageBlocks?.filter((block) => block.mimeType !== "image/png")).toEqual([]); + expect(imageBlocks?.every((block) => block.mimeType === "image/png")).toBe(true); } else { expect(imageText).toContain("[Image omitted:"); } @@ -868,7 +868,7 @@ describe("createOpenClawCodingTools", () => { path: textPath, }); - expect(textResult?.content?.filter((block) => block.type === "image")).toEqual([]); + expect(textResult?.content?.some((block) => block.type === "image")).toBe(false); const textBlocks = textResult?.content?.filter((block) => block.type === "text") as | Array<{ text?: string }> | undefined; diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 7589554093b..d3635036d6f 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -209,7 +209,7 @@ describe("sanitizeToolUseResultPairing", () => { ]); const out = sanitizeToolUseResultPairing(input); - expect(out.filter((m) => m.role === "toolResult")).toEqual([]); + expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); From ed4d7bb94e371abaf9163035f90acbbf729bc759 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:47:38 +0100 Subject: [PATCH 749/806] test: clear remaining empty filter assertions --- extensions/codex/src/app-server/run-attempt.test.ts | 2 +- extensions/memory-core/src/memory/qmd-manager.test.ts | 4 ++-- src/agents/harness/v2.test.ts | 4 ++-- src/agents/openai-ws-stream.test.ts | 8 ++++---- .../pi-embedded-runner/context-engine-maintenance.test.ts | 4 ++-- .../run/attempt.spawn-workspace.context-engine.test.ts | 2 +- .../pi-embedded-runner/run/idle-timeout-breaker.test.ts | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index af2031fa68e..d6858e6a355 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1882,7 +1882,7 @@ describe("runCodexAppServerAttempt", () => { }, ]), ); - expect(agentEvents.filter((event) => event.stream === "assistant")).toEqual([]); + expect(agentEvents.some((event) => event.stream === "assistant")).toBe(false); expect(agentEnd).toHaveBeenCalledWith( expect.objectContaining({ success: false, diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 6baee0c0d77..aa5a467358c 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2974,7 +2974,7 @@ describe("QmdMemoryManager", () => { await manager.search("hello again", { sessionKey: "agent:main:slack:dm:u123" }); expect(selectors.length).toBeGreaterThanOrEqual(2); - expect(selectors.filter((selector) => selector !== "qmd.query")).toEqual([]); + expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); expect(logWarnMock).not.toHaveBeenCalledWith( expect.stringContaining("falling back to v1 tool names"), ); @@ -3043,7 +3043,7 @@ describe("QmdMemoryManager", () => { expect(runMcporterSpy).toHaveBeenCalled(); expect(selectors.length).toBeGreaterThanOrEqual(1); - expect(selectors.filter((selector) => selector !== "qmd.query")).toEqual([]); + expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); expect(logWarnMock).not.toHaveBeenCalledWith( expect.stringContaining("falling back to v1 tool names"), ); diff --git a/src/agents/harness/v2.test.ts b/src/agents/harness/v2.test.ts index 3170ce4cf62..d5086a017a1 100644 --- a/src/agents/harness/v2.test.ts +++ b/src/agents/harness/v2.test.ts @@ -181,7 +181,7 @@ describe("AgentHarness V2 compatibility adapter", () => { "harness.run.started", "harness.run.completed", ]); - expect(diagnostics.events.filter(({ metadata }) => !metadata.trusted)).toEqual([]); + expect(diagnostics.events.every(({ metadata }) => metadata.trusted)).toBe(true); expect(diagnostics.events[1]?.event).toMatchObject({ type: "harness.run.completed", runId: "run-1", @@ -238,7 +238,7 @@ describe("AgentHarness V2 compatibility adapter", () => { "harness.run.started", "harness.run.error", ]); - expect(diagnostics.events.filter(({ metadata }) => !metadata.trusted)).toEqual([]); + expect(diagnostics.events.every(({ metadata }) => metadata.trusted)).toBe(true); expect(diagnostics.events[1]?.event).toMatchObject({ type: "harness.run.error", phase: "send", diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index c65f872059a..b90caff3e74 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -1458,7 +1458,7 @@ describe("buildAssistantMessageFromResponse", () => { }; expect(msg.phase).toBeUndefined(); - expect(msg.content.filter((part) => part.type === "text")).toEqual([]); + expect(msg.content.some((part) => part.type === "text")).toBe(false); expect(msg.content).toMatchObject([{ type: "toolCall", name: "exec" }]); expect(msg.stopReason).toBe("toolUse"); }); @@ -2182,7 +2182,7 @@ describe("createOpenAIWebSocketStreamFn", () => { } | undefined; expect(doneEvent?.message.phase).toBeUndefined(); - expect(doneEvent?.message.content?.filter((part) => part.type === "text")).toEqual([]); + expect(doneEvent?.message.content?.some((part) => part.type === "text")).toBe(false); expect(doneEvent?.message.stopReason).toBe("toolUse"); }); @@ -2752,7 +2752,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); expect(events.filter((event) => event.type === "start")).toHaveLength(1); - expect(events.filter((event) => event.type === "error")).toEqual([]); + expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); }); @@ -2786,7 +2786,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); expect(events.filter((event) => event.type === "start")).toHaveLength(1); - expect(events.filter((event) => event.type === "error")).toEqual([]); + expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); }); diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index b920078c115..4c67541cbd6 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -607,7 +607,7 @@ describe("runContextEngineMaintenance", () => { (task) => task.taskKind === TURN_MAINTENANCE_TASK_KIND, ); expect(completedTasks).toHaveLength(2); - expect(completedTasks.filter((task) => task.status !== "succeeded")).toEqual([]); + expect(completedTasks.every((task) => task.status === "succeeded")).toBe(true); await foregroundTurn; } finally { @@ -684,7 +684,7 @@ describe("runContextEngineMaintenance", () => { (task) => task.taskKind === TURN_MAINTENANCE_TASK_KIND, ); expect(tasks).toHaveLength(2); - expect(tasks.filter((task) => task.status !== "succeeded")).toEqual([]); + expect(tasks.every((task) => task.status === "succeeded")).toBe(true); } finally { vi.useRealTimers(); } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 8e2fda36bc8..e3db6aa8ab9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -631,7 +631,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { .trim() .split("\n") .map((line) => JSON.parse(line) as TrajectoryEvent); - expect(trajectoryEvents.filter((event) => event.type === "prompt.submitted")).toEqual([]); + expect(trajectoryEvents.some((event) => event.type === "prompt.submitted")).toBe(false); expect(trajectoryEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts index 4d4ee31c0c6..6034ffdd033 100644 --- a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts +++ b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts @@ -79,7 +79,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { ], { cap: 0 }, ); - expect(steps.filter((step) => step.tripped)).toEqual([]); + expect(steps.some((step) => step.tripped)).toBe(false); expect(steps.at(-1)?.consecutive).toBe(7); }); @@ -94,7 +94,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { outputTokens: 220, })), ); - expect(steps.filter((step) => step.tripped)).toEqual([]); + expect(steps.some((step) => step.tripped)).toBe(false); expect(steps.at(-1)?.consecutive).toBe(0); }); From 84c4e66288edccea2537e18b893901ebf64a2310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:49:20 +0100 Subject: [PATCH 750/806] test: avoid zero length filter assertions --- extensions/qa-lab/src/lab-server.test.ts | 2 +- src/gateway/server-chat.agent-events.test.ts | 2 +- src/logging/diagnostic.test.ts | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index c6a44ad1d82..4d89348af02 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -673,7 +673,7 @@ describe("qa-lab server", () => { const snapshot = (await (await fetchWithRetry(`${lab.baseUrl}/api/state`)).json()) as { messages: Array<{ direction: string }>; }; - expect(snapshot.messages.filter((message) => message.direction === "outbound")).toHaveLength(0); + expect(snapshot.messages.some((message) => message.direction === "outbound")).toBe(false); }); it("exposes structured outcomes and can attach control-ui after startup", async () => { diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 6cae9b71477..32659259263 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -1538,7 +1538,7 @@ describe("agent event handler", () => { emitLifecycleEnd(handler, "run-hidden", 2); expect(chatBroadcastCalls(broadcast)).toHaveLength(0); - expect(broadcast.mock.calls.filter(([event]) => event === "agent")).toHaveLength(0); + expect(broadcast.mock.calls.some(([event]) => event === "agent")).toBe(false); expect(nodeSendToSession).not.toHaveBeenCalled(); expect(persistGatewaySessionLifecycleEventMock).toHaveBeenCalledWith({ sessionKey: "session-hidden", diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 12b71fbe89b..8fbf42f4c7d 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -261,7 +261,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(0); + expect(events.some((event) => event.type === "session.long_running")).toBe(false); const stuckEvents = events.filter((event) => event.type === "session.stuck"); expect(stuckEvents).toHaveLength(1); expect(stuckEvents[0]).toMatchObject({ @@ -298,9 +298,9 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); + expect(events.some((event) => event.type === "session.long_running")).toBe(false); }); it("backs off repeated stuck warnings while a session remains unchanged", () => { @@ -359,7 +359,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); const stalledEvents = events.filter((event) => event.type === "session.stalled"); expect(stalledEvents).toHaveLength(1); expect(stalledEvents[0]).toMatchObject({ @@ -657,8 +657,8 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); const longRunningEvents = events.filter((event) => event.type === "session.long_running"); expect(longRunningEvents).toHaveLength(1); expect(longRunningEvents[0]).toMatchObject({ @@ -737,8 +737,8 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); const longRunningEvents = events.filter((event) => event.type === "session.long_running"); expect(longRunningEvents).toHaveLength(1); expect(longRunningEvents[0]).toMatchObject({ @@ -1077,7 +1077,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); }); it("uses default threshold for invalid values", () => { From 6d785f01e862ba546baaefbb7a2d8e4111ec88ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:50:46 +0100 Subject: [PATCH 751/806] test: avoid diagnostic count filter allocations --- src/agents/openai-ws-stream.test.ts | 16 +++++++++++++--- src/logging/diagnostic.test.ts | 18 ++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index b90caff3e74..91d59977392 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -29,6 +29,16 @@ import type { InputItem, ResponseCreateEvent } from "./openai-ws-types.js"; import { log } from "./pi-embedded-runner/logger.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + // ───────────────────────────────────────────────────────────────────────────── // Mock OpenAIWebSocketManager // ───────────────────────────────────────────────────────────────────────────── @@ -2751,7 +2761,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); @@ -2785,7 +2795,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); @@ -2820,7 +2830,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls).toHaveLength(0); expect(firstManager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("retry succeeded"); }); diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 8fbf42f4c7d..a3f29555935 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -52,6 +52,16 @@ function flushDiagnosticEvents() { return new Promise((resolve) => setImmediate(resolve)); } +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("diagnostic session state pruning", () => { beforeEach(() => { vi.useFakeTimers(); @@ -691,7 +701,7 @@ describe("stuck session diagnostics threshold", () => { markDiagnosticEmbeddedRunStarted({ sessionId: "s1", sessionKey: "main" }); vi.advanceTimersByTime(16_000); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "session.long_running")).toBe(1); vi.advanceTimersByTime(28_000); emitDiagnosticEvent({ @@ -702,7 +712,7 @@ describe("stuck session diagnostics threshold", () => { }); vi.advanceTimersByTime(2_000); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "session.long_running")).toBe(1); } finally { unsubscribe(); } @@ -1038,14 +1048,14 @@ describe("stuck session diagnostics threshold", () => { vi.advanceTimersByTime(30_000); vi.advanceTimersByTime(90_000); - expect(events.filter((event) => event === "diagnostic.liveness.warning")).toHaveLength(1); + expect(countMatching(events, (event) => event === "diagnostic.liveness.warning")).toBe(1); vi.advanceTimersByTime(30_000); } finally { unsubscribe(); } - expect(events.filter((event) => event === "diagnostic.liveness.warning")).toHaveLength(2); + expect(countMatching(events, (event) => event === "diagnostic.liveness.warning")).toBe(2); }); it("does not start the heartbeat when diagnostics are disabled by config", () => { From 25985ebb8e540603741909de5f49a3a61203751f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:52:15 +0100 Subject: [PATCH 752/806] test: avoid sessions count filter allocations --- .../src/app-server/plugin-thread-config.test.ts | 4 +++- src/agents/openclaw-tools.sessions.test.ts | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 46ea221b8f8..8ed84af785a 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -261,7 +261,9 @@ describe("Codex plugin thread config", () => { pluginName: "google-calendar", }); expect(config.diagnostics).toEqual([]); - expect(request.mock.calls.filter(([method]) => method === "app/list")).toHaveLength(1); + expect( + request.mock.calls.reduce((count, [method]) => count + (method === "app/list" ? 1 : 0), 0), + ).toBe(1); }); it("does not expose plugin apps missing from the app inventory snapshot", async () => { diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index aa20da0f226..cb772b2ccec 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -52,6 +52,16 @@ const TEST_CONFIG = { }, } as OpenClawConfig; +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + const resolveSessionConversationStub: NonNullable< ChannelMessagingAdapter["resolveSessionConversation"] > = ({ rawId }) => ({ @@ -1248,7 +1258,7 @@ describe("sessions tools", () => { sessionKey: targetKey, }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls.filter((call) => call.method === "agent")).toHaveLength(1); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(1); }); it("sessions_send skips duplicate A2A delivery for waited parent-owned native subagents", async () => { @@ -1315,7 +1325,7 @@ describe("sessions tools", () => { reply: "child reply", delivery: { status: "skipped", mode: "announce" }, }); - expect(calls.filter((call) => call.method === "agent")).toHaveLength(1); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(1); const replyPromptAgentCalls = calls.filter( (call) => call.method === "agent" && @@ -1449,7 +1459,7 @@ describe("sessions tools", () => { }); await vi.waitFor( () => { - expect(calls.filter((call) => call.method === "send")).toHaveLength(1); + expect(countMatching(calls, (call) => call.method === "send")).toBe(1); }, { timeout: 2_000, interval: 5 }, ); From 016c8c996897084dd2222d4b0f987177a156edab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:53:22 +0100 Subject: [PATCH 753/806] test: avoid subagent count filter allocations --- .../subagent-registry.steer-restart.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 17e14bf7acd..4b6acb8c457 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -67,6 +67,17 @@ vi.mock("../config/sessions.js", () => { const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); const emitSessionLifecycleEventMock = vi.fn(); + +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + const noopContextEngine = { info: { id: "test-context-engine", name: "Test context engine" }, ingest: async () => ({ ingested: false }), @@ -687,7 +698,7 @@ describe("subagent registry steer restarts", () => { const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(1); }); emitLifecycleEnd("run-child"); @@ -695,15 +706,15 @@ describe("subagent registry steer restarts", () => { const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2); - expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(2); + expect(countMatching(childRunIds, (id) => id === "run-child")).toBe(1); }); const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2); - expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(2); + expect(countMatching(childRunIds, (id) => id === "run-child")).toBe(1); }); it("retries completion-mode announce delivery with backoff and then gives up after retry limit", async () => { From 9803a96adcf31b677c8d8778dac4ed8b6ce46fcd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:54:43 +0100 Subject: [PATCH 754/806] test: avoid cli count filter allocations --- src/cli/program/command-registry.test.ts | 6 ++++-- src/cli/program/register.subclis.test.ts | 2 +- src/plugins/cli.test.ts | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index c6c2bbc06d2..231d1f09dd9 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -173,8 +173,10 @@ describe("command-registry", () => { } const names = namesOf(program); - expect(names.filter((name) => name === "commitments")).toHaveLength(1); - expect(names.filter((name) => name === "tasks")).toHaveLength(1); + const countName = (target: string) => + names.reduce((count, name) => count + (name === target ? 1 : 0), 0); + expect(countName("commitments")).toBe(1); + expect(countName("tasks")).toBe(1); }); it("replaces placeholders when loading a grouped entry by secondary command name", async () => { diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 601975fa741..9cea56efd08 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -192,7 +192,7 @@ describe("registerSubCliCommands", () => { await registerSubCliByName(program, "acp"); const names = program.commands.map((cmd) => cmd.name()); - expect(names.filter((name) => name === "acp")).toHaveLength(1); + expect(names.reduce((count, name) => count + (name === "acp" ? 1 : 0), 0)).toBe(1); await program.parseAsync(["acp"], { from: "user" }); expect(registerAcpCli).toHaveBeenCalledTimes(1); diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 8217025062c..fa6167be265 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -397,7 +397,9 @@ describe("registerPluginCliCommands", () => { primary: "memory", }); - expect(program.commands.filter((command) => command.name() === "memory")).toHaveLength(1); + expect( + program.commands.reduce((count, command) => count + (command.name() === "memory" ? 1 : 0), 0), + ).toBe(1); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ onlyPluginIds: ["memory-core"], From 8543b386988948e888929787e8e283cf42561071 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:56:52 +0100 Subject: [PATCH 755/806] test: avoid infra count filter allocations --- src/infra/bonjour-discovery.test.ts | 2 +- src/infra/infra-runtime.test.ts | 16 +++++++++++++--- .../delivery-queue.reconnect-drain.test.ts | 12 +++++++++++- src/infra/state-migrations.orphan-keys.test.ts | 4 +++- src/tasks/task-registry.test.ts | 14 ++++++++++++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index bf866b491e7..34e5a22176b 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -305,7 +305,7 @@ describe("bonjour-discovery", () => { run: run as unknown as typeof runCommandWithTimeout, }); - expect(calls.filter((c) => c[1] === "-B")).toHaveLength(1); + expect(calls.reduce((count, c) => count + (c[1] === "-B" ? 1 : 0), 0)).toBe(1); expect(calls.find((c) => c[1] === "-B")?.[3]).toBe("local."); }); }); diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 2a758236a48..78a778b79a9 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -58,6 +58,16 @@ function withoutSigusr1Listeners(fn: () => void): void { } } +function countSigusr1Emits(calls: readonly unknown[][]): number { + let count = 0; + for (const args of calls) { + if (args[0] === "SIGUSR1") { + count += 1; + } + } + return count; +} + function withRestartSupervisorEnabled(fn: () => void): void { const originalVitest = process.env.VITEST; const originalNodeEnv = process.env.NODE_ENV; @@ -417,10 +427,10 @@ describe("infra runtime", () => { expect(second.cooldownMsApplied).toBe(30_000); await vi.advanceTimersByTimeAsync(29_999); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(1); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(1); await vi.advanceTimersByTimeAsync(1); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(2); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2); } finally { process.removeListener("SIGUSR1", handler); } @@ -447,7 +457,7 @@ describe("infra runtime", () => { expect(forced.cooldownMsApplied).toBe(0); await vi.advanceTimersByTimeAsync(0); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(2); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2); expect(peekGatewaySigusr1RestartReason()).toBe("update.run"); } finally { process.removeListener("SIGUSR1", handler); diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index 309a6e0f603..a4f9fa5e717 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -25,6 +25,16 @@ function normalizeReconnectAccountIdForTest(accountId?: string | null): string { return (accountId ?? "").trim() || "default"; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + async function drainDirectChatReconnectPending(opts: { accountId: string; deliver: DeliverFn; @@ -347,7 +357,7 @@ describe("drainPendingDeliveries for reconnect", () => { await startupRecovery; expect(deliver).toHaveBeenCalledTimes(2); - expect(deliveredTargets.filter((target) => target === "+1555")).toHaveLength(1); + expect(countMatching(deliveredTargets, (target) => target === "+1555")).toBe(1); expect(startupLog.info).toHaveBeenCalledWith( expect.stringContaining("Recovery skipped for delivery"), ); diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index f00df6d50d8..9be68d72ef0 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -149,7 +149,9 @@ describe("migrateOrphanedSessionKeys", () => { expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session"); expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session"); // The key must NOT have been merged into ops namespace - expect(Object.keys(store).filter((k) => k.startsWith("agent:ops:")).length).toBe(1); + expect( + Object.keys(store).reduce((count, k) => count + (k.startsWith("agent:ops:") ? 1 : 0), 0), + ).toBe(1); }); }); diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index cfd950defc8..7cf656c24b2 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -72,6 +72,16 @@ const hoisted = vi.hoisted(() => { }; }); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + vi.mock("../acp/control-plane/manager.js", () => ({ getAcpSessionManager: () => ({ cancelSession: hoisted.cancelSessionMock, @@ -1120,7 +1130,7 @@ describe("task-registry", () => { deliveryStatus: "pending", }); - expect(listTaskRecords().filter((task) => task.runId === "run-shared")).toHaveLength(2); + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-shared")).toBe(2); expect(findTaskByRunId("run-shared")).toMatchObject({ runtime: "acp", task: "Spawn ACP child", @@ -1367,7 +1377,7 @@ describe("task-registry", () => { }); expect(directTask.taskId).toBe(spawnedTask.taskId); - expect(listTaskRecords().filter((task) => task.runId === "run-collapse")).toHaveLength(1); + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-collapse")).toBe(1); expect(findTaskByRunId("run-collapse")).toMatchObject({ task: "Spawn ACP child", }); From 7645824c9c0ccf377a7288a83fbc1d5d2f81ece8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 21:58:28 +0100 Subject: [PATCH 756/806] test: avoid extension count filter allocations --- extensions/feishu/src/monitor.startup.test.ts | 4 +++- extensions/file-transfer/src/shared/policy.test.ts | 2 +- .../src/mattermost/monitor-websocket.test.ts | 14 ++++++++++++-- extensions/ollama/provider-discovery.test.ts | 10 ++++++---- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 3e7d0cf1b84..32b20c7c307 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -127,7 +127,9 @@ describe("Feishu monitor startup preflight", () => { try { await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); - expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1); + expect(started.reduce((count, accountId) => count + (accountId === "alpha" ? 1 : 0), 0)).toBe( + 1, + ); } finally { releaseStartedBetaProbe(); abortController.abort(); diff --git a/extensions/file-transfer/src/shared/policy.test.ts b/extensions/file-transfer/src/shared/policy.test.ts index af541b28043..6e37474d067 100644 --- a/extensions/file-transfer/src/shared/policy.test.ts +++ b/extensions/file-transfer/src/shared/policy.test.ts @@ -507,6 +507,6 @@ describe("persistAllowAlways", () => { }; }; const list = root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths; - expect(list.filter((p) => p === "/tmp/x").length).toBe(1); + expect(list.reduce((count, p) => count + (p === "/tmp/x" ? 1 : 0), 0)).toBe(1); }); }); diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts index a2fcf9c80fb..a95dd282299 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts @@ -6,6 +6,16 @@ import { WebSocketClosedBeforeOpenError, } from "./monitor-websocket.js"; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + class FakeWebSocket implements MattermostWebSocketLike { public readonly sent: string[] = []; public pingCalls = 0; @@ -172,8 +182,8 @@ describe("mattermost websocket monitor", () => { data: { token: "token" }, seq: 1, }); - expect(patches.filter((patch) => patch.connected === true)).toHaveLength(1); - expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2); + expect(countMatching(patches, (patch) => patch.connected === true)).toBe(1); + expect(countMatching(patches, (patch) => patch.connected === false)).toBe(2); }); it("dispatches reaction events to the reaction handler", async () => { diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index dc8eb2603c0..e27aa55eb27 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -25,13 +25,15 @@ describe("Ollama provider", () => { const fetchCallUrls = (fetchMock: ReturnType): string[] => fetchMock.mock.calls.map(([input]) => String(input)); + const countFetchCallUrls = (fetchMock: ReturnType, suffix: string): number => + fetchCallUrls(fetchMock).reduce((count, url) => count + (url.endsWith(suffix) ? 1 : 0), 0); + const expectDiscoveryCallCounts = ( fetchMock: ReturnType, params: { tags: number; show: number }, ) => { - const urls = fetchCallUrls(fetchMock); - expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(params.tags); - expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(params.show); + expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(params.tags); + expect(countFetchCallUrls(fetchMock, "/api/show")).toBe(params.show); }; async function withOllamaApiKey(run: () => Promise): Promise { @@ -148,7 +150,7 @@ describe("Ollama provider", () => { env: { OLLAMA_API_KEY: "test-key" }, }); - expect(fetchCallUrls(fetchMock).filter((url) => url.endsWith("/api/tags"))).toHaveLength(1); + expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1); // Native API strips /v1 suffix via resolveOllamaApiBase() expect(provider?.baseUrl).toBe("http://192.168.20.14:11434"); From 66232280b76bfa8c723c1f2fe0ca7aaf53a42006 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:00:29 +0100 Subject: [PATCH 757/806] test: avoid loader count filter allocations --- src/config/io.write-config.test.ts | 8 ++++++-- src/config/sessions/targets.test.ts | 14 ++++++++++++-- src/hooks/loader.test.ts | 7 ++++++- src/plugins/loader.test.ts | 14 ++++++++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 8979f89c275..ccf59142494 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -738,7 +738,9 @@ describe("config io write", () => { ]); await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(cleanRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.filter((entry) => entry.includes(".clobbered."))).toHaveLength(1); + expect( + entries.reduce((count, entry) => count + (entry.includes(".clobbered.") ? 1 : 0), 0), + ).toBe(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("Config auto-stripped non-JSON prefix:"), ); @@ -827,7 +829,9 @@ describe("config io write", () => { await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.filter((entry) => entry.includes(".rejected."))).toHaveLength(1); + expect( + entries.reduce((count, entry) => count + (entry.includes(".rejected.") ? 1 : 0), 0), + ).toBe(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Config write rejected:")); }); }); diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 5f7c6d77bb7..7b389f1b3e1 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -42,6 +42,16 @@ function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenCl }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { const customRoot = path.join(home, "custom-state"); const storePaths = await createAgentSessionStores(customRoot, agentIds); @@ -186,7 +196,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); expectTargetsToContainStores(targets, storePaths); - expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); + expect(countMatching(targets, (target) => target.storePath === storePaths.ops)).toBe(1); }); }); @@ -195,7 +205,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]); expectTargetsToContainStores(targets, storePaths); - expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); + expect(countMatching(targets, (target) => target.storePath === storePaths.ops)).toBe(1); }); }); diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index eed0b323d8e..476cb78afbd 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -292,7 +292,12 @@ describe("loader", () => { const event = createInternalHookEvent("command", "new", "test-session"); await triggerInternalHook(event); - expect(event.messages.filter((message) => message === "reloadable-hook")).toHaveLength(1); + expect( + event.messages.reduce( + (count, message) => count + (message === "reloadable-hook" ? 1 : 0), + 0, + ), + ).toBe(1); }); it("should support named exports", async () => { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a37dc2e6a4f..08bad34ba37 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -96,6 +96,16 @@ let cachedBundledMemoryDir = ""; type GlobalHookRunner = NonNullable>; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function expectGlobalHookRunner(runner: ReturnType): GlobalHookRunner { expect(runner).toEqual(expect.objectContaining({ hasHooks: expect.any(Function) })); if (runner === null) { @@ -2169,7 +2179,7 @@ module.exports = { id: "throws-after-import", register() {} };`, const event = createInternalHookEvent("gateway", "startup", "gateway:startup"); await triggerInternalHook(event); - expect(event.messages.filter((message) => message === "reload-hook-fired")).toHaveLength(1); + expect(countMatching(event.messages, (message) => message === "reload-hook-fired")).toBe(1); clearInternalHooks(); }); @@ -4008,7 +4018,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }); } };`, assert: (registry: ReturnType) => { - expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); + expect(countMatching(registry.channels, (entry) => entry.plugin.id === "demo")).toBe(1); expect( registry.channels.find((entry) => entry.plugin.id === "demo")?.plugin.meta?.label, ).toBe("Demo Duplicate"); From cd7f733a99254ebdc7e561a13eb3671e0824180e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:02:06 +0100 Subject: [PATCH 758/806] test: avoid agent count filter allocations --- src/agents/cli-runner.helpers.test.ts | 2 +- src/agents/openai-transport-stream.test.ts | 4 +++- .../compaction-successor-transcript.test.ts | 8 +++++--- src/agents/pi-hooks/compaction-safeguard.test.ts | 4 +++- src/agents/session-transcript-repair.test.ts | 2 +- src/agents/skills.loadworkspaceskillentries.test.ts | 4 +++- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 07c917cb96d..8f5b99b4fd9 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -437,7 +437,7 @@ describe("writeCliImages", () => { useResume: false, }); - expect(argv.filter((arg) => arg === "--image")).toHaveLength(1); + expect(argv.reduce((count, arg) => count + (arg === "--image" ? 1 : 0), 0)).toBe(1); expect(argv[argv.indexOf("--image") + 1]).toContain("openclaw-cli-images"); await expect(fs.readFile(prepared.imagePaths?.[0] ?? "")).resolves.toEqual( Buffer.from(explicitImage.data, "base64"), diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index a5eb59e49a7..eedfcf25fce 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1301,7 +1301,9 @@ describe("openai transport stream", () => { }>; }; - expect(params.input?.filter((item) => item.type === "reasoning")).toHaveLength(1); + expect( + params.input?.reduce((count, item) => count + (item.type === "reasoning" ? 1 : 0), 0), + ).toBe(1); const assistantMessage = params.input?.find( (item) => item.type === "message" && item.role === "assistant", ); diff --git a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts b/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts index b2ffbd0ce83..c385b5bb53f 100644 --- a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts +++ b/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts @@ -179,9 +179,11 @@ describe("rotateTranscriptAfterCompaction", () => { expect(entries.find((entry) => entry.id === staleModelId)).toBeUndefined(); expect(entries.find((entry) => entry.id === staleThinkingId)).toBeUndefined(); expect(entries.find((entry) => entry.id === staleSessionInfoId)).toBeUndefined(); - expect(entries.filter((entry) => entry.type === "model_change")).toHaveLength(1); - expect(entries.filter((entry) => entry.type === "thinking_level_change")).toHaveLength(1); - expect(entries.filter((entry) => entry.type === "session_info")).toHaveLength(1); + const countEntryType = (type: (typeof entries)[number]["type"]) => + entries.reduce((count, entry) => count + (entry.type === type ? 1 : 0), 0); + expect(countEntryType("model_change")).toBe(1); + expect(countEntryType("thinking_level_change")).toBe(1); + expect(countEntryType("session_info")).toBe(1); expect(entries.find((entry) => entry.type === "model_change")).toMatchObject({ provider: "openai", modelId: "gpt-5.2", diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 0354707ec11..931531423aa 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -899,7 +899,9 @@ describe("compaction-safeguard recent-turn preservation", () => { const identifiers = extractOpaqueIdentifiers( "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", ); - expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); // pragma: allowlist secret + expect( + identifiers.reduce((count, id) => count + (id === "A1B2C3D4E5F6" ? 1 : 0), 0), // pragma: allowlist secret + ).toBe(1); }); it("dedupes identifiers before applying the result cap", () => { diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index d3635036d6f..0e74006559e 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -175,7 +175,7 @@ describe("sanitizeToolUseResultPairing", () => { ]); const out = sanitizeToolUseResultPairing(input); - expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1); + expect(out.reduce((count, m) => count + (m.role === "toolResult" ? 1 : 0), 0)).toBe(1); }); it("drops duplicate tool results for the same id across the transcript", () => { diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 5f0ba4a3347..3b585b1ced9 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -561,7 +561,9 @@ describe("loadWorkspaceSkillEntries", () => { }, }).map((entry) => entry.skill.name); - expect(names.filter((name) => name.startsWith("nested-skill-"))).toHaveLength(2); + expect( + names.reduce((count, name) => count + (name.startsWith("nested-skill-") ? 1 : 0), 0), + ).toBe(2); expect( warn.mock.calls .map(([line]) => String(line)) From ce515dbf4de89d3eab6961b599fe099f4bbf0aff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:05:41 +0100 Subject: [PATCH 759/806] test: avoid misc count filter allocations --- src/auto-reply/reply/session-transcript-replay.test.ts | 2 +- src/cli/run-main.exit.test.ts | 7 ++++++- src/commands/models/list.status.test.ts | 4 +++- src/daemon/launchd.integration.e2e.test.ts | 2 +- src/gateway/gateway-misc.test.ts | 4 +++- src/logging/diagnostic-memory.test.ts | 7 ++++++- src/plugin-sdk/memory-host-events.test.ts | 6 ++++-- src/plugin-state/plugin-state-store.test.ts | 2 +- src/tui/tui-event-handlers.test.ts | 2 +- test/scripts/docker-e2e-plan.test.ts | 8 ++++---- 10 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/session-transcript-replay.test.ts b/src/auto-reply/reply/session-transcript-replay.test.ts index 76d83a62a89..34961b862fd 100644 --- a/src/auto-reply/reply/session-transcript-replay.test.ts +++ b/src/auto-reply/reply/session-transcript-replay.test.ts @@ -73,7 +73,7 @@ describe("replayRecentUserAssistantMessages", () => { .split(/\r?\n/) .filter((line) => line.trim().length > 0) .map((line) => JSON.parse(line)); - expect(records.filter((r) => r.type === "session")).toHaveLength(1); + expect(records.reduce((count, r) => count + (r.type === "session" ? 1 : 0), 0)).toBe(1); expect(records[0]).toMatchObject({ id: "existing" }); expect(records[1].message.role).toBe("user"); }); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 4510e918356..09b81a5f028 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -582,7 +582,12 @@ describe("runCli exit behavior", () => { try { const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]); await vi.waitFor(() => { - expect(processOnceSpy.mock.calls.filter(([event]) => event === "exit")).toHaveLength(2); + expect( + processOnceSpy.mock.calls.reduce( + (count, [event]) => count + (event === "exit" ? 1 : 0), + 0, + ), + ).toBe(2); }); const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1]; diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 371ae1f0cbe..e4aaeadac71 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -585,7 +585,9 @@ describe("modelsStatusCommand auth overview", () => { await modelsStatusCommand({ json: true }, aliasRuntime as never); const aliasPayload = JSON.parse(String((aliasRuntime.log as Mock).mock.calls[0]?.[0])); const providers = aliasPayload.auth.providers as Array<{ provider: string }>; - expect(providers.filter((provider) => provider.provider === "zai")).toHaveLength(1); + expect( + providers.reduce((count, provider) => count + (provider.provider === "zai" ? 1 : 0), 0), + ).toBe(1); expect(providers.map((provider) => provider.provider)).not.toContain("z.ai"); } finally { if (originalLoadConfig) { diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index ef4bc98dd4e..5f4dbf4725b 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -294,7 +294,7 @@ describeLaunchdIntegration("launchd integration", () => { }); const events = await fs.readFile(eventsPath, "utf8"); const lines = events.trim().split(/\r?\n/).filter(Boolean); - expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1); + expect(lines.reduce((count, line) => count + (line.startsWith("start ") ? 1 : 0), 0)).toBe(1); const signalLines = lines.filter((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line)); expect(signalLines).toEqual([]); }, 60_000); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 20410d0af4c..285eb01df7d 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -631,7 +631,9 @@ describe("gateway broadcaster", () => { reason: "ws_send_buffer_drop", }), ); - expect(events.filter((event) => event.type === "payload.large")).toHaveLength(1); + expect( + events.reduce((count, event) => count + (event.type === "payload.large" ? 1 : 0), 0), + ).toBe(1); } finally { stop(); resetDiagnosticEventsForTest(); diff --git a/src/logging/diagnostic-memory.test.ts b/src/logging/diagnostic-memory.test.ts index 2a1b224b500..bc0af7485f3 100644 --- a/src/logging/diagnostic-memory.test.ts +++ b/src/logging/diagnostic-memory.test.ts @@ -149,6 +149,11 @@ describe("diagnostic memory", () => { } stop(); - expect(events.filter((event) => event.type === "diagnostic.memory.pressure")).toHaveLength(1); + expect( + events.reduce( + (count, event) => count + (event.type === "diagnostic.memory.pressure" ? 1 : 0), + 0, + ), + ).toBe(1); }); }); diff --git a/src/plugin-sdk/memory-host-events.test.ts b/src/plugin-sdk/memory-host-events.test.ts index 92fa5c6844b..fa2f8269c74 100644 --- a/src/plugin-sdk/memory-host-events.test.ts +++ b/src/plugin-sdk/memory-host-events.test.ts @@ -135,8 +135,10 @@ describe("createClaimableDedupe", () => { await expect(dedupe.claim("line:evt-1")).resolves.toEqual({ kind: "duplicate" }); const claims = await Promise.all([dedupe.claim("line:race-1"), dedupe.claim("line:race-1")]); - expect(claims.filter((claim) => claim.kind === "claimed")).toHaveLength(1); - expect(claims.filter((claim) => claim.kind === "inflight")).toHaveLength(1); + const countClaimKind = (kind: (typeof claims)[number]["kind"]) => + claims.reduce((count, claim) => count + (claim.kind === kind ? 1 : 0), 0); + expect(countClaimKind("claimed")).toBe(1); + expect(countClaimKind("inflight")).toBe(1); const waitingClaim = claims.find((claim) => claim.kind === "inflight"); await expect(dedupe.commit("line:race-1")).resolves.toBe(true); diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index a613fc93908..cab16747406 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -150,7 +150,7 @@ describe("plugin state keyed store", () => { ), ); - expect(attempts.filter(Boolean)).toHaveLength(1); + expect(attempts.reduce((count, attempt) => count + (attempt ? 1 : 0), 0)).toBe(1); const stored = await store.lookup("claim"); if (stored === undefined) { throw new Error("expected winning plugin-state claim"); diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 620c3779041..9289f465d76 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -1226,7 +1226,7 @@ describe("tui-event-handlers: streaming watchdog", () => { vi.advanceTimersByTime(10_000); const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]); - expect(statusCalls.filter((s) => s === "idle").length).toBe(1); + expect(statusCalls.reduce((count, s) => count + (s === "idle" ? 1 : 0), 0)).toBe(1); expect(chatLog.addSystem).not.toHaveBeenCalledWith(expectedTimeoutMessage); expect(state.activeChatRunId).toBeNull(); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 0d53e3b99d3..80f929657c4 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -60,10 +60,10 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.lanes.map((lane) => lane.name)).toContain("commitments-safety"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-0"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-23"); - expect(plan.lanes.filter((lane) => lane.name === "install-e2e-openai")).toHaveLength(1); - expect( - plan.lanes.filter((lane) => lane.name === "bundled-plugin-install-uninstall-0"), - ).toHaveLength(1); + const countLane = (name: string) => + plan.lanes.reduce((count, lane) => count + (lane.name === name ? 1 : 0), 0); + expect(countLane("install-e2e-openai")).toBe(1); + expect(countLane("bundled-plugin-install-uninstall-0")).toBe(1); expect(plan.lanes.map((lane) => lane.name)).not.toContain("bundled-plugin-install-uninstall"); expect(plan.lanes.map((lane) => lane.name)).not.toContain("bundled-channel-deps"); expect(plan.lanes.map((lane) => lane.name)).not.toContain("openwebui"); From 7188ab7f6b16f69e99d127dd64bbdf74daf287e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:07:09 +0100 Subject: [PATCH 760/806] test: clear exact count filter assertions --- .../agent-runner.runreplyagent.e2e.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 78ee122be98..b1eeb91c57f 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -30,6 +30,16 @@ const state = vi.hoisted(() => ({ runEmbeddedPiAgentMock: vi.fn(), })); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + let modelFallbackModule: typeof import("../../agents/model-fallback.js"); let onAgentEvent: typeof import("../../infra/agent-events.js").onAgentEvent; @@ -1000,8 +1010,8 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(firstText).toContain("Model Fallback:"); expect(secondText).toContain("Model Fallback cleared:"); expect(thirdText).not.toContain("Model Fallback cleared:"); - expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1); - expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1); + expect(countMatching(phases, (phase) => phase === "fallback")).toBe(1); + expect(countMatching(phases, (phase) => phase === "fallback_cleared")).toBe(1); } finally { fallbackSpy.mockRestore(); } @@ -1077,8 +1087,8 @@ describe("runReplyAgent typing (heartbeat)", () => { const secondText = Array.isArray(second) ? second[0]?.text : second?.text; expect(firstText).not.toContain("Model Fallback:"); expect(secondText).not.toContain("Model Fallback cleared:"); - expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1); - expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1); + expect(countMatching(phases, (phase) => phase === "fallback")).toBe(1); + expect(countMatching(phases, (phase) => phase === "fallback_cleared")).toBe(1); } finally { fallbackSpy.mockRestore(); } From 27ddb6bea284315582e20b99ed396eb5b65d2462 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:08:55 +0100 Subject: [PATCH 761/806] test: avoid core count filter predicates --- src/agents/openclaw-tools.sessions.test.ts | 2 +- ....subagents.sessions-spawn.lifecycle.test.ts | 18 ++++++++++++++---- src/agents/sandbox/fs-bridge.shell.test.ts | 14 +++++++++++++- src/auto-reply/reply/acp-projector.test.ts | 12 +++++++++++- src/tasks/task-registry.test.ts | 2 +- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index cb772b2ccec..2dd0ecc1eb5 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1062,7 +1062,7 @@ describe("sessions tools", () => { }); await vi.waitFor( () => { - expect(calls.filter((call) => call.method === "agent")).toHaveLength(3); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(3); }, { timeout: 2_000, interval: 5 }, ); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index a4bf1a2b057..8962a105964 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -52,6 +52,16 @@ vi.mock("./tools/agent-step.js", () => ({ const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { return { onAgentSubagentSpawn: (params: unknown) => { @@ -235,7 +245,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { () => ctx.waitCalls.some((call) => call.runId === child.runId) && patchCalls.some((call) => call.label === "my-task") && - ctx.calls.filter((call) => call.method === "agent").length >= 2, + countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); if (!child.sessionKey) { throw new Error("missing child sessionKey"); @@ -371,7 +381,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitForSessionsSpawnEvent( "lifecycle cleanup", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey), + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2 && Boolean(deletedKey), ); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); @@ -437,7 +447,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { ); await waitForSessionsSpawnEvent( "main agent cleanup trigger", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2, + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); await waitForSessionsSpawnEvent("delete cleanup", () => Boolean(deletedKey)); @@ -563,7 +573,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitForSessionsSpawnEvent( "account-aware lifecycle announce", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2, + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); await waitForRunCleanup(child.sessionKey); diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index db29883896c..866f2c96109 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -20,6 +20,16 @@ function expectSomeScriptContaining(scripts: string[], needle: string) { expect(scripts.some((script) => script.includes(needle))).toBe(true); } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("sandbox fs bridge shell compatibility", () => { installFsBridgeTestHarness(); @@ -157,7 +167,9 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.rename({ from: "a.txt", to: "nested/b.txt" }); const scripts = getScriptsFromCalls(); - expect(scripts.filter((script) => script.includes("operation = sys.argv[1]")).length).toBe(3); + expect(countMatching(scripts, (script) => script.includes("operation = sys.argv[1]"))).toBe( + 3, + ); expectNoScriptsContaining(scripts, 'mkdir -p -- "$2"'); expectNoScriptsContaining(scripts, 'rm -f -- "$2"'); expectNoScriptsContaining(scripts, 'mv -- "$3" "$2/$4"'); diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index 7402e07d1cc..5d6eddf3eff 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -5,6 +5,16 @@ import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js type Delivery = { kind: string; text?: string }; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function createProjectorHarness( cfgOverrides?: Parameters[0], opts?: { onProgress?: () => void }, @@ -567,7 +577,7 @@ describe("createAcpReplyProjector", () => { }); await projector.flush(true); - expect(deliveries.filter((entry) => entry.kind === "tool").length).toBe(4); + expect(countMatching(deliveries, (entry) => entry.kind === "tool")).toBe(4); expect(deliveries[0]).toEqual({ kind: "tool", text: prefixSystemMessage("available commands updated"), diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index 7cf656c24b2..4c6451c2883 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -1233,7 +1233,7 @@ describe("task-registry", () => { await maybeDeliverTaskTerminalUpdate(spawnedTask.taskId); expect(hoisted.sendMessageMock).toHaveBeenCalledTimes(1); - expect(listTaskRecords().filter((task) => task.runId === "run-shared-delivery")).toHaveLength( + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-shared-delivery")).toBe( 1, ); expect(findTaskByRunId("run-shared-delivery")).toMatchObject({ From 2c7f2d3ac2a219923e9713001c071c5fbf48478b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:11:01 +0100 Subject: [PATCH 762/806] test: avoid extension count filter predicates --- .../src/app-server/plugin-thread-config.test.ts | 4 +--- extensions/google/oauth.test.ts | 12 +++++++++++- extensions/memory-wiki/src/bridge.test.ts | 4 +++- .../src/substrate/harness.runtime.test.ts | 14 ++++++++++++-- .../synology-chat/src/webhook-handler.test.ts | 14 ++++++++++++-- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 8ed84af785a..08fe1bf8e7c 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -393,9 +393,7 @@ describe("Codex plugin thread config", () => { }); expect(config.diagnostics).toEqual([]); expect(request.mock.calls.map(([method]) => method)).toContain("plugin/install"); - expect(request.mock.calls.filter(([method]) => method === "app/list").length).toBeGreaterThan( - 0, - ); + expect(request.mock.calls.some(([method]) => method === "app/list")).toBe(true); expect(appListParams.map((params) => params.forceRefetch)).toContain(true); }); diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 03c99d3a547..1274fffa620 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -46,6 +46,16 @@ const mockReaddirSync = vi.fn(); const mockSettingsExistsSync = vi.fn(); const mockSettingsReadFileSync = vi.fn(); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("resolveGeminiCliSelectedAuthType", () => { const ENV_KEYS = ["GOOGLE_GENAI_USE_GCA"] as const; @@ -843,7 +853,7 @@ describe("loginGeminiCliOAuth", () => { }); await runProjectDiscoveryExpectingProjectId("env-project"); - expect(requests.filter(({ url }) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); + expect(countMatching(requests, ({ url }) => url.includes("v1internal:loadCodeAssist"))).toBe(3); expect(requests.map(({ url }) => url)).not.toEqual( expect.arrayContaining([expect.stringContaining("v1internal:onboardUser")]), ); diff --git a/extensions/memory-wiki/src/bridge.test.ts b/extensions/memory-wiki/src/bridge.test.ts index 54e9b0eeab1..ec98804c4d9 100644 --- a/extensions/memory-wiki/src/bridge.test.ts +++ b/extensions/memory-wiki/src/bridge.test.ts @@ -128,7 +128,9 @@ describe("syncMemoryWikiBridgeSources", () => { expect(first.pagePaths).toHaveLength(3); const sourcePages = await fs.readdir(path.join(vaultDir, "sources")); - expect(sourcePages.filter((name) => name.startsWith("bridge-"))).toHaveLength(3); + expect( + sourcePages.reduce((count, name) => count + (name.startsWith("bridge-") ? 1 : 0), 0), + ).toBe(3); const memoryPage = await fs.readFile(path.join(vaultDir, first.pagePaths[0] ?? ""), "utf8"); expect(memoryPage).toContain("sourceType: memory-bridge"); diff --git a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts index 39873474285..e25546ca8c9 100644 --- a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts +++ b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts @@ -45,6 +45,16 @@ function createContainerNetworkRunCommand(calls?: string[]) { }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("matrix harness runtime", () => { it("writes a pinned Tuwunel compose file and redacted manifest", async () => { const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-")); @@ -180,7 +190,7 @@ describe("matrix harness runtime", () => { return { ok: input === "http://127.0.0.1:28008/_matrix/client/versions" && - fetchCalls.filter((url) => url === input).length > 1, + countMatching(fetchCalls, (url) => url === input) > 1, }; }), sleepImpl: vi.fn(async () => {}), @@ -208,7 +218,7 @@ describe("matrix harness runtime", () => { return { ok: input === "http://172.18.0.10:8008/_matrix/client/versions" && - fetchCalls.filter((url) => url === input).length > 1, + countMatching(fetchCalls, (url) => url === input) > 1, }; }), sleepImpl: vi.fn(async () => {}), diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index a69a801f705..07330148fe9 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -16,6 +16,16 @@ type TestLog = { error: (...args: unknown[]) => void; }; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function makeAccount( overrides: Partial = {}, ): ResolvedSynologyChatAccount { @@ -240,8 +250,8 @@ describe("createWebhookHandler", () => { await new Promise((resolve) => setTimeout(resolve, 0)); // Default maxInFlightPerKey is 8; 12 total requests leaves 4 rejected with 429. - expect(responses.filter((res) => res._status === 0)).toHaveLength(8); - expect(responses.filter((res) => res._status === 429)).toHaveLength(4); + expect(countMatching(responses, (res) => res._status === 0)).toBe(8); + expect(countMatching(responses, (res) => res._status === 429)).toBe(4); for (const req of requests) { req.emit("end"); From 2f26025085112c182796711a4e55f5cfbe38fe61 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 8 May 2026 17:12:48 -0400 Subject: [PATCH 763/806] fix(active-memory): allow active-memory to support custom recall tools (#77906) * fix(active-memory): allow custom recall tools * docs(active-memory): document custom recall tools * docs(active-memory): note tools allowlist change * fix(active-memory): constrain recall tool allowlist * fix(active-memory): preserve lancedb recall defaults * fix(active-memory): block non-memory recall tools * fix(active-memory): satisfy bundled lint * fix(active-memory): satisfy type-aware lint * fix(tests): satisfy type-aware lint * fix(tests): clear next type-aware lint batch * fix(tests): clear lint and test type annotations * docs(changelog): consolidate active memory entry * docs(changelog): reclassify active memory tools entry --- CHANGELOG.md | 1 + docs/concepts/active-memory.md | 159 +++++++-- extensions/active-memory/config.test.ts | 28 ++ extensions/active-memory/index.test.ts | 306 +++++++++++++++--- extensions/active-memory/index.ts | 167 ++++++++-- extensions/active-memory/openclaw.plugin.json | 12 + .../src/channel.message-adapter.test.ts | 2 +- .../src/channel.message-adapter.test.ts | 11 +- .../src/channel.message-adapter.test.ts | 2 +- .../tlon/src/channel.message-adapter.test.ts | 2 +- ...o-reply.connection-and-logging.e2e.test.ts | 3 + .../command/attempt-execution.cli.test.ts | 3 +- .../pi-hooks/compaction-safeguard.test.ts | 3 +- .../pi-tools.read.host-edit-access.test.ts | 7 +- src/agents/tools/sessions-spawn-tool.test.ts | 16 +- src/commands/backup.test.ts | 4 +- src/gateway/openresponses-http.test.ts | 2 +- 17 files changed, 615 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeac2f8b820..7fe061bbfb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Active Memory: support concrete `plugins.entries.active-memory.config.toolsAllow` recall tool names for custom memory plugins while keeping the built-in memory-core default on `memory_search`/`memory_get` and preserving `memory_recall` automatically for `plugins.slots.memory: "memory-lancedb"`. - Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index ded9c3bb3aa..8d3844e32cd 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -332,12 +332,16 @@ flowchart LR I --> M["Main Reply"] ``` -The blocking memory sub-agent can use only the available memory recall tools: +The blocking memory sub-agent can use only the configured memory recall tools. +By default that is: -- `memory_recall` - `memory_search` - `memory_get` +When `plugins.slots.memory` is `memory-lancedb`, the default is `memory_recall` +instead. Set `config.toolsAllow` when another memory provider exposes a +different recall tool contract. + If the connection is weak, it should return `NONE`. ## Query modes @@ -462,6 +466,110 @@ skips recall for that turn. `config.modelFallbackPolicy` is retained only as a deprecated compatibility field for older configs. It no longer changes runtime behavior. +## Memory tools + +By default Active Memory lets the blocking recall sub-agent call +`memory_search` and `memory_get`. That matches the built-in `memory-core` +contract. When `plugins.slots.memory` selects `memory-lancedb` and +`config.toolsAllow` is unset, Active Memory keeps the existing LanceDB behavior +and uses `memory_recall` instead. + +If you use another memory plugin, set `config.toolsAllow` to the exact tool +names that plugin registers. Active Memory lists those tools in the recall +prompt and passes the same list to the embedded sub-agent. If none of the +configured tools are available, or the memory sub-agent fails, Active Memory +skips recall for that turn and the main reply continues without memory context. +`toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*` +entries, and core agent tools such as `read`, `exec`, `message`, and +`web_search` are ignored before the hidden memory sub-agent starts. + +Default-behavior note: Active Memory no longer includes `memory_recall` in the +memory-core default allowlist. Existing `memory-lancedb` setups keep working +when `plugins.slots.memory` is set to `memory-lancedb`. Explicit `toolsAllow` +always overrides the automatic default. + +### Built-in memory-core + +The default setup does not need an explicit `toolsAllow`: + +```json5 +{ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + agents: ["main"], + // Default: ["memory_search", "memory_get"] + }, + }, + }, + }, +} +``` + +### LanceDB memory + +The bundled `memory-lancedb` plugin exposes `memory_recall`. Selecting the +memory slot is enough for Active Memory to use that recall tool: + +```json5 +{ + plugins: { + slots: { + memory: "memory-lancedb", + }, + entries: { + "memory-lancedb": { + enabled: true, + config: { + embedding: { + provider: "openai", + model: "text-embedding-3-small", + }, + }, + }, + "active-memory": { + enabled: true, + config: { + agents: ["main"], + promptAppend: "Use memory_recall for long-term user preferences, past decisions, and previously discussed topics. If recall finds nothing useful, return NONE.", + }, + }, + }, + }, +} +``` + +### Lossless Claw + +Lossless Claw is a context-engine plugin with its own recall tools. Install and +configure it as a context engine first; see [Context engine](/concepts/context-engine). +Then let Active Memory use the Lossless Claw recall tools: + +```json5 +{ + plugins: { + entries: { + "lossless-claw": { + enabled: true, + }, + "active-memory": { + enabled: true, + config: { + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + promptAppend: "Use lcm_grep first for compacted conversation recall. Use lcm_describe to inspect a specific summary. Use lcm_expand_query only when the latest user message needs exact details that may have been compacted away. Return NONE if the retrieved context is not clearly useful.", + }, + }, + }, + }, +} +``` + +Do not include `lcm_expand` in `toolsAllow` for the main Active Memory sub-agent. +Lossless Claw uses that as a lower-level delegated expansion tool. + ## Advanced escape hatches These options are intentionally not part of the recommended setup. @@ -488,6 +596,9 @@ Memory prompt and before the conversation context: promptAppend: "Prefer stable long-term preferences over one-off events." ``` +Use `promptAppend` with custom `toolsAllow` when a non-core memory plugin needs +provider-specific tool order or query-shaping instructions. + `config.promptOverride` replaces the default Active Memory prompt. OpenClaw still appends the conversation context afterward: @@ -558,25 +669,26 @@ plugins.entries.active-memory The most important fields are: -| Key | Type | Meaning | -| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Enables the plugin itself | -| `config.agents` | `string[]` | Agent ids that may use active memory | -| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | -| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | -| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | -| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | -| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | -| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | -| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | -| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | -| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | -| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | -| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance | -| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | -| `config.logging` | `boolean` | Emits active memory logs while tuning | -| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | -| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | +| Key | Type | Meaning | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Enables the plugin itself | +| `config.agents` | `string[]` | Agent ids that may use active memory | +| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | +| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | +| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | +| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | +| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | +| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | +| `config.toolsAllow` | `string[]` | Concrete memory tool names the blocking memory sub-agent may call; defaults to `["memory_search", "memory_get"]`, or `["memory_recall"]` when `plugins.slots.memory` is `memory-lancedb`; wildcards, `group:*` entries, and core agent tools are ignored | +| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | +| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | +| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | +| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | +| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance | +| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | +| `config.logging` | `boolean` | Emits active memory logs while tuning | +| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | +| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | Useful tuning fields: @@ -692,8 +804,9 @@ If active memory is too slow: Active Memory rides on the configured memory plugin's recall pipeline, so most recall surprises are embedding-provider problems, not Active Memory bugs. The -default `memory-core` path uses `memory_search`; `memory-lancedb` uses -`memory_recall`. +default `memory-core` path uses `memory_search` and `memory_get`; the +`memory-lancedb` slot uses `memory_recall`. If you use another memory plugin, +confirm `config.toolsAllow` names the tools that plugin actually registers. diff --git a/extensions/active-memory/config.test.ts b/extensions/active-memory/config.test.ts index 1b9aa512ebd..eabbb7e42f7 100644 --- a/extensions/active-memory/config.test.ts +++ b/extensions/active-memory/config.test.ts @@ -22,6 +22,34 @@ describe("active-memory manifest config schema", () => { expect(result.ok).toBe(true); }); + it("accepts custom toolsAllow entries", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.tools-allow", + value: { + enabled: true, + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects wildcard and group toolsAllow entries", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.tools-allow.reserved", + value: { + enabled: true, + agents: ["main"], + toolsAllow: ["*", "group:plugins"], + }, + }); + + expect(result.ok).toBe(false); + }); + it("accepts timeoutMs values at the runtime ceiling", () => { const result = validateJsonSchemaValue({ schema: manifest.configSchema, diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 98c3d1b82be..07ba5d7213f 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -67,6 +67,19 @@ describe("active-memory plugin", () => { }, }; }; + const setMemorySlot = (memory: string) => { + const plugins = configFile.plugins as Record | undefined; + configFile = { + ...configFile, + plugins: { + ...plugins, + slots: { + ...(plugins?.slots as Record | undefined), + memory, + }, + }, + }; + }; const api: any = { get pluginConfig() { return pluginConfig; @@ -147,7 +160,7 @@ describe("active-memory plugin", () => { }; const makeMemoryToolAllowlistError = ( reason: string, - sources = "runtime toolsAllow: memory_recall, memory_search, memory_get", + sources = "runtime toolsAllow: memory_search, memory_get", ) => new Error( `No callable tools remain after resolving explicit tool allowlist ` + @@ -1285,16 +1298,17 @@ describe("active-memory plugin", () => { ); expect(runParams?.prompt).toContain("Use only the available memory tools."); expect(runParams?.prompt).toContain( - "Use the bounded search query as the memory_search or memory_recall query.", + "Use the bounded search query with the configured memory tools.", ); - expect(runParams?.prompt).toContain("Prefer memory_recall when available."); + expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get."); expect(runParams?.prompt).toContain( - "If memory_recall is unavailable, use memory_search and memory_get.", + "If the available memory tools find nothing useful, reply with NONE.", ); - expect(runParams?.toolsAllow).toEqual(["memory_recall", "memory_search", "memory_get"]); + expect(runParams?.prompt).not.toContain("memory_recall"); + expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]); expect(runParams?.allowGatewaySubagentBinding).toBe(true); expect(runParams?.prompt).toContain( - "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", + "When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.", ); expect(runParams?.prompt).toContain( "If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.", @@ -1318,6 +1332,187 @@ describe("active-memory plugin", () => { ); }); + it("passes custom configured memory tools and reflects them in the default prompt", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: [" lcm_grep ", "lcm_describe", "", "lcm_expand_query", "lcm_grep"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe", "lcm_expand_query"]); + expect(runParams?.prompt).toContain( + "Configured memory tools: lcm_grep, lcm_describe, lcm_expand_query.", + ); + expect(runParams?.prompt).not.toContain("Prefer memory_recall"); + expect(runParams?.prompt).not.toContain("If memory_recall is unavailable"); + }); + + it("uses memory_recall by default when the memory slot selects LanceDB", async () => { + setMemorySlot("memory-lancedb"); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_recall"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_recall."); + }); + + it("keeps explicit custom memory tools authoritative when the memory slot selects LanceDB", async () => { + setMemorySlot("memory-lancedb"); + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["lcm_grep"], + }; + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep"]); + expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep."); + }); + + it("drops wildcard group and core tools from custom memory tools", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: [ + "*", + "agents_list", + "apply_patch", + "canvas", + "cron", + "edit", + "gateway", + "heartbeat_respond", + "heartbeat_response", + "image", + "image_generate", + "music_generate", + "nodes", + "pdf", + "process", + "session_status", + "sessions_history", + "sessions_list", + "sessions_send", + "sessions_spawn", + "sessions_yield", + "tts", + "video_generate", + "group:plugins", + "read", + "exec", + "message", + "lcm_grep", + "web_search", + "lcm_describe", + ], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe"]); + expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep, lcm_describe."); + }); + + it("falls back to default memory tools when custom memory tools only contain reserved entries", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get."); + }); + + it("falls back to LanceDB compat tools when custom memory tools only contain reserved entries", async () => { + setMemorySlot("memory-lancedb"); + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], + }; + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_recall"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_recall."); + }); + it("defaults prompt style by query mode when no promptStyle is configured", async () => { api.pluginConfig = { agents: ["main"], @@ -1871,7 +2066,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); expect(hasWarnLine("No callable tools remain")).toBe(false); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]); @@ -1886,7 +2081,7 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError( "no registered tools matched", - "tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get", + "tools.allow: *, lobster; runtime toolsAllow: memory_search, memory_get", ); expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); runEmbeddedPiAgent.mockRejectedValueOnce(error); @@ -1897,14 +2092,46 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); expect(hasWarnLine("No callable tools remain")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), ]); }); - it("keeps memory-tool allowlist errors visible when upstream policy can filter memory tools", async () => { + it("skips missing custom memory tools using the resolved custom allowlist", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + logging: true, + }; + plugin.register(api as unknown as OpenClawPluginApi); + const sessionKey = "agent:main:missing-custom-memory-tools"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-custom-memory-tools", + updatedAt: 0, + }; + const toolsAllow = ["lcm_grep", "lcm_describe", "lcm_expand_query"]; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + `runtime toolsAllow: ${toolsAllow.join(", ")}`, + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error, toolsAllow)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what did we decide? missing custom memory tools", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no configured memory tools available")).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }); + + it("skips memory-tool allowlist errors when upstream policy filters memory tools", async () => { const sessionKey = "agent:main:memory-tools-filtered-by-policy"; hoisted.sessionStore[sessionKey] = { sessionId: "s-memory-tools-filtered-by-policy", @@ -1912,9 +2139,9 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError( "no registered tools matched", - "tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get", + "tools.allow: read, exec; runtime toolsAllow: memory_search, memory_get", ); - expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); runEmbeddedPiAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( @@ -1923,38 +2150,41 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); - expect(hasWarnLine("No callable tools remain")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), + expect.stringContaining("🧩 Active Memory: status=empty"), ]); }); it.each([ ["disabled tools", "tools are disabled for this run"], ["models without tool support", "the selected model does not support tools"], - ])("keeps allowlist errors for %s visible", async (_label, reason) => { - const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; - hoisted.sessionStore[sessionKey] = { - sessionId: `s-${reason.replace(/\W+/g, "-")}`, - updatedAt: 0, - }; - const error = makeMemoryToolAllowlistError(reason); - expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + ])( + "skips allowlist errors for %s without surfacing to the main thread", + async (_label, reason) => { + const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; + hoisted.sessionStore[sessionKey] = { + sessionId: `s-${reason.replace(/\W+/g, "-")}`, + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError(reason); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); - const result = await hooks.before_prompt_build( - { prompt: `what wings should i order? ${reason}`, messages: [] }, - { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, - ); + const result = await hooks.before_prompt_build( + { prompt: `what wings should i order? ${reason}`, messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); - expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); - expect(hasWarnLine(reason)).toBe(true); - expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), - ]); - }); + expect(result).toBeUndefined(); + expect(hasDebugLine("no configured memory tools available")).toBe(false); + expect(hasWarnLine(reason)).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }, + ); it("does not skip missing memory-tool allowlist errors after abort", async () => { const sessionKey = "agent:main:missing-memory-tools-after-abort"; @@ -1976,7 +2206,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasDebugLine("no configured memory tools available")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=timeout"), ]); @@ -2258,7 +2488,7 @@ describe("active-memory plugin", () => { expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain("partial abort summary"); }); - it("keeps generic subagent errors unavailable without using partial transcript output", async () => { + it("skips generic subagent errors without using partial transcript output", async () => { api.pluginConfig = { agents: ["main"], persistTranscripts: true, @@ -2287,7 +2517,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), + expect.stringContaining("🧩 Active Memory: status=empty"), ]); expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain( "must not be surfaced from generic errors", diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index ec14a051546..8bb40fcc138 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -42,7 +42,43 @@ const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3; const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000; -const ACTIVE_MEMORY_TOOL_ALLOWLIST = ["memory_recall", "memory_search", "memory_get"] as const; +const DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_search", "memory_get"] as const; +const LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_recall"] as const; +const MAX_ACTIVE_MEMORY_TOOLS_ALLOW = 32; +const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([ + "*", + "agents_list", + "apply_patch", + "browser", + "canvas", + "cron", + "edit", + "exec", + "gateway", + "heartbeat_respond", + "heartbeat_response", + "image", + "image_generate", + "message", + "music_generate", + "nodes", + "pdf", + "process", + "read", + "session_status", + "sessions_history", + "sessions_list", + "sessions_send", + "sessions_spawn", + "sessions_yield", + "subagents", + "tts", + "update_plan", + "video_generate", + "web_fetch", + "web_search", + "write", +]); const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; @@ -101,6 +137,7 @@ type ActiveRecallPluginConfig = { | "recall-heavy" | "precision-heavy" | "preference-only"; + toolsAllow?: string[]; promptOverride?: string; promptAppend?: string; timeoutMs?: number; @@ -141,6 +178,7 @@ type ResolvedActiveRecallPluginConfig = { | "recall-heavy" | "precision-heavy" | "preference-only"; + toolsAllow: string[]; promptOverride?: string; promptAppend?: string; timeoutMs: number; @@ -399,6 +437,46 @@ function normalizeChatIdList(value: unknown): string[] { return out; } +function normalizeConfiguredToolsAllow(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed || isReservedActiveMemoryToolsAllowEntry(trimmed) || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + out.push(trimmed); + if (out.length >= MAX_ACTIVE_MEMORY_TOOLS_ALLOW) { + break; + } + } + return out.length > 0 ? out : undefined; +} + +function isReservedActiveMemoryToolsAllowEntry(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized.startsWith("group:") || ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW.has(normalized); +} + +function resolveDefaultToolsAllow(cfg: OpenClawConfig | undefined): string[] { + return cfg?.plugins?.slots?.memory === "memory-lancedb" + ? [...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW] + : [...DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW]; +} + +function resolveToolsAllow(params: { pluginToolsAllow: unknown; cfg?: OpenClawConfig }): string[] { + return ( + normalizeConfiguredToolsAllow(params.pluginToolsAllow) ?? resolveDefaultToolsAllow(params.cfg) + ); +} + function normalizePromptConfigText(value: unknown): string | undefined { const text = typeof value === "string" ? value.trim() : ""; return text ? text : undefined; @@ -445,6 +523,13 @@ function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: str ); } +function requireTransientWorkspaceDir(tempDir: string | undefined): string { + if (!tempDir) { + throw new Error("Active memory transient workspace was not initialized."); + } + return tempDir; +} + function resolveCanonicalSessionKeyFromSessionId(params: { api: OpenClawPluginApi; agentId: string; @@ -497,7 +582,14 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function isMissingRegisteredMemoryToolsError(error: unknown): boolean { +function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string { + return `runtime toolsAllow: ${toolsAllow.join(", ")}`; +} + +function isMissingRegisteredMemoryToolsError( + error: unknown, + toolsAllow: readonly string[] = DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW, +): boolean { if (!(error instanceof Error)) { return false; } @@ -509,24 +601,12 @@ function isMissingRegisteredMemoryToolsError(error: unknown): boolean { return false; } const sources = message.slice(prefix.length, -suffix.length); - const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`; + const runtimeSource = formatRuntimeToolsAllowSource(toolsAllow); const sourceParts = sources .split(";") .map((source) => source.trim()) .filter(Boolean); - if (!sourceParts.includes(runtimeSource)) { - return false; - } - return sourceParts.every((source) => { - if (source === runtimeSource) { - return true; - } - const entries = source - .slice(source.indexOf(":") + 1) - .split(",") - .map((entry) => entry.trim()); - return entries.includes("*"); - }); + return sourceParts.includes(runtimeSource); } function resolveRecallRunChannelContext(params: { @@ -791,7 +871,10 @@ function requiresAdminToMutateActiveMemoryGlobal(gatewayClientScopes?: readonly const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT = "⚠️ /active-memory global enable/disable changes require operator.admin for gateway clients."; -function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig { +function normalizePluginConfig( + pluginConfig: unknown, + cfg?: OpenClawConfig, +): ResolvedActiveRecallPluginConfig { const raw = ( pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {} ) as ActiveRecallPluginConfig; @@ -819,6 +902,7 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi deniedChatIds: normalizeChatIdList(raw.deniedChatIds), thinking: resolveThinkingLevel(raw.thinking), promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode), + toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }), promptOverride: normalizePromptConfigText(raw.promptOverride), promptAppend: normalizePromptConfigText(raw.promptAppend), timeoutMs: clampInt( @@ -990,11 +1074,11 @@ function buildRecallPrompt(params: { "Your job is to search memory and return only the most relevant memory context for that model.", "You receive a bounded search query plus conversation context, including the user's latest message.", "Use only the available memory tools.", - "Use the bounded search query as the memory_search or memory_recall query.", + "Use the bounded search query with the configured memory tools.", + `Configured memory tools: ${params.config.toolsAllow.join(", ")}.`, "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", - "Prefer memory_recall when available.", - "If memory_recall is unavailable, use memory_search and memory_get.", - "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", + "If the available memory tools find nothing useful, reply with NONE.", + "When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.", "Do not answer the user directly.", `Prompt style: ${params.config.promptStyle}.`, ...buildPromptStyleLines(params.config.promptStyle), @@ -2398,9 +2482,10 @@ async function runRecallSubagent(params: { params.config.transcriptDir, ) : undefined; - const sessionFile = params.config.persistTranscripts - ? path.join(persistedDir!, `${subagentSessionId}.jsonl`) - : path.join(tempDir!, "session.jsonl"); + const sessionFile = + persistedDir !== undefined + ? path.join(persistedDir, `${subagentSessionId}.jsonl`) + : path.join(requireTransientWorkspaceDir(tempDir), "session.jsonl"); params.onSessionFile?.(sessionFile); if (persistedDir) { await fs.mkdir(persistedDir, { recursive: true, mode: 0o700 }); @@ -2439,7 +2524,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", - toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST], + toolsAllow: [...params.config.toolsAllow], disableMessageTool: true, allowGatewaySubagentBinding: true, bootstrapContextMode: "lightweight", @@ -2482,9 +2567,19 @@ async function runRecallSubagent(params: { const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; attachPartialTimeoutData(error, partialReply, searchDebug); } - if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) { + if ( + !params.abortSignal?.aborted && + isMissingRegisteredMemoryToolsError(error, params.config.toolsAllow) + ) { params.api.logger.debug?.( - `active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`, + `active-memory: no configured memory tools available; skipping sub-agent`, + ); + return { rawReply: "NONE" }; + } + if (!params.abortSignal?.aborted) { + const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error)); + params.api.logger.warn?.( + `active-memory: memory sub-agent failed, skipping recall: ${message}`, ); return { rawReply: "NONE" }; } @@ -2751,10 +2846,10 @@ async function maybeResolveActiveRecall(params: { } const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error)); if (params.config.logging) { - params.api.logger.warn?.(`${logPrefix} failed error=${message}`); + params.api.logger.warn?.(`${logPrefix} failed error=${message}; skipping recall`); } const result: ActiveRecallResult = { - status: "unavailable", + status: "empty", elapsedMs: Date.now() - startedAt, summary: null, }; @@ -2777,7 +2872,17 @@ export default definePluginEntry({ name: "Active Memory", description: "Proactively surfaces relevant memory before eligible conversational replies.", register(api: OpenClawPluginApi) { - let config = normalizePluginConfig(api.pluginConfig); + const readCurrentConfig = (): OpenClawConfig | undefined => { + try { + return ( + (api.runtime.config?.current?.() as OpenClawConfig | undefined) ?? + (api.config as OpenClawConfig | undefined) + ); + } catch { + return api.config as OpenClawConfig | undefined; + } + }; + let config = normalizePluginConfig(api.pluginConfig, readCurrentConfig()); const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => { if (hasDeprecatedModelFallbackPolicy(pluginConfig)) { // Wording matters here: the previous text ("set config.modelFallback @@ -2805,7 +2910,7 @@ export default definePluginEntry({ "active-memory", api.pluginConfig as Record, ); - config = normalizePluginConfig(livePluginConfig ?? { enabled: false }); + config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig()); if (livePluginConfig) { warnDeprecatedModelFallbackPolicy(livePluginConfig); } diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index a19b28a9820..cfcc47b1de3 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -56,6 +56,14 @@ "preference-only" ] }, + "toolsAllow": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\*$)(?![Gg][Rr][Oo][Uu][Pp]:).+" + }, + "maxItems": 32 + }, "promptOverride": { "type": "string" }, "promptAppend": { "type": "string" }, "maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 }, @@ -129,6 +137,10 @@ "label": "Prompt Style", "help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory." }, + "toolsAllow": { + "label": "Allowed Memory Tools", + "help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get, or memory_recall when plugins.slots.memory selects memory-lancedb; configure this for other non-core memory providers. Wildcards, group entries, and core agent tools are ignored." + }, "thinking": { "label": "Thinking Override", "help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed." diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index cbe9914ebcb..273f398b41d 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -151,7 +151,7 @@ describe("discord channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "discordMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 5e5c8156245..0e549d395ce 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -44,8 +44,9 @@ describe("matrix channel message adapter", () => { it("backs declared durable-final capabilities with runtime outbound proofs", async () => { const adapter = matrixPlugin.message; - if (adapter?.send?.text === undefined || adapter.send.media === undefined) { - throw new Error("expected matrix text and media message adapter"); + expect(adapter).toBeDefined(); + if (!adapter?.send?.text || !adapter.send.media) { + throw new Error("Expected Matrix message adapter send capabilities."); } const sendText = adapter.send.text; const sendMedia = adapter.send.media; @@ -93,7 +94,7 @@ describe("matrix channel message adapter", () => { const proveReplyThread = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter.send!.text!({ + const result = await adapter.send.text({ cfg, to: "room:!room:example", text: "threaded", @@ -116,14 +117,14 @@ describe("matrix channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "matrixMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(adapter.send?.text).toBeTypeOf("function"); }, }, }); diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index 51edfa1576b..2df75528e0d 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -132,7 +132,7 @@ describe("mattermost channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 7c79e3716bf..d9b84a13f67 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -114,7 +114,7 @@ describe("tlon channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "tlonMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 667ffab64ae..2b9c0493d10 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -754,6 +754,9 @@ describe("web auto-reply connection", () => { timestamp: 1735689600000, spies, }); + if (!capturedOnMessage) { + throw new Error("Expected WhatsApp web runtime to register onMessage."); + } await sendWebDirectInboundMessage({ onMessage: capturedOnMessage, body: "second", diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 7c0c134a825..4d92fbe49c4 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -509,8 +509,9 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); const sessionFile = updatedFirst?.sessionFile; + expect(sessionFile).toBeTruthy(); if (!sessionFile) { - throw new Error("expected embedded gap-fill persistence to create a session file"); + throw new Error("Expected CLI transcript session file."); } await appendSessionTranscriptMessage({ diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 931531423aa..fa1e0c65298 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -113,8 +113,9 @@ const createCompactionHandler = () => { }), } as unknown as ExtensionAPI; compactionSafeguardExtension(mockApi); + expect(compactionHandler).toBeDefined(); if (!compactionHandler) { - throw new Error("expected compaction safeguard handler"); + throw new Error("Expected compaction safeguard to register a handler."); } return compactionHandler; }; diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/pi-tools.read.host-edit-access.test.ts index 6e807b716a3..f227137f4a4 100644 --- a/src/agents/pi-tools.read.host-edit-access.test.ts +++ b/src/agents/pi-tools.read.host-edit-access.test.ts @@ -66,8 +66,13 @@ describe("createHostWorkspaceEditTool host access mapping", () => { // library replaces any access error with a misleading "File not found". // By resolving silently the subsequent readFile call surfaces the real // "Path escapes workspace root" / "outside-workspace" error instead. + const operations = mocks.operations; + expect(operations).toBeDefined(); + if (!operations) { + throw new Error("Expected workspace edit operations to be registered."); + } await expect( - mocks.operations.access(path.join(workspaceDir, "escape", "secret.txt")), + operations.access(path.join(workspaceDir, "escape", "secret.txt")), ).resolves.toBeUndefined(); }, ); diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 2e0c1461cf9..841d3872005 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -210,10 +210,10 @@ describe("sessions_spawn tool", () => { }, }); const schema = tool.parameters as { - properties?: { - thread?: { description?: string; enum?: string[]; type?: string }; - mode?: { description?: string; enum?: string[]; type?: string }; - }; + properties?: Record< + string, + { description?: string; enum?: string[]; type?: string } | undefined + >; }; expect(schema.properties?.thread).toBeUndefined(); @@ -236,10 +236,10 @@ describe("sessions_spawn tool", () => { }, }); const schema = tool.parameters as { - properties?: { - thread?: { description?: string; enum?: string[]; type?: string }; - mode?: { description?: string; enum?: string[]; type?: string }; - }; + properties?: Record< + string, + { description?: string; enum?: string[]; type?: string } | undefined + >; }; const thread = requireSchemaProperty(schema.properties, "thread"); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 89b71f1599a..8b7842d9bb3 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -236,8 +236,10 @@ describe("backup commands", () => { const stateAsset = result.assets.find((asset) => asset.kind === "state"); const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace"); + expect(stateAsset).toBeDefined(); + expect(workspaceAsset).toBeDefined(); if (!stateAsset || !workspaceAsset) { - throw new Error("expected state and workspace backup assets"); + throw new Error("Expected backup assets to include state and workspace entries."); } expect(capturedEntryPaths).toHaveLength(result.assets.length + 1); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index cb0b595e2e7..91e2707b0aa 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -141,7 +141,7 @@ function findSseEvent(events: SseEvent[], eventName: string): SseEvent { } function parseSseData(event: SseEvent): unknown { - return JSON.parse(event.data); + return JSON.parse(event.data) as unknown; } function requireSessionKey(value: string | undefined, label: string): string { From edfc5294cbbcd0f3b97e6ec5983f8ca5978389a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:13:41 +0100 Subject: [PATCH 764/806] test: avoid line count filter allocations --- src/commands/doctor-session-transcripts.test.ts | 14 ++++++++++++-- src/gateway/server-methods/server-methods.test.ts | 12 +++++++++++- src/plugins/loader.test.ts | 2 +- src/secrets/audit.test.ts | 14 ++++++++++++-- test/scripts/parallels-smoke-model.test.ts | 12 +++++++++++- test/vitest-unit-fast-config.test.ts | 12 +++++++++++- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 1d9b22ba9a2..7ef9c8e7d8f 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -14,6 +14,16 @@ import { repairBrokenSessionTranscriptFile, } from "./doctor-session-transcripts.js"; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split(/\r?\n/)) { + if (line) { + count += 1; + } + } + return count; +} + describe("doctor session transcript repair", () => { let root: string; @@ -129,7 +139,7 @@ describe("doctor session transcript repair", () => { expect(title).toBe("Session transcripts"); expect(message).toContain("duplicated prompt-rewrite branches"); expect(message).toContain('Run "openclaw doctor --fix"'); - expect((await fs.readFile(filePath, "utf-8")).split(/\r?\n/).filter(Boolean)).toHaveLength(3); + expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); it("ignores ordinary branch history without internal runtime context", async () => { @@ -152,6 +162,6 @@ describe("doctor session transcript repair", () => { const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: true }); expect(result.broken).toBe(false); - expect((await fs.readFile(filePath, "utf-8")).split(/\r?\n/).filter(Boolean)).toHaveLength(3); + expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); }); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index c49dede5266..9fa3b49f2db 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -31,6 +31,16 @@ vi.mock("../../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("waitForAgentJob", () => { async function runLifecycleScenario(params: { runIdPrefix: string; @@ -1212,7 +1222,7 @@ describe("exec approval handlers", () => { expect(firstResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(repeatResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - expect(broadcasts.filter((entry) => entry.event === "exec.approval.resolved")).toHaveLength( + expect(countMatching(broadcasts, (entry) => entry.event === "exec.approval.resolved")).toBe( resolvedBroadcastCount, ); expect(conflictingResolveRespond).toHaveBeenCalledWith( diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 08bad34ba37..d39dfa2f0ad 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -4282,7 +4282,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }); - expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength( + expect(countMatching(registry.services, (entry) => entry.service.id === "shared-service")).toBe( 1, ); expectNoDiagnosticContaining({ diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index 43564a141fa..b2b69b0cffa 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -18,6 +18,16 @@ type AuditFixture = { const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split("\n")) { + if (line.trim().length > 0) { + count += 1; + } + } + return count; +} + async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -334,7 +344,7 @@ describe("secrets audit", () => { expect(report.summary.unresolvedRefCount).toBe(0); const callLog = await fs.readFile(execLogPath, "utf8"); - const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; + const callCount = countNonEmptyLines(callLog); expect(callCount).toBe(1); }); @@ -398,7 +408,7 @@ describe("secrets audit", () => { expect(report.summary.unresolvedRefCount).toBeGreaterThanOrEqual(2); const callLog = await fs.readFile(execLogPath, "utf8"); - const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; + const callCount = countNonEmptyLines(callLog); expect(callCount).toBe(1); }); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index eb4c8bc821e..d538f1d22da 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -35,6 +35,16 @@ const TS_PATHS = { const OS_TS_PATHS = [TS_PATHS.linux, TS_PATHS.macos, TS_PATHS.windows]; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split("\n")) { + if (line) { + count += 1; + } + } + return count; +} + function runTsEval(source: string, env: Record = {}) { return execFileSync("node", ["--import", "tsx", "--input-type=module", "--eval", source], { encoding: "utf8", @@ -79,7 +89,7 @@ describe("Parallels smoke model selection", () => { } else { expect(wrapper, wrapperPath).toContain(TS_PATHS[platform as "linux" | "macos" | "windows"]); } - expect(wrapper.split("\n").filter(Boolean).length).toBeLessThanOrEqual(5); + expect(countNonEmptyLines(wrapper)).toBeLessThanOrEqual(5); } }); diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index b6c64b8532e..5c62b7bb9fc 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -20,6 +20,16 @@ function requireTestConfig(config: T): NonNullable return config.test as NonNullable; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("unit-fast vitest lane", () => { it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { const config = createUnitFastVitestConfig({}); @@ -122,7 +132,7 @@ describe("unit-fast vitest lane", () => { expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length); expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length); - expect(broadAnalysis.filter((entry) => entry.unitFast).length).toBeGreaterThan( + expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan( unitFastTestFiles.length, ); }); From aa78d9eab9546716b4811e1b8f6f4344eae71b68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:15:56 +0100 Subject: [PATCH 765/806] test: avoid extension filter count helpers --- .../browser/src/browser/cdp.internal.test.ts | 12 +++++++++++- .../matrix/src/channel.message-adapter.test.ts | 2 +- extensions/telegram/src/format.wrap-md.test.ts | 18 ++++-------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/extensions/browser/src/browser/cdp.internal.test.ts b/extensions/browser/src/browser/cdp.internal.test.ts index d920c726f96..b0fe0177cc3 100644 --- a/extensions/browser/src/browser/cdp.internal.test.ts +++ b/extensions/browser/src/browser/cdp.internal.test.ts @@ -35,6 +35,16 @@ function sendCdpResult(socket: WebSocket, id: number | undefined, result: Record socket.send(JSON.stringify({ id, result })); } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function replyToPageEnable(msg: CdpMockMessage, socket: WebSocket): boolean { if (msg.method !== "Page.enable") { return false; @@ -197,7 +207,7 @@ describe("cdp internal", () => { } if (msg.method === "Runtime.evaluate") { // Pre-capture viewport probe + post-capture probe. - const isPre = events.filter((m) => m === "Runtime.evaluate").length === 1; + const isPre = countMatching(events, (m) => m === "Runtime.evaluate") === 1; socket.send( JSON.stringify({ id: msg.id, diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 0e549d395ce..604eb978a96 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -94,7 +94,7 @@ describe("matrix channel message adapter", () => { const proveReplyThread = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "room:!room:example", text: "threaded", diff --git a/extensions/telegram/src/format.wrap-md.test.ts b/extensions/telegram/src/format.wrap-md.test.ts index a9bcd6240c0..8c5083312ee 100644 --- a/extensions/telegram/src/format.wrap-md.test.ts +++ b/extensions/telegram/src/format.wrap-md.test.ts @@ -9,27 +9,17 @@ import { type TelegramChunk = ReturnType[number]; function expectHtmlChunkLengthsAtMost(chunks: TelegramChunk[], limit: number) { - expect( - chunks - .map((chunk, index) => ({ index, htmlLength: chunk.html.length, text: chunk.text })) - .filter((chunk) => chunk.htmlLength > limit), - ).toEqual([]); + expect(chunks.some((chunk) => chunk.html.length > limit)).toBe(false); } function expectNonBlankTextChunks(chunks: TelegramChunk[]) { - expect( - chunks - .map((chunk, index) => ({ index, text: chunk.text })) - .filter((chunk) => chunk.text.trim().length === 0), - ).toEqual([]); + expect(chunks.some((chunk) => chunk.text.trim().length === 0)).toBe(false); } function expectHtmlChunksWrappedWith(chunks: TelegramChunk[], prefix: string, suffix: string) { expect( - chunks - .map((chunk, index) => ({ index, html: chunk.html })) - .filter((chunk) => !chunk.html.startsWith(prefix) || !chunk.html.endsWith(suffix)), - ).toEqual([]); + chunks.every((chunk) => chunk.html.startsWith(prefix) && chunk.html.endsWith(suffix)), + ).toBe(true); } describe("wrapFileReferencesInHtml", () => { From aa34ce41a15a8d65d93bf8faef9dbe559127a024 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:19:50 +0100 Subject: [PATCH 766/806] test: avoid single result filter assertions --- .../telegram/src/bot.command-menu.test.ts | 13 +++++++++- .../src/monitor.polling.media-reply.test.ts | 17 +++++++++++-- src/daemon/launchd.test.ts | 22 +++++++++++++--- src/plugins/discovery.test.ts | 25 ++++++++++++++----- 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts index 288c38b302b..45f0ebe6239 100644 --- a/extensions/telegram/src/bot.command-menu.test.ts +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -49,6 +49,16 @@ function resolveSkillCommands(config: Parameters["skillCommands"]; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("createTelegramBot command menu", () => { beforeAll(async () => { ({ normalizeTelegramCommandName } = await import("./command-config.js")); @@ -175,7 +185,8 @@ describe("createTelegramBot command menu", () => { } expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); - expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]); + expect(registered.find((command) => command.command === "status")).toEqual(nativeStatus); + expect(countMatching(registered, (command) => command.command === "status")).toBe(1); expect(errorSpy).toHaveBeenCalled(); }); diff --git a/extensions/zalo/src/monitor.polling.media-reply.test.ts b/extensions/zalo/src/monitor.polling.media-reply.test.ts index 9fe4b91fc11..144d487792e 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test.ts @@ -79,6 +79,16 @@ function createHostedMediaResponse() { return { headers, res: res as unknown as ServerResponse & { end: ReturnType } }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("Zalo polling media replies", () => { const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); const recordInboundSessionMock = vi.fn(async () => undefined); @@ -335,9 +345,12 @@ describe("Zalo polling media replies", () => { firstAbort.abort(); await firstRun; - expect(registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media")).toEqual([ + expect(registry.httpRoutes.find((route) => route.source === "zalo-hosted-media")).toEqual( hostedMediaRoute, - ]); + ); + expect( + countMatching(registry.httpRoutes, (route) => route.source === "zalo-hosted-media"), + ).toBe(1); await writeHostedZaloMediaFixture({ id: "def456def456def456def456", diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 01aa3fadd2a..a9543489766 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -55,6 +55,16 @@ const cleanStaleGatewayProcessesSync = vi.hoisted(() => ); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function createDefaultLaunchdEnv(): Record { return { HOME: "/Users/test", @@ -417,9 +427,11 @@ describe("launchd bootstrap repair", () => { const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ - ["kickstart", serviceId], + expect(state.launchctlCalls.find((call) => call[0] === "kickstart")).toEqual([ + "kickstart", + serviceId, ]); + expect(countMatching(state.launchctlCalls, (call) => call[0] === "kickstart")).toBe(1); }); it("skips kickstart when already-loaded service is actively running", async () => { @@ -443,9 +455,11 @@ describe("launchd bootstrap repair", () => { const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ - ["kickstart", serviceId], + expect(state.launchctlCalls.find((call) => call[0] === "kickstart")).toEqual([ + "kickstart", + serviceId, ]); + expect(countMatching(state.launchctlCalls, (call) => call[0] === "kickstart")).toBe(1); }); it("keeps genuine bootstrap failures as failures", async () => { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index addcab16112..91fffd3e56c 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -28,6 +28,16 @@ function makeTempDir() { const mkdirSafe = mkdirSafeDir; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function withOpenClawPackageArgv(packageRoot: string, fn: () => T): T { mkdirSafe(path.join(packageRoot, "bin")); fs.writeFileSync(path.join(packageRoot, "package.json"), '{"name":"openclaw"}\n', "utf-8"); @@ -591,9 +601,10 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "feishu")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "feishu")).toEqual( expect.objectContaining({ origin: "bundled" }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "feishu")).toBe(1); expect(diagnostics).toEqual([ expect.objectContaining({ level: "warn", @@ -628,9 +639,10 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "telegram")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "telegram")).toEqual( expect.objectContaining({ origin: "bundled" }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "telegram")).toBe(1); expect(diagnostics).toEqual([ expect.objectContaining({ level: "warn", @@ -725,13 +737,14 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "synology-chat")).toEqual( expect.objectContaining({ origin: "bundled", rootDir: fs.realpathSync(bundledPluginDir), source: fs.realpathSync(bundledEntryPath), }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "synology-chat")).toBe(1); expect(diagnostics).toEqual([]); }); From 17b1562c1e608aae9755fa8bf82a25701558f853 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:23:52 +0100 Subject: [PATCH 767/806] test: avoid filtered map assertion allocations --- extensions/acpx/src/process-reaper.test.ts | 24 +++++++++++++++--- ...s.before-tool-call.integration.e2e.test.ts | 25 ++++++++++++++++--- .../skills.loadworkspaceskillentries.test.ts | 15 ++++++++++- src/cli/daemon-cli.coverage.test.ts | 25 ++++++++++++++++--- src/infra/bonjour-discovery.test.ts | 24 +++++++++++++++--- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/extensions/acpx/src/process-reaper.test.ts b/extensions/acpx/src/process-reaper.test.ts index 80e6775e1be..84164ba3b67 100644 --- a/extensions/acpx/src/process-reaper.test.ts +++ b/extensions/acpx/src/process-reaper.test.ts @@ -28,6 +28,20 @@ function cleanupDeps(processes: AcpxProcessInfo[]) { }; } +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + describe("process reaper", () => { it("recognizes generated Codex and Claude wrappers only under the configured root", () => { expect( @@ -237,9 +251,13 @@ describe("process reaper", () => { expect(result.skippedReason).toBeUndefined(); expect(result.inspectedPids).toEqual([400, 401, 402, 403, 404, 405]); - expect(killed.filter((entry) => entry.signal === "SIGTERM").map((entry) => entry.pid)).toEqual([ - 402, 401, 400, 404, 403, 405, - ]); + expect( + collectMatching( + killed, + (entry) => entry.signal === "SIGTERM", + (entry) => entry.pid, + ), + ).toEqual([402, 401, 400, 404, 403, 405]); }); it("keeps startup scans quiet when process listing is unavailable", async () => { diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index be7233891b6..ad446753f3a 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -29,6 +29,20 @@ type BeforeToolCallHookInstall = { handler: BeforeToolCallHandlerMock; }; +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + function installBeforeToolCallHook(params?: { enabled?: boolean; runBeforeToolCallImpl?: (...args: unknown[]) => unknown; @@ -427,10 +441,13 @@ describe("before_tool_call hook integration for client tools", () => { releaseFirstHook(); await firstRun; - expect(slots.filter((slot) => slot.completed).map((slot) => slot.name)).toEqual([ - "first_tool", - "second_tool", - ]); + expect( + collectMatching( + slots, + (slot) => slot.completed, + (slot) => slot.name, + ), + ).toEqual(["first_tool", "second_tool"]); expect(slots.map((slot) => slot.params)).toEqual([ { value: "first", marker: "first_tool" }, { value: "second", marker: "second_tool" }, diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 3b585b1ced9..90059289b58 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -52,6 +52,16 @@ let envSnapshot: SkillsHomeEnvSnapshot; let tempRoot = ""; let workspaceCaseIndex = 0; +function collectMatching(items: readonly T[], predicate: (item: T) => boolean): T[] { + const matches: T[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(item); + } + } + return matches; +} + async function createTempWorkspaceDir() { const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); await fs.mkdir(workspaceDir, { recursive: true }); @@ -599,7 +609,10 @@ describe("loadWorkspaceSkillEntries", () => { }, }).map((entry) => entry.skill.name); - expect(names.filter((name) => name.startsWith("valid-"))).toEqual(["valid-a", "valid-b"]); + expect(collectMatching(names, (name) => name.startsWith("valid-"))).toEqual([ + "valid-a", + "valid-b", + ]); }); }); }); diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 9c2bf093c88..e9cffc0139b 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -28,6 +28,21 @@ const inspectPortUsage = vi.fn(async (port: number) => ({ listeners: [], hints: [], })); + +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + const buildGatewayInstallPlan = vi.fn( async (params: { port: number; @@ -325,8 +340,12 @@ describe("daemon-cli coverage", () => { expect(serviceStop).toHaveBeenCalledTimes(1); const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.filter((entry) => entry.ok).map((entry) => entry.action)).toEqual( - expect.arrayContaining(["start", "stop"]), - ); + expect( + collectMatching( + parsed, + (entry) => Boolean(entry.ok), + (entry) => entry.action, + ), + ).toEqual(expect.arrayContaining(["start", "stop"])); }); }); diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index 34e5a22176b..9690ddbbb28 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -4,6 +4,20 @@ import { discoverGatewayBeacons } from "./bonjour-discovery.js"; const WIDE_AREA_DOMAIN = "openclaw.internal."; +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + describe("bonjour-discovery", () => { it("discovers beacons on darwin across local + wide-area domains", async () => { const calls: Array<{ argv: string[]; timeoutMs: number }> = []; @@ -293,9 +307,13 @@ describe("bonjour-discovery", () => { run: run as unknown as typeof runCommandWithTimeout, }); - expect(calls.filter((c) => c[1] === "-B").map((c) => c[3])).toEqual( - expect.arrayContaining(["local.", "openclaw.internal."]), - ); + expect( + collectMatching( + calls, + (c) => c[1] === "-B", + (c) => c[3], + ), + ).toEqual(expect.arrayContaining(["local.", "openclaw.internal."])); calls.length = 0; await discoverGatewayBeacons({ From db35bc7693d6ab57c9e86ef11ffc9d3123df1358 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:29:02 +0100 Subject: [PATCH 768/806] fix: normalize retired Gemini config keys --- src/config/defaults.ts | 7 +++- src/config/model-alias-defaults.test.ts | 18 +++++++++ src/config/model-input.ts | 33 +++++++++++++++++ src/flows/model-picker.ts | 3 +- .../provider-auth-choice-helpers.test.ts | 24 ++++++++++++ src/plugins/provider-auth-choice-helpers.ts | 33 ++++------------- src/plugins/provider-model-primary.test.ts | 37 +++++++++++++++++++ src/plugins/provider-model-primary.ts | 7 +++- 8 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 src/plugins/provider-model-primary.test.ts diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 67d4a6bb4d2..dea22a1281e 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,6 +2,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; +import { normalizeAgentModelMapForConfig } from "./model-input.js"; import { applyProviderConfigDefaultsForConfig, normalizeProviderConfigForConfigDefaults, @@ -255,7 +256,11 @@ export function applyModelDefaults( if (!existingAgent) { return mutated ? nextCfg : cfg; } - const existingModels = existingAgent.models ?? {}; + const rawExistingModels = existingAgent.models ?? {}; + const existingModels = normalizeAgentModelMapForConfig(rawExistingModels); + if (existingModels !== rawExistingModels) { + mutated = true; + } if (Object.keys(existingModels).length === 0) { return mutated ? nextCfg : cfg; } diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index f7ce7f5f4eb..4a3cb85a575 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -127,6 +127,24 @@ describe("applyModelDefaults", () => { ); }); + it("normalizes retired Gemini model keys before applying aliases", () => { + const cfg = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": {}, + }, + }, + }, + } satisfies OpenClawConfig; + + const next = applyModelDefaults(cfg); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { alias: "gemini" }, + }); + }); + it("fills missing model provider defaults", () => { const cfg = buildProxyProviderConfig(); diff --git a/src/config/model-input.ts b/src/config/model-input.ts index aed1c859cd4..aeae34621ca 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -15,6 +15,10 @@ type AgentModelListLike = { const GOOGLE_CONFIG_MODEL_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function modelKeyForConfig(provider: string, model: string): string { const providerId = provider.trim(); const modelId = model.trim(); @@ -79,3 +83,32 @@ export function normalizeAgentModelRefForConfig(model: string): string { const normalizedModel = normalizeGooglePreviewModelId(trimmed.slice(slash + 1)); return modelKeyForConfig(provider, normalizedModel); } + +function mergeAgentModelEntryForConfig(existing: unknown, incoming: unknown): unknown { + if (!isPlainRecord(existing) || !isPlainRecord(incoming)) { + return incoming; + } + + const existingParams = isPlainRecord(existing.params) ? existing.params : undefined; + const incomingParams = isPlainRecord(incoming.params) ? incoming.params : undefined; + return { + ...existing, + ...incoming, + ...(existingParams || incomingParams + ? { params: { ...existingParams, ...incomingParams } } + : undefined), + }; +} + +export function normalizeAgentModelMapForConfig>(models: T): T { + let mutated = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(models)) { + const normalizedKey = normalizeAgentModelRefForConfig(key); + if (normalizedKey !== key || Object.prototype.hasOwnProperty.call(next, normalizedKey)) { + mutated = true; + } + next[normalizedKey] = mergeAgentModelEntryForConfig(next[normalizedKey], entry); + } + return (mutated ? next : models) as T; +} diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index b36de2fa8a7..6e41daaba5b 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -21,6 +21,7 @@ import { import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { + normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig, resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, @@ -1158,7 +1159,7 @@ export function applyModelAllowlist( }; } - const existingModels = defaults?.models ?? {}; + const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); if (scopeKeySet) { const nextModels = { ...existingModels }; for (const key of scopeKeySet) { diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index 9d840a5b6a1..77fb3d1c5ae 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -256,6 +256,30 @@ describe("applyDefaultModel", () => { }); }); + it("normalizes existing retired Google Gemini model keys before writing defaults", () => { + const config = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyDefaultModel(config, "google/gemini-3.1-pro-preview"); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }); + }); + it("normalizes retired Google Gemini fallbacks when writing config", () => { const config = { agents: { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 84d5681781c..4bca4ab2134 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -1,5 +1,8 @@ import { normalizeProviderId } from "../agents/model-selection.js"; -import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, +} from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty, @@ -109,33 +112,11 @@ function normalizeAgentModelConfigForWrite(value: unknown): unknown { return next; } -function mergeModelEntryConfig(existing: unknown, incoming: unknown): unknown { - if (!isPlainRecord(existing) || !isPlainRecord(incoming)) { - return incoming; - } - - const existingParams = isPlainRecord(existing.params) ? existing.params : undefined; - const incomingParams = isPlainRecord(incoming.params) ? incoming.params : undefined; - return { - ...existing, - ...incoming, - ...(existingParams || incomingParams - ? { params: { ...existingParams, ...incomingParams } } - : undefined), - }; -} - function normalizeAgentModelMapForWrite(value: unknown): unknown { if (!isPlainRecord(value)) { return value; } - - const next: Record = {}; - for (const [key, entry] of Object.entries(value)) { - const normalizedKey = normalizeAgentModelRefForConfig(key); - next[normalizedKey] = mergeModelEntryConfig(next[normalizedKey], entry); - } - return next; + return normalizeAgentModelMapForConfig(value); } function normalizeConfigModelRefsForWrite(cfg: OpenClawConfig): OpenClawConfig { @@ -200,7 +181,9 @@ export function applyDefaultModel( opts?: { preserveExistingPrimary?: boolean }, ): OpenClawConfig { const normalizedModel = normalizeAgentModelRefForConfig(model); - const models = { ...cfg.agents?.defaults?.models }; + const models = { + ...normalizeAgentModelMapForConfig(cfg.agents?.defaults?.models ?? {}), + }; models[normalizedModel] = models[normalizedModel] ?? {}; const existingModel = cfg.agents?.defaults?.model; diff --git a/src/plugins/provider-model-primary.test.ts b/src/plugins/provider-model-primary.test.ts new file mode 100644 index 00000000000..8addfc0aa0f --- /dev/null +++ b/src/plugins/provider-model-primary.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { applyPrimaryModel } from "./provider-model-primary.js"; + +describe("applyPrimaryModel", () => { + it("normalizes retired Gemini allowlist keys before writing the primary", () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3-pro-preview"], + }, + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyPrimaryModel(cfg, "google/gemini-3-pro-preview"); + + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + fallbacks: ["google/gemini-3.1-pro-preview"], + }); + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }); + }); +}); diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts index a310942b951..199669cc8be 100644 --- a/src/plugins/provider-model-primary.ts +++ b/src/plugins/provider-model-primary.ts @@ -1,4 +1,7 @@ -import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, +} from "../config/model-input.js"; import type { AgentModelListConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -50,7 +53,7 @@ export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawC const normalizedModel = normalizeAgentModelRefForConfig(model); const defaults = cfg.agents?.defaults; const existingModel = defaults?.model; - const existingModels = defaults?.models; + const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); const fallbacks = typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel ? (existingModel as { fallbacks?: string[] }).fallbacks?.map((fallback) => From 1b9cfc86cd98d1fe6defc193bc77db1285b22dd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:30:52 +0100 Subject: [PATCH 769/806] test: dedupe replay JSONL parsing --- .../reply/session-transcript-replay.test.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/auto-reply/reply/session-transcript-replay.test.ts b/src/auto-reply/reply/session-transcript-replay.test.ts index 34961b862fd..581d777efd8 100644 --- a/src/auto-reply/reply/session-transcript-replay.test.ts +++ b/src/auto-reply/reply/session-transcript-replay.test.ts @@ -9,6 +9,27 @@ import { const j = (obj: unknown): string => `${JSON.stringify(obj)}\n`; +type ReplayRecord = { + type?: string; + id?: string; + message?: { + role?: string; + content?: string; + }; +}; + +async function readJsonlRecords(filePath: string): Promise { + const records: ReplayRecord[] = []; + const raw = await fs.readFile(filePath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + records.push(JSON.parse(line) as ReplayRecord); + } + return records; +} + describe("replayRecentUserAssistantMessages", () => { let root = ""; beforeEach(async () => { @@ -37,14 +58,11 @@ describe("replayRecentUserAssistantMessages", () => { await fs.writeFile(source, lines.join(""), "utf8"); expect(await call(source, target)).toBe(DEFAULT_REPLAY_MAX_MESSAGES); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); + const records = await readJsonlRecords(target); expect(records[0]).toMatchObject({ type: "session", id: "new-session" }); expect(records).toHaveLength(1 + DEFAULT_REPLAY_MAX_MESSAGES); for (const r of records.slice(1)) { - expect(["user", "assistant"]).toContain(r.message.role); + expect(["user", "assistant"]).toContain(r.message?.role); } expect(await call(path.join(root, "missing.jsonl"), path.join(root, "out.jsonl"))).toBe(0); @@ -69,13 +87,10 @@ describe("replayRecentUserAssistantMessages", () => { await fs.writeFile(source, lines.join(""), "utf8"); expect(await call(source, target)).toBe(DEFAULT_REPLAY_MAX_MESSAGES - 1); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); + const records = await readJsonlRecords(target); expect(records.reduce((count, r) => count + (r.type === "session" ? 1 : 0), 0)).toBe(1); expect(records[0]).toMatchObject({ id: "existing" }); - expect(records[1].message.role).toBe("user"); + expect(records[1].message?.role).toBe("user"); }); it("coalesces same-role runs so replayed records strictly alternate", async () => { @@ -95,17 +110,14 @@ describe("replayRecentUserAssistantMessages", () => { ); expect(await call(source, target)).toBe(4); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); - expect(records.slice(1).map((r) => r.message.role)).toEqual([ + const records = await readJsonlRecords(target); + expect(records.slice(1).map((r) => r.message?.role)).toEqual([ "user", "assistant", "user", "assistant", ]); - expect(records.slice(1).map((r) => r.message.content)).toEqual([ + expect(records.slice(1).map((r) => r.message?.content)).toEqual([ "latest user", "latest assistant", "follow-up", From b3b9d4a8587ce5b19ce129639367ef1729de83cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:32:45 +0100 Subject: [PATCH 770/806] test: simplify abort transcript lookups --- .../chat.abort-persistence.test.ts | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 7238abe6225..f8c8d2219d2 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -50,16 +50,43 @@ async function writeTranscriptHeader(transcriptPath: string, sessionId: string) async function readTranscriptLines(transcriptPath: string): Promise { const raw = await fs.readFile(transcriptPath, "utf-8"); - return raw - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => { - try { - return JSON.parse(line) as TranscriptLine; - } catch { - return {}; - } - }); + const lines: TranscriptLine[] = []; + for (const line of raw.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + try { + lines.push(JSON.parse(line) as TranscriptLine); + } catch { + lines.push({}); + } + } + return lines; +} + +function collectMessagesWithIdempotencyKey( + lines: TranscriptLine[], + idempotencyKey: string, +): Record[] { + const messages: Record[] = []; + for (const line of lines) { + if (line.message?.idempotencyKey === idempotencyKey) { + messages.push(line.message); + } + } + return messages; +} + +function findMessageWithIdempotencyKey( + lines: TranscriptLine[], + idempotencyKey: string, +): Record | undefined { + for (const line of lines) { + if (line.message?.idempotencyKey === idempotencyKey) { + return line.message; + } + } + return undefined; } function setMockSessionEntry(transcriptPath: string, sessionId: string) { @@ -124,12 +151,7 @@ describe("chat abort transcript persistence", () => { }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .filter( - (message): message is Record => - Boolean(message) && message?.idempotencyKey === `${runId}:assistant`, - ); + const persisted = collectMessagesWithIdempotencyKey(lines, `${runId}:assistant`); expect(persisted).toHaveLength(1); expect(persisted[0]).toMatchObject({ @@ -176,12 +198,8 @@ describe("chat abort transcript persistence", () => { expect(payload.runIds).toEqual(expect.arrayContaining(["run-a", "run-b"])); const lines = await readTranscriptLines(transcriptPath); - const runAPersisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-a:assistant"); - const runBPersisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-b:assistant"); + const runAPersisted = findMessageWithIdempotencyKey(lines, "run-a:assistant"); + const runBPersisted = findMessageWithIdempotencyKey(lines, "run-b:assistant"); expect(runAPersisted).toMatchObject({ idempotencyKey: "run-a:assistant", @@ -226,9 +244,7 @@ describe("chat abort transcript persistence", () => { expect(payload).toMatchObject({ aborted: true, runIds: ["run-stop-1"] }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-stop-1:assistant"); + const persisted = findMessageWithIdempotencyKey(lines, "run-stop-1:assistant"); expect(persisted).toMatchObject({ idempotencyKey: "run-stop-1:assistant", @@ -264,9 +280,7 @@ describe("chat abort transcript persistence", () => { expect(payload).toMatchObject({ aborted: true, runIds: [runId] }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === `${runId}:assistant`); + const persisted = findMessageWithIdempotencyKey(lines, `${runId}:assistant`); expect(persisted).toBeUndefined(); }); }); From 3dfc4d85bf2139121866f223d120662a98e4fcdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:34:43 +0100 Subject: [PATCH 771/806] test: simplify safe-bin doc normalization --- src/infra/exec-safe-bin-policy.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 44d7f2def15..a5d21f9d9d1 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -26,14 +26,21 @@ function normalizeGeneratedDocBlock(block: string): string { while (lines.at(-1)?.trim() === "") { lines.pop(); } - const indents = lines - .filter((line) => line.trim().length > 0) - .map((line) => line.match(/^ */)?.[0].length ?? 0); - const commonIndent = Math.min(...indents); + let commonIndent = Infinity; + for (const line of lines) { + if (line.trim().length === 0) { + continue; + } + commonIndent = Math.min(commonIndent, line.match(/^ */)?.[0].length ?? 0); + } if (commonIndent <= 0) { return lines.join("\n"); } - return lines.map((line) => line.slice(Math.min(line.length, commonIndent))).join("\n"); + const normalizedLines: string[] = []; + for (const line of lines) { + normalizedLines.push(line.slice(Math.min(line.length, commonIndent))); + } + return normalizedLines.join("\n"); } function buildDeniedFlagArgvVariants(flag: string): string[][] { From 247fed1ca9a8bfce7f83bf91acd0026d0f355bba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:35:47 +0100 Subject: [PATCH 772/806] test: dedupe import boundary file checks --- test/plugin-extension-import-boundary.test.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index c447b597f15..5d490da0801 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -17,29 +17,38 @@ const baselinePath = path.join( ); const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); +function collectInventoryFiles( + inventory: Awaited>, + predicate: (file: string) => boolean, +): string[] { + const files: string[] = []; + for (const entry of inventory) { + if (predicate(entry.file)) { + files.push(entry.file); + } + } + return files; +} + describe("plugin extension import boundary inventory", () => { it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); - const blockedShimFiles = inventory - .filter( - (entry) => - entry.file === "src/plugins/web-search-providers.ts" || - entry.file === "src/plugins/bundled-web-search-registry.ts", - ) - .map((entry) => entry.file); + const blockedShimFiles = collectInventoryFiles( + inventory, + (file) => + file === "src/plugins/web-search-providers.ts" || + file === "src/plugins/bundled-web-search-registry.ts", + ); expect(blockedShimFiles).toEqual([]); }); it("ignores boundary shims by scope", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); - const boundaryShimFiles = inventory - .filter( - (entry) => - entry.file.startsWith("src/plugin-sdk/") || - entry.file.startsWith("src/plugin-sdk-internal/"), - ) - .map((entry) => entry.file); + const boundaryShimFiles = collectInventoryFiles( + inventory, + (file) => file.startsWith("src/plugin-sdk/") || file.startsWith("src/plugin-sdk-internal/"), + ); expect(boundaryShimFiles).toEqual([]); }); From 07f6167cce5103e3ab03c2683d1909e58501df88 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:37:19 +0100 Subject: [PATCH 773/806] test: simplify unit-fast forced diagnostics --- test/vitest-unit-fast-config.test.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 5c62b7bb9fc..ac4d914ee3a 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -30,6 +30,24 @@ function countMatching(items: readonly T[], predicate: (item: T) => boolean): return count; } +type UnitFastAnalysisEntry = ReturnType[number]; + +function collectUnroutedForcedFiles( + analysis: readonly UnitFastAnalysisEntry[], + forcedFiles: ReadonlySet, +): Array<{ file: string; forced: boolean; unitFast: boolean }> { + const unrouted: Array<{ file: string; forced: boolean; unitFast: boolean }> = []; + for (const entry of analysis) { + if (!forcedFiles.has(entry.file)) { + continue; + } + if (!entry.forced || !entry.unitFast) { + unrouted.push({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast }); + } + } + return unrouted; +} + describe("unit-fast vitest lane", () => { it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { const config = createUnitFastVitestConfig({}); @@ -110,17 +128,16 @@ describe("unit-fast vitest lane", () => { it("routes audited stateful-looking tests through the fast lane", () => { const analysis = collectUnitFastTestFileAnalysis(); - const forcedAnalysis = analysis.filter((entry) => forcedUnitFastTestFiles.includes(entry.file)); + const forcedFileSet = new Set(forcedUnitFastTestFiles); + const forcedAnalysisCount = countMatching(analysis, (entry) => forcedFileSet.has(entry.file)); const unitFastTestFiles = getUnitFastTestFiles(); - expect(forcedAnalysis).toHaveLength(forcedUnitFastTestFiles.length); + expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length); for (const file of forcedUnitFastTestFiles) { expect(unitFastTestFiles).toContain(file); expect(isUnitFastTestFile(file)).toBe(true); } - const unroutedForcedFiles = forcedAnalysis - .filter((entry) => !entry.forced || !entry.unitFast) - .map((entry) => ({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast })); + const unroutedForcedFiles = collectUnroutedForcedFiles(analysis, forcedFileSet); expect(unroutedForcedFiles).toEqual([]); }); From d82cc7f702b2f049ab4b62ff0237a5c76265aa01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:38:24 +0100 Subject: [PATCH 774/806] test: simplify migration status counts --- src/commands/migrate/selection.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/migrate/selection.test.ts b/src/commands/migrate/selection.test.ts index 38d59123e6c..5689f2c263b 100644 --- a/src/commands/migrate/selection.test.ts +++ b/src/commands/migrate/selection.test.ts @@ -87,15 +87,25 @@ function codexPluginConfigItem(pluginNames: string[]): MigrationItem { } function plan(items: MigrationItem[]): MigrationPlan { + const countStatus = (status: MigrationItem["status"]): number => { + let count = 0; + for (const item of items) { + if (item.status === status) { + count += 1; + } + } + return count; + }; + return { providerId: "codex", source: "/tmp/codex", summary: { total: items.length, - planned: items.filter((item) => item.status === "planned").length, + planned: countStatus("planned"), migrated: 0, - skipped: items.filter((item) => item.status === "skipped").length, - conflicts: items.filter((item) => item.status === "conflict").length, + skipped: countStatus("skipped"), + conflicts: countStatus("conflict"), errors: 0, sensitive: 0, }, From a232ac37834d111689207c19c144729717226ba2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:40:30 +0100 Subject: [PATCH 775/806] test: simplify status reaction call scans --- src/channels/status-reactions.test.ts | 70 +++++++++++++++++++++------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index d5832699d64..48498b204c3 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -60,6 +60,53 @@ function expectSetEmojiCall(calls: Array<{ method: string; emoji: string }>, emo expect(calls).toContainEqual({ method: "set", emoji }); } +function collectEmojisForMethod( + calls: Array<{ method: string; emoji: string }>, + method: string, +): string[] { + const emojis: string[] = []; + for (const call of calls) { + if (call.method === method) { + emojis.push(call.emoji); + } + } + return emojis; +} + +function countCallsForMethod(calls: Array<{ method: string; emoji: string }>, method: string) { + let count = 0; + for (const call of calls) { + if (call.method === method) { + count += 1; + } + } + return count; +} + +function countCallsForEmoji(calls: Array<{ method: string; emoji: string }>, emoji: string) { + let count = 0; + for (const call of calls) { + if (call.emoji === emoji) { + count += 1; + } + } + return count; +} + +function countCallsForMethodAndEmoji( + calls: Array<{ method: string; emoji: string }>, + method: string, + emoji: string, +) { + let count = 0; + for (const call of calls) { + if (call.method === method && call.emoji === emoji) { + count += 1; + } + } + return count; +} + function expectArrayContainsAll(values: readonly string[], expected: readonly string[]) { expected.forEach((value) => { expect(values).toContain(value); @@ -264,7 +311,7 @@ describe("createStatusReactionController", () => { await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have the last one (exec → display emoji) - const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji); + const setEmojis = collectEmojisForMethod(calls, "set"); expect(setEmojis).toEqual(["🛠️"]); }); @@ -292,7 +339,7 @@ describe("createStatusReactionController", () => { void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji); + const setEmojis = collectEmojisForMethod(calls, "set"); expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]); }); @@ -325,7 +372,7 @@ describe("createStatusReactionController", () => { await controller.setDone(); - const removeEmojis = calls.filter((call) => call.method === "remove").map((call) => call.emoji); + const removeEmojis = collectEmojisForMethod(calls, "remove"); expect(removeEmojis).toEqual(expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, "🛠️"])); expect(removeEmojis).not.toContain(DEFAULT_EMOJIS.done); }); @@ -354,10 +401,7 @@ describe("createStatusReactionController", () => { void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - const thinkingSets = calls.filter( - (call) => call.method === "set" && call.emoji === DEFAULT_EMOJIS.thinking, - ); - expect(thinkingSets).toHaveLength(1); + expect(countCallsForMethodAndEmoji(calls, "set", DEFAULT_EMOJIS.thinking)).toBe(1); expect(calls).not.toContainEqual({ method: "remove", emoji: DEFAULT_EMOJIS.thinking }); }); @@ -371,8 +415,7 @@ describe("createStatusReactionController", () => { await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have set calls, no remove - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls).toHaveLength(0); + expect(countCallsForMethod(calls, "remove")).toBe(0); expect(calls.some((c) => c.method === "set")).toBe(true); }); @@ -385,8 +428,7 @@ describe("createStatusReactionController", () => { await controller.clear(); // Should have removed multiple emojis - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls.length).toBeGreaterThan(0); + expect(countCallsForMethod(calls, "remove")).toBeGreaterThan(0); }); it("should handle clear gracefully when adapter lacks removeReaction", async () => { @@ -395,8 +437,7 @@ describe("createStatusReactionController", () => { await controller.clear(); // Should not throw, no remove calls - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls).toHaveLength(0); + expect(countCallsForMethod(calls, "remove")).toBe(0); }); it("should restore initial emoji", async () => { @@ -497,8 +538,7 @@ describe("createStatusReactionController", () => { await runUpdate(controller); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); - const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft); - expect(stallCalls).toHaveLength(0); + expect(countCallsForEmoji(calls, DEFAULT_EMOJIS.stallSoft)).toBe(0); }); it("should call onError callback when adapter throws", async () => { From b07b21df669fec0bf05cbce6b90b493dc33abc68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:42:41 +0100 Subject: [PATCH 776/806] test: simplify install package dir scans --- src/infra/install-package-dir.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index 82bdc89f5bd..05b7328cd91 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -16,14 +16,24 @@ vi.mock("../process/exec.js", async () => { async function listMatchingDirs(root: string, prefix: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) - .map((entry) => entry.name); + const names: string[] = []; + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith(prefix)) { + names.push(entry.name); + } + } + return names; } async function listMatchingEntries(root: string, prefix: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }); - return entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name); + const names: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith(prefix)) { + names.push(entry.name); + } + } + return names; } function normalizeDarwinTmpPath(filePath: string): string { From cac418d0dd7864207ef36a064b86a08e501353a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:44:01 +0100 Subject: [PATCH 777/806] test: simplify query expansion duplicate count --- .../src/host/query-expansion.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/memory-host-sdk/src/host/query-expansion.test.ts b/packages/memory-host-sdk/src/host/query-expansion.test.ts index f1e9bff520e..b7496358392 100644 --- a/packages/memory-host-sdk/src/host/query-expansion.test.ts +++ b/packages/memory-host-sdk/src/host/query-expansion.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { expandQueryForFts, extractKeywords } from "./query-expansion.js"; +function countKeyword(keywords: readonly string[], keyword: string): number { + let count = 0; + for (const candidate of keywords) { + if (candidate === keyword) { + count++; + } + } + return count; +} + describe("extractKeywords", () => { it("extracts keywords from English conversational query", () => { const keywords = extractKeywords("that thing we discussed about the API"); @@ -171,8 +181,7 @@ describe("extractKeywords", () => { it("removes duplicate keywords", () => { const keywords = extractKeywords("test test testing"); - const testCount = keywords.filter((k) => k === "test").length; - expect(testCount).toBe(1); + expect(countKeyword(keywords, "test")).toBe(1); }); describe("with trigram tokenizer", () => { From 10e425debee47335122dcbb0c01d6e21d83fe6bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:45:16 +0100 Subject: [PATCH 778/806] test: simplify cron event call counts --- src/cron/service.every-jobs-fire.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index 2cbdffbe096..e7d0de9e582 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -41,6 +41,19 @@ describe("CronService interval/cron jobs fire on time", () => { ); }; + const countMainSystemEvents = ( + enqueueSystemEvent: ReturnType, + expectedText: string, + ): number => { + let count = 0; + for (const [text] of enqueueSystemEvent.mock.calls) { + if (text === expectedText) { + count++; + } + } + return count; + }; + it("fires an every-type main job when the timer fires a few ms late", async () => { const store = await makeStorePath(); const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ @@ -171,10 +184,8 @@ describe("CronService interval/cron jobs fire on time", () => { const sfRun = await cron.run("legacy-every", "due"); expect(sfRun).toEqual({ ok: true, ran: true }); - const sfRuns = enqueueSystemEvent.mock.calls.filter((args) => args[0] === "sf-tick").length; - const minuteRuns = enqueueSystemEvent.mock.calls.filter( - (args) => args[0] === "minute-tick", - ).length; + const sfRuns = countMainSystemEvents(enqueueSystemEvent, "sf-tick"); + const minuteRuns = countMainSystemEvents(enqueueSystemEvent, "minute-tick"); expect(minuteRuns).toBeGreaterThan(0); expect(sfRuns).toBeGreaterThan(0); From dc062ee9b12d17072e68daec17719e84ccc0fb65 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:46:44 +0100 Subject: [PATCH 779/806] test: dedupe command builtin name checks --- src/gateway/server-methods/commands.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 3f5614a714c..b861ae8b2d9 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -190,6 +190,16 @@ function requireCommand(commands: T[], name: string) return command; } +function collectBuiltinNames(commands: readonly { name: string; source: string }[]): string[] { + const names: string[] = []; + for (const command of commands) { + if (command.source !== "plugin") { + names.push(command.name); + } + } + return names; +} + describe("commands.list handler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -286,7 +296,7 @@ describe("commands.list handler", () => { it("filters built-in commands by scope=native (excludes text-only)", () => { const { payload } = callHandler({ scope: "native" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name); + const builtinNames = collectBuiltinNames(commands); expect(builtinNames).not.toContain("commands"); expect(builtinNames).toContain("model"); expect(builtinNames).toContain("debug_prompt"); @@ -295,7 +305,7 @@ describe("commands.list handler", () => { it("filters built-in commands by scope=text (excludes native-only)", () => { const { payload } = callHandler({ scope: "text" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name); + const builtinNames = collectBuiltinNames(commands); expect(builtinNames).toContain("commands"); expect(builtinNames).not.toContain("debug_prompt"); }); From 5cb295926c76722ff5f0497445e17ca0a475adde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:47:51 +0100 Subject: [PATCH 780/806] test: simplify pairing read call counts --- src/pairing/pairing-store.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 2f47c756df4..c130dcbf2b1 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -210,7 +210,13 @@ async function expectAllowFromCacheInvalidationWithReadSpy(params: { } function countFileReads(spy: { mock: { calls: unknown[][] } }, filePath: string): number { - return spy.mock.calls.filter(([candidate]) => candidate === filePath).length; + let count = 0; + for (const [candidate] of spy.mock.calls) { + if (candidate === filePath) { + count++; + } + } + return count; } async function seedDefaultAccountAllowFromFixture(stateDir: string) { From 672426eb50475acc832d03a1386aa3336a662bb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:49:12 +0100 Subject: [PATCH 781/806] test: simplify connected node collection --- src/gateway/server.node-invoke-approval-bypass.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index cb68da708e5..69c0e5c43ef 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -84,7 +84,13 @@ async function getConnectedNodeIds(ws: WebSocket): Promise { {}, ); expect(nodes.ok).toBe(true); - return (nodes.payload?.nodes ?? []).filter((n) => n.connected).map((n) => n.nodeId); + const nodeIds: string[] = []; + for (const node of nodes.payload?.nodes ?? []) { + if (node.connected) { + nodeIds.push(node.nodeId); + } + } + return nodeIds; } async function requestAllowOnceApproval( From 3d5002f2db1da36f25edf530fa4da5ea31af2e48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:50:37 +0100 Subject: [PATCH 782/806] test: simplify role allowlist node counts --- src/gateway/server.roles-allowlist-update.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index dd95371637a..44828551fd4 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -40,6 +40,16 @@ const FAST_WAIT_OPTS = { timeout: 1_000, interval: 2 } as const; let ws: WebSocket; let port: number; +function countConnectedNodes(nodes: readonly { connected?: boolean }[] | undefined): number { + let count = 0; + for (const node of nodes ?? []) { + if (node.connected) { + count++; + } + } + return count; +} + function installCanvasNodePolicyForTest() { const registry = getActiveRuntimePluginRegistry(); if (!registry) { @@ -326,8 +336,7 @@ describe("gateway node command allowlist", () => { const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }>; }>(ws, "node.list", {}); - const nodes = listRes.payload?.nodes ?? []; - return nodes.filter((node) => node.connected).length; + return countConnectedNodes(listRes.payload?.nodes); }, FAST_WAIT_OPTS) .toBe(count); }; @@ -554,7 +563,7 @@ describe("gateway node command allowlist", () => { "node.list", {}, ); - return (listRes.payload?.nodes ?? []).filter((node) => node.connected).length; + return countConnectedNodes(listRes.payload?.nodes); }, FAST_WAIT_OPTS) .toBe(0); From 1968db9ddd2a0da043e1f4e224bb371b609624f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:52:04 +0100 Subject: [PATCH 783/806] test: simplify pi package missing scan --- src/plugins/pi-package-graph.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/plugins/pi-package-graph.test.ts b/src/plugins/pi-package-graph.test.ts index 07fb8147f54..04e92e5d806 100644 --- a/src/plugins/pi-package-graph.test.ts +++ b/src/plugins/pi-package-graph.test.ts @@ -37,6 +37,16 @@ function readPiDependencySpecs() { })); } +function collectMissingSpecNames(specs: Array<{ name: string; spec?: string }>): string[] { + const names: string[] = []; + for (const entry of specs) { + if (!entry.spec) { + names.push(entry.name); + } + } + return names; +} + function expectNoGraphViolations(violations: string[], message: string) { expect(violations, message).toEqual([]); } @@ -45,7 +55,7 @@ describe("pi package graph guardrails", () => { it("keeps root Pi packages aligned to the same exact version", () => { const specs = readPiDependencySpecs(); - const missing = specs.filter((entry) => !entry.spec).map((entry) => entry.name); + const missing = collectMissingSpecNames(specs); expectNoGraphViolations( missing, `Missing required root Pi dependencies: ${missing.join(", ") || ""}. Mixed or incomplete Pi root dependencies create an unsupported package graph.`, From 3e56f862371fd80c5f0ae621bd88c23fc4b1bc68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:53:40 +0100 Subject: [PATCH 784/806] test: simplify doctor warning collection --- src/commands/doctor-config-flow.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 0c28f39b1db..16fad04fae9 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1352,7 +1352,13 @@ async function collectDoctorWarnings(config: Record): Promise call[1] === "Doctor warnings").map((call) => call[0]); + const warnings: string[] = []; + for (const [message, title] of noteSpy.mock.calls) { + if (title === "Doctor warnings") { + warnings.push(message); + } + } + return warnings; } type DiscordGuildRule = { From 0c0e2e6c8b30d7a5cbd041e0f2d14c4074eefe56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:54:57 +0100 Subject: [PATCH 785/806] test: simplify acp env key normalization --- src/acp/client.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 13e99cc1ff8..729134bb98e 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -10,7 +10,13 @@ vi.mock("../secrets/provider-env-vars.js", () => ({ baseEnv: NodeJS.ProcessEnv, keys: Iterable, ): NodeJS.ProcessEnv => { - const denied = new Set([...keys].map((key) => key.trim().toUpperCase()).filter(Boolean)); + const denied = new Set(); + for (const key of keys) { + const normalized = key.trim().toUpperCase(); + if (normalized) { + denied.add(normalized); + } + } const env = { ...baseEnv }; for (const key of Object.keys(env)) { if (denied.has(key.toUpperCase())) { From 946419d105580895be23c441067dc147029f61c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:56:59 +0100 Subject: [PATCH 786/806] test: simplify auto reply allowlist normalization --- src/auto-reply/command-control.test.ts | 12 ++++++++++-- src/auto-reply/reply/commands-allowlist.test.ts | 15 +++++++++++++-- src/auto-reply/reply/commands-stop-target.test.ts | 12 ++++++++++-- .../reply/commands-subagents-routing.test.ts | 12 ++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index afe10f4e63f..673b208d367 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -13,8 +13,16 @@ import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registr installDiscordRegistryHooks(); describe("resolveCommandAuthorization", () => { - const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); + const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; + }; function createAllowFromPlugin( id: string, diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts index 96d1e04a400..350b3b72b49 100644 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -59,6 +59,17 @@ function normalizeTelegramAllowFromEntries(values: Array): stri return formatAllowFromLowercase({ allowFrom: values, stripPrefixRe: /^(telegram|tg):/i }); } +function normalizeAllowlistValues(values: Array): string[] { + const normalized: string[] = []; + for (const value of values) { + const entry = String(value).trim(); + if (entry) { + normalized.push(entry); + } + } + return normalized; +} + function resolveTelegramTestAccount( cfg: OpenClawConfig, accountId?: string | null, @@ -128,7 +139,7 @@ const whatsappAllowlistTestPlugin: ChannelPlugin = { channelId: "whatsapp", resolveAccount: ({ cfg }) => (cfg.channels?.whatsapp as DmGroupAllowlistTestSectionConfig | undefined) ?? {}, - normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean), + normalize: ({ values }) => normalizeAllowlistValues(values), resolveDmAllowFrom: (account) => account.allowFrom, resolveGroupAllowFrom: (account) => account.groupAllowFrom, resolveDmPolicy: () => undefined, @@ -154,7 +165,7 @@ function createLegacyAllowlistPlugin(channelId: "discord" | "slack"): ChannelPlu channelId, resolveAccount: ({ cfg }) => (cfg.channels?.[channelId] as DmGroupAllowlistTestSectionConfig | undefined) ?? {}, - normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean), + normalize: ({ values }) => normalizeAllowlistValues(values), resolveDmAllowFrom: (account) => account.allowFrom ?? account.dm?.allowFrom, resolveGroupPolicy: () => undefined, resolveGroupOverrides: () => undefined, diff --git a/src/auto-reply/reply/commands-stop-target.test.ts b/src/auto-reply/reply/commands-stop-target.test.ts index 0cf415de302..ffe81b98643 100644 --- a/src/auto-reply/reply/commands-stop-target.test.ts +++ b/src/auto-reply/reply/commands-stop-target.test.ts @@ -56,8 +56,16 @@ vi.mock("./reply-run-registry.js", () => ({ }, })); -const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; +}; let previousPluginRegistry: ReturnType; diff --git a/src/auto-reply/reply/commands-subagents-routing.test.ts b/src/auto-reply/reply/commands-subagents-routing.test.ts index 770e2bb02d4..0e6feb8668d 100644 --- a/src/auto-reply/reply/commands-subagents-routing.test.ts +++ b/src/auto-reply/reply/commands-subagents-routing.test.ts @@ -32,8 +32,16 @@ vi.mock("./commands-subagents-control.runtime.js", () => ({ listControlledSubagentRuns: listControlledSubagentRunsMock, })); -const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; +}; let previousPluginRegistry: ReturnType; From 91a6372897eff3c8fc486326ffe6f6d02d0d592c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:58:30 +0100 Subject: [PATCH 787/806] test: simplify openresponses event type collection --- src/gateway/openresponses-http.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 91e2707b0aa..506321ed010 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -132,6 +132,16 @@ function parseSseEvents(text: string): SseEvent[] { return events; } +function collectSseEventTypes(events: readonly SseEvent[]): string[] { + const eventTypes: string[] = []; + for (const event of events) { + if (event.event) { + eventTypes.push(event.event); + } + } + return eventTypes; +} + function findSseEvent(events: SseEvent[], eventName: string): SseEvent { const event = events.find((candidate) => candidate.event === eventName); if (!event) { @@ -710,7 +720,7 @@ describe("OpenResponses HTTP API (e2e)", () => { const deltaText = await resDelta.text(); const deltaEvents = parseSseEvents(deltaText); - const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean); + const eventTypes = collectSseEventTypes(deltaEvents); expect(eventTypes).toContain("response.created"); expect(eventTypes).toContain("response.output_item.added"); expect(eventTypes).toContain("response.in_progress"); From df1851b27de2821d7faa7150ca2bdd1503f05fd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 22:59:57 +0100 Subject: [PATCH 788/806] test: simplify export html specificity count --- .../reply/export-html/template.security.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts index 2be2f256c78..ec6b3cc123f 100644 --- a/src/auto-reply/reply/export-html/template.security.test.ts +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -145,9 +145,12 @@ function selectorSpecificity(selector: string): [number, number, number] { const ids = selector.match(/#[\w-]+/g)?.length ?? 0; const classes = selector.match(/\.[\w-]+/g)?.length ?? 0; const withoutIdsOrClasses = selector.replace(/#[\w-]+|\.[\w-]+/g, " "); - const elements = withoutIdsOrClasses - .split(/[\s>+~]+/) - .filter((part) => /^[a-z][\w-]*$/i.test(part)).length; + let elements = 0; + for (const part of withoutIdsOrClasses.split(/[\s>+~]+/)) { + if (/^[a-z][\w-]*$/i.test(part)) { + elements++; + } + } return [ids, classes, elements]; } From 4fb3bd845f5c065e7fe54a83c45c43f2ac8abead Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:01:35 +0100 Subject: [PATCH 789/806] test: simplify bootstrap cache hit count --- src/agents/bootstrap-budget.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index fc832dfc42a..4eacba1e9ab 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -475,7 +475,12 @@ describe("bootstrap prompt warnings", () => { injectLegacyWarning(optimizedTurns[2] ?? "", warningLines), ]; const cacheHitRate = (turns: string[]) => { - const hits = turns.slice(1).filter((turn, index) => turn === turns[index]).length; + let hits = 0; + for (let index = 1; index < turns.length; index++) { + if (turns[index] === turns[index - 1]) { + hits++; + } + } return hits / Math.max(1, turns.length - 1); }; From 35363a279bbff0a40924a14c414b0be49ccc5ddc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:03:18 +0100 Subject: [PATCH 790/806] test: simplify browser doctor warning ids --- extensions/browser/src/browser/doctor.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/extensions/browser/src/browser/doctor.test.ts b/extensions/browser/src/browser/doctor.test.ts index 791804de327..04aa769e01b 100644 --- a/extensions/browser/src/browser/doctor.test.ts +++ b/extensions/browser/src/browser/doctor.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { buildBrowserDoctorReport } from "./doctor.js"; +function collectWarningCheckIds(checks: readonly { id: string; status: string }[]): string[] { + const ids: string[] = []; + for (const check of checks) { + if (check.status === "warn") { + ids.push(check.id); + } + } + return ids; +} + describe("buildBrowserDoctorReport", () => { it("reports stopped managed browsers as launchable diagnostics", () => { const report = buildBrowserDoctorReport({ @@ -101,9 +111,11 @@ describe("buildBrowserDoctorReport", () => { }); expect(report.ok).toBe(true); - expect( - report.checks.filter((check) => check.status === "warn").map((check) => check.id), - ).toEqual(["managed-executable", "display", "linux-sandbox"]); + expect(collectWarningCheckIds(report.checks)).toEqual([ + "managed-executable", + "display", + "linux-sandbox", + ]); expect(report.checks.find((check) => check.id === "display")).toMatchObject({ summary: "No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (config)", }); From 8cc9618bfab6b0891d04970c15903e256107aec4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 22:56:38 +0100 Subject: [PATCH 791/806] test: isolate session reset cleanup queues --- src/auto-reply/reply/session-reset-cleanup.ts | 2 +- src/gateway/test/server-sessions.test-helpers.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/session-reset-cleanup.ts b/src/auto-reply/reply/session-reset-cleanup.ts index c36a33364ad..ecf88c29567 100644 --- a/src/auto-reply/reply/session-reset-cleanup.ts +++ b/src/auto-reply/reply/session-reset-cleanup.ts @@ -1,5 +1,5 @@ import { drainSystemEventEntries } from "../../infra/system-events.js"; -import { clearSessionQueues, type ClearSessionQueueResult } from "./queue.js"; +import { clearSessionQueues, type ClearSessionQueueResult } from "./queue/cleanup.js"; export type ClearSessionResetRuntimeStateResult = ClearSessionQueueResult & { systemEventsCleared: number; diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 6c9c6d24c2f..09b6a261dd4 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -127,6 +127,16 @@ vi.mock("../../auto-reply/reply/queue.js", async () => { }; }); +vi.mock("../../auto-reply/reply/queue/cleanup.js", async () => { + const actual = await vi.importActual( + "../../auto-reply/reply/queue/cleanup.js", + ); + return { + ...actual, + clearSessionQueues: sessionCleanupMocks.clearSessionQueues, + }; +}); + vi.mock("../../auto-reply/reply/abort.js", async () => { const actual = await vi.importActual( "../../auto-reply/reply/abort.js", From 05fb889efa6e5b83523ede18e3d695ae805a4634 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:05:01 +0100 Subject: [PATCH 792/806] test: simplify ollama warning count --- extensions/ollama/provider-discovery.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index e27aa55eb27..5120f9da5bb 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -28,6 +28,16 @@ describe("Ollama provider", () => { const countFetchCallUrls = (fetchMock: ReturnType, suffix: string): number => fetchCallUrls(fetchMock).reduce((count, url) => count + (url.endsWith(suffix) ? 1 : 0), 0); + const countWarnCallsIncluding = (warnSpy: ReturnType, text: string): number => { + let count = 0; + for (const [message] of warnSpy.mock.calls) { + if (String(message).includes(text)) { + count++; + } + } + return count; + }; + const expectDiscoveryCallCounts = ( fetchMock: ReturnType, params: { tags: number; show: number }, @@ -274,9 +284,7 @@ describe("Ollama provider", () => { env: { VITEST: "", NODE_ENV: "development" }, }); - expect( - warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")).length, - ).toBeGreaterThan(0); + expect(countWarnCallsIncluding(warnSpy, "Ollama")).toBeGreaterThan(0); warnSpy.mockRestore(); }); }); From 5104fd02c9178cae323f99c41558a4dd0567eb56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:06:56 +0100 Subject: [PATCH 793/806] test: simplify matrix room state count --- extensions/matrix/src/matrix/monitor/room-info.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts index c982de51775..ad59f517fda 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.test.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -46,7 +46,13 @@ function createMissingMetadataError() { } function getRoomStateCallCount(client: RoomInfoClientStub, eventType: string) { - return client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length; + let count = 0; + for (const [, type] of client.getRoomStateEvent.mock.calls) { + if (type === eventType) { + count++; + } + } + return count; } describe("createMatrixRoomInfoResolver", () => { From f4d5f9985b36e536b2244533a3d8367dbd8a15a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:09:23 +0100 Subject: [PATCH 794/806] test: simplify provider thinking level scans --- extensions/anthropic/provider-policy-api.test.ts | 14 +++++++++++--- extensions/opencode/provider-policy-api.test.ts | 14 +++++++++++--- extensions/vercel-ai-gateway/thinking.test.ts | 14 +++++++++++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/extensions/anthropic/provider-policy-api.test.ts b/extensions/anthropic/provider-policy-api.test.ts index 49e6fd8ec79..3b6ff1873b3 100644 --- a/extensions/anthropic/provider-policy-api.test.ts +++ b/extensions/anthropic/provider-policy-api.test.ts @@ -23,6 +23,16 @@ function createModel(id: string, name: string): ModelDefinitionConfig { }; } +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("anthropic provider policy public artifact", () => { it("normalizes Anthropic provider config", () => { expect( @@ -117,9 +127,7 @@ describe("anthropic provider policy public artifact", () => { if (!profile) { throw new Error("Expected Anthropic policy profile"); } - expect( - profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile.levels)).toEqual([]); }); it("does not expose Anthropic thinking profiles for unrelated providers", () => { diff --git a/extensions/opencode/provider-policy-api.test.ts b/extensions/opencode/provider-policy-api.test.ts index 4877c1a70d2..4f02914f247 100644 --- a/extensions/opencode/provider-policy-api.test.ts +++ b/extensions/opencode/provider-policy-api.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { resolveThinkingProfile } from "./provider-policy-api.js"; +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("opencode provider policy public artifact", () => { it("exposes Claude Opus 4.7 thinking levels without loading the full provider plugin", () => { expect( @@ -24,8 +34,6 @@ describe("opencode provider policy public artifact", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect( - profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile.levels)).toEqual([]); }); }); diff --git a/extensions/vercel-ai-gateway/thinking.test.ts b/extensions/vercel-ai-gateway/thinking.test.ts index 58a3d60cb88..54fafefcfb1 100644 --- a/extensions/vercel-ai-gateway/thinking.test.ts +++ b/extensions/vercel-ai-gateway/thinking.test.ts @@ -5,6 +5,16 @@ import { import { describe, expect, it } from "vitest"; import plugin from "./index.js"; +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("vercel ai gateway thinking profile", () => { async function getProvider() { const { providers } = await registerProviderPlugin({ @@ -49,9 +59,7 @@ describe("vercel ai gateway thinking profile", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect( - profile?.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile?.levels)).toEqual([]); }); it("falls through for unsupported OpenAI or untrusted namespaced refs", async () => { From 019d7247cd4b1f925897c034875a0a4628aa469a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:11:09 +0100 Subject: [PATCH 795/806] test: simplify setup allowlist normalization --- extensions/tlon/src/core.test.ts | 11 ++++++++++- extensions/whatsapp/src/channel.setup.test.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts index 411a18e7c3d..dd7c8673262 100644 --- a/extensions/tlon/src/core.test.ts +++ b/extensions/tlon/src/core.test.ts @@ -26,7 +26,16 @@ const tlonTestPlugin = { }: { cfg: OpenClawConfig; allowFrom: Array | undefined | null; - }) => (allowFrom ?? []).map((entry) => normalizeShip(String(entry))).filter(Boolean), + }) => { + const entries: string[] = []; + for (const entry of allowFrom ?? []) { + const normalized = normalizeShip(String(entry)); + if (normalized) { + entries.push(normalized); + } + } + return entries; + }, }, setup: { resolveAccountId: ({ accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index b0669d2800c..7f1e5a1291a 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -58,9 +58,16 @@ vi.mock("openclaw/plugin-sdk/setup", async () => { ...actual, DEFAULT_ACCOUNT_ID, normalizeAccountId: (value?: string | null) => value?.trim() || DEFAULT_ACCOUNT_ID, - normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => [ - ...new Set(entries.map((entry) => (entry === "*" ? "*" : normalize(entry))).filter(Boolean)), - ], + normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => { + const normalized = new Set(); + for (const entry of entries) { + const value = entry === "*" ? "*" : normalize(entry); + if (value) { + normalized.add(value); + } + } + return [...normalized]; + }, normalizeE164, pathExists: hoisted.pathExists, splitSetupEntries: (raw: string) => From 4aac25a58883f09dbfe0088691fa911b90b56359 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:13:32 +0100 Subject: [PATCH 796/806] test: simplify memory wiki path collection --- extensions/memory-core/src/tools.citations.test.ts | 14 +++++++++++--- extensions/memory-wiki/src/query.test.ts | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index cb9f814bac3..8cf27ed3c6a 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -27,6 +27,16 @@ import { const { createTempWorkspace } = createMemoryCoreTestHarness(); +function collectWikiResultPaths(results: readonly { corpus: string; path: string }[]): string[] { + const paths: string[] = []; + for (const result of results) { + if (result.corpus === "wiki") { + paths.push(result.path); + } + } + return paths; +} + async function waitFor(task: () => Promise, timeoutMs: number = 1500): Promise { const startedAt = Date.now(); let lastError: unknown; @@ -369,9 +379,7 @@ describe("memory tools", () => { expect(corpora).toContain("memory"); expect(corpora).toContain("wiki"); expect(details.results).toHaveLength(5); - expect( - details.results.filter((entry) => entry.corpus === "wiki").map((entry) => entry.path), - ).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]); + expect(collectWikiResultPaths(details.results)).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]); }); it("merges memory and wiki corpus search results for corpus=all", async () => { diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index e9d535721eb..6979a9f2b2b 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -45,6 +45,16 @@ const { createVault } = createMemoryWikiTestHarness(); let suiteRoot = ""; let caseIndex = 0; +function collectWikiResultPaths(results: readonly { corpus: string; path: string }[]): string[] { + const paths: string[] = []; + for (const result of results) { + if (result.corpus === "wiki") { + paths.push(result.path); + } + } + return paths; +} + beforeEach(() => { getActiveMemorySearchManagerMock.mockReset(); getActiveMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "unavailable" }); @@ -693,9 +703,7 @@ describe("searchMemoryWiki", () => { expect(results).toHaveLength(5); expect(results.map((result) => result.corpus)).toContain("memory"); - expect( - results.filter((result) => result.corpus === "wiki").map((result) => result.path), - ).toEqual([ + expect(collectWikiResultPaths(results)).toEqual([ "entities/alpha-1.md", "entities/alpha-2.md", "entities/alpha-3.md", From 3c131c84741ba9c2821b0e6e269d389c170f71d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:15:30 +0100 Subject: [PATCH 797/806] test: simplify loader duplicate counts --- src/plugins/loader.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d39dfa2f0ad..72b1134fb20 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -4177,7 +4177,7 @@ module.exports = { id: "throws-after-import", register() {} };`, api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); } };`, selectCount: (registry: ReturnType) => - registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook").length, + countMatching(registry.hooks, (entry) => entry.entry.hook.name === "shared-hook"), duplicateMessage: "hook already registered: shared-hook (hook-owner-a)", assert: expectDuplicateRegistrationResult, }, @@ -4189,7 +4189,7 @@ module.exports = { id: "throws-after-import", register() {} };`, api.registerService({ id: "shared-service", start() {} }); } };`, selectCount: (registry: ReturnType) => - registry.services.filter((entry) => entry.service.id === "shared-service").length, + countMatching(registry.services, (entry) => entry.service.id === "shared-service"), duplicateMessage: "service already registered: shared-service (service-owner-a)", assert: expectDuplicateRegistrationResult, }, From 1c8e58b4ff8e40dd09411f6053197e3cd9cbac90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:17:36 +0100 Subject: [PATCH 798/806] test: simplify slack final dispatch count --- .../dispatch.preview-fallback.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 266df7bfd0b..33ce73fff83 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -80,6 +80,17 @@ let mockedDispatchSequence: Array<{ mediaUrls?: string[]; }; }> = []; + +function countFinalDispatches(): number { + let count = 0; + for (const entry of mockedDispatchSequence) { + if (entry.kind === "final") { + count++; + } + } + return count; +} + let mockedProgressEvents: string[] = []; let mockedReplyOptionEvents: Array< | { @@ -624,7 +635,7 @@ vi.mock("../reply.runtime.js", () => ({ return { queuedFinal: false, counts: { - final: mockedDispatchSequence.filter((entry) => entry.kind === "final").length, + final: countFinalDispatches(), }, }; }, From 5d335dd6039eec56948ada6f549e464119e89c9d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 23:19:34 +0100 Subject: [PATCH 799/806] fix: pin fast-uri audit dependency --- CHANGELOG.md | 1 + package.json | 2 ++ pnpm-lock.yaml | 9 +++++---- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe061bbfb3..c66864b6601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd. - Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. - Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. diff --git a/package.json b/package.json index d44e5cf8f7b..ac6979e305d 100644 --- a/package.json +++ b/package.json @@ -1775,6 +1775,7 @@ "overrides": { "@aws-sdk/client-bedrock-runtime": "$@aws-sdk/client-bedrock-runtime", "axios": "1.16.0", + "fast-uri": "3.1.2", "follow-redirects": "1.16.0", "ip-address": "10.2.0", "node-domexception": "npm:@nolyfill/domexception@1.0.28", @@ -1791,6 +1792,7 @@ "@hono/node-server": "1.19.14", "@aws-sdk/client-bedrock-runtime": "3.1024.0", "axios": "1.16.0", + "fast-uri": "3.1.2", "follow-redirects": "1.16.0", "defu": "6.1.5", "fast-xml-parser": "5.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d16ebc67ad..171a85c9763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: '@hono/node-server': 1.19.14 '@aws-sdk/client-bedrock-runtime': 3.1024.0 axios: 1.16.0 + fast-uri: 3.1.2 follow-redirects: 1.16.0 defu: 6.1.5 fast-xml-parser: 5.7.0 @@ -5411,8 +5412,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.1: - resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -11524,7 +11525,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.1 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -12307,7 +12308,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.1: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: From 00d64a7148a2da551106994e0293ee50e86cf049 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:20:19 +0100 Subject: [PATCH 800/806] test: simplify session compact line count --- src/gateway/server.sessions.store-rpc.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index af79e1ce994..1a9097d7534 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -11,6 +11,16 @@ import { const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); +function collectNonEmptyLines(text: string): string[] { + const lines: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (line.trim().length > 0) { + lines.push(line); + } + } + return lines; +} + test("lists and patches session store via sessions.* RPC", async () => { const { dir, storePath } = await createSessionStoreDir(); const now = Date.now(); @@ -378,9 +388,9 @@ test("lists and patches session store via sessions.* RPC", async () => { }); expect(compacted.ok).toBe(true); expect(compacted.payload?.compacted).toBe(true); - const compactedLines = (await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8")) - .split(/\r?\n/) - .filter((l) => l.trim().length > 0); + const compactedLines = collectNonEmptyLines( + await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8"), + ); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); expect(filesAfterCompact).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.bak\./)); From 64e731b5e883e4d44c19844df8f618ea034317ba Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 23:20:57 +0100 Subject: [PATCH 801/806] test: avoid cooldown expiry sort allocation --- src/agents/model-fallback.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index b1737efb172..51e54ea4edc 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -131,9 +131,15 @@ const authRuntimeMock = vi.hoisted(() => { continue; } const stats = store.usageStats?.[profileId]; - const expiry = [stats?.cooldownUntil, stats?.disabledUntil] - .filter((value): value is number => isActive(value, ts)) - .toSorted((a, b) => a - b)[0]; + const cooldownUntil = stats?.cooldownUntil; + const disabledUntil = stats?.disabledUntil; + let expiry: number | undefined; + if (isActive(cooldownUntil, ts)) { + expiry = cooldownUntil; + } + if (isActive(disabledUntil, ts) && (expiry === undefined || disabledUntil < expiry)) { + expiry = disabledUntil; + } if (expiry !== undefined && (soonest === null || expiry < soonest)) { soonest = expiry; } From d88f154045f9d11979b8542f03c8f1544e25e91a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:21:56 +0100 Subject: [PATCH 802/806] test: simplify websocket manager close count --- src/agents/openai-ws-stream.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index 91d59977392..185ce90fce9 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -2704,7 +2704,7 @@ describe("createOpenAIWebSocketStreamFn", () => { // The failed manager is closed before the replacement session manager is installed. expect( - MockManager.instances.filter((instance) => instance.closeCallCount >= 1).length, + countMatching(MockManager.instances, (instance) => instance.closeCallCount >= 1), ).toBeGreaterThanOrEqual(1); } finally { MockManager.globalConnectShouldFail = false; From 4415c4e21efef69b8a3e3f2f02cb14ce2b466382 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 23:22:15 +0100 Subject: [PATCH 803/806] test: count qa lab events without filters --- extensions/qa-lab/src/lab-server.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 4d89348af02..e46adbdf335 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -35,6 +35,15 @@ const captureMock = vi.hoisted(() => { return acc; }, {}), ).map(([value, count]) => ({ value, count })); + const countMatching = (values: T[], predicate: (value: T) => boolean) => { + let count = 0; + for (const value of values) { + if (predicate(value)) { + count += 1; + } + } + return count; + }; const store = { upsertSession(session: Record) { @@ -46,7 +55,7 @@ const captureMock = vi.hoisted(() => { listSessions(limit: number) { return sessions.slice(0, limit).map((session) => Object.assign({}, session, { - eventCount: events.filter((event) => event.sessionId === session.id).length, + eventCount: countMatching(events, (event) => event.sessionId === session.id), }), ); }, @@ -59,7 +68,7 @@ const captureMock = vi.hoisted(() => { return { sessionId, totalEvents: selected.length, - unlabeledEventCount: metas.filter((meta) => !meta.provider && !meta.model).length, + unlabeledEventCount: countMatching(metas, (meta) => !meta.provider && !meta.model), providers: countValues(metas.map((meta) => meta.provider as string | undefined)), apis: countValues(metas.map((meta) => meta.api as string | undefined)), models: countValues(metas.map((meta) => meta.model as string | undefined)), From f83037800d594d7cc0ef85cdeeb5b6c7efc94b85 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 23:24:02 +0100 Subject: [PATCH 804/806] test: reuse sessions call counters --- src/agents/openclaw-tools.sessions.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 2dd0ecc1eb5..a1ebce999b1 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -791,7 +791,8 @@ describe("sessions tools", () => { it("sessions_send supports fire-and-forget and wait", async () => { const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; - let _historyCallCount = 0; + let historyCallCount = 0; + let waitCallCount = 0; let sendCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); @@ -820,12 +821,13 @@ describe("sessions tools", () => { }; } if (request.method === "agent.wait") { + waitCallCount += 1; const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { - _historyCallCount += 1; + historyCallCount += 1; const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [ @@ -867,9 +869,9 @@ describe("sessions tools", () => { runId: "run-1", delivery: { status: "pending", mode: "announce" }, }); - await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 3); - await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 3); - await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 3); + await waitForCalls(() => agentCallCount, 3); + await waitForCalls(() => waitCallCount, 3); + await waitForCalls(() => historyCallCount, 3); const waitPromise = tool.execute("call6", { sessionKey: "main", @@ -883,9 +885,9 @@ describe("sessions tools", () => { delivery: { status: "pending", mode: "announce" }, }); expect(typeof (waited.details as { runId?: string }).runId).toBe("string"); - await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 6); - await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 6); - await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 7); + await waitForCalls(() => agentCallCount, 6); + await waitForCalls(() => waitCallCount, 6); + await waitForCalls(() => historyCallCount, 7); const agentCalls = calls.filter((call) => call.method === "agent"); const waitCalls = calls.filter((call) => call.method === "agent.wait"); From 5aa909545432d0f897e3685c7fe8c21c3a24b281 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:25:40 +0100 Subject: [PATCH 805/806] test: guard fallback disabled cooldown expiry --- src/agents/model-fallback.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 51e54ea4edc..7c9df6b4a17 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -137,7 +137,11 @@ const authRuntimeMock = vi.hoisted(() => { if (isActive(cooldownUntil, ts)) { expiry = cooldownUntil; } - if (isActive(disabledUntil, ts) && (expiry === undefined || disabledUntil < expiry)) { + if ( + disabledUntil !== undefined && + isActive(disabledUntil, ts) && + (expiry === undefined || disabledUntil < expiry) + ) { expiry = disabledUntil; } if (expiry !== undefined && (soonest === null || expiry < soonest)) { From 23341e5432a2206757e56b75f411103d74e02b1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 23:29:41 +0100 Subject: [PATCH 806/806] test: simplify oc path matched item collection --- src/oc-path/tests/find.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/oc-path/tests/find.test.ts b/src/oc-path/tests/find.test.ts index 68bdd879132..7cde01967fa 100644 --- a/src/oc-path/tests/find.test.ts +++ b/src/oc-path/tests/find.test.ts @@ -16,6 +16,16 @@ import { parseMd } from "../parse.js"; import { resolveOcPath, setOcPath } from "../universal.js"; import { parseYaml } from "../yaml/parse.js"; +function collectMatchedItems(matches: readonly { path: { item?: string } }[]): string[] { + const items: string[] = []; + for (const match of matches) { + if (match.path.item !== undefined) { + items.push(match.path.item); + } + } + return items; +} + // ---------- hasWildcard ---------------------------------------------------- describe("hasWildcard", () => { @@ -699,7 +709,7 @@ describe("findOcPaths — Markdown kind", () => { // The `send-email` item is under the `tools` block. Pin that we // get at least one match (the substrate's md `**` should reach it). expect(out.length).toBeGreaterThanOrEqual(1); - const items = out.map((m) => m.path.item).filter((v): v is string => v !== undefined); + const items = collectMatchedItems(out); expect(items).toContain("send-email"); }); });