From a2a49b430cd6b864f4690425b519691e57eb9e46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:04:53 +0100 Subject: [PATCH 01/93] test(plugins): route tts contract helper changes narrowly --- scripts/test-projects.test-support.mjs | 24 ++++++++++++--- .../inventory/bundled-capability-metadata.ts | 30 ++++++++++++++++++- src/plugins/contracts/registry.ts | 13 ++++++++ test/helpers/plugins/tts-contract-suites.ts | 17 +++++------ test/scripts/test-projects.test.ts | 11 +++++++ 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index f915a5d1610..c309e2b1e6e 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -215,6 +215,15 @@ const BROAD_CHANGED_RERUN_PATTERNS = [ /^test\/vitest\/vitest\.(?:config|shared\.config|scoped-config|performance-config)\.ts$/u, /^test\/helpers\//u, ]; +const PRECISE_SOURCE_TEST_TARGETS = new Map([ + [ + "test/helpers/plugins/tts-contract-suites.ts", + [ + "src/plugins/contracts/core-extension-facade-boundary.test.ts", + "src/plugins/contracts/tts.contract.test.ts", + ], + ], +]); const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], @@ -239,6 +248,7 @@ const TOOLING_TEST_TARGETS = new Map([ ], ]); const SOURCE_TEST_TARGETS = new Map([ + ...PRECISE_SOURCE_TEST_TARGETS, ["src/agents/live-model-turn-probes.ts", ["src/agents/live-model-turn-probes.test.ts"]], [ "src/auto-reply/reply/dispatch-from-config.ts", @@ -487,15 +497,16 @@ function stripChangedArgs(args) { function shouldKeepBroadChangedRun(changedPaths) { return changedPaths.some((changedPath) => - BROAD_CHANGED_RERUN_PATTERNS.some((pattern) => pattern.test(changedPath)), + PRECISE_SOURCE_TEST_TARGETS.has(changedPath) + ? false + : BROAD_CHANGED_RERUN_PATTERNS.some((pattern) => pattern.test(changedPath)), ); } function resolveToolingChangedTestTargets(changedPaths) { const targets = []; for (const changedPath of changedPaths) { - const testTargets = - TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath); + const testTargets = resolveToolingTestTargets(changedPath); if (!testTargets) { return null; } @@ -504,6 +515,10 @@ function resolveToolingChangedTestTargets(changedPaths) { return [...new Set(targets)]; } +function resolveToolingTestTargets(changedPath) { + return TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath); +} + function isRoutableChangedTarget(changedPath) { if (GENERATED_CHANGED_TEST_TARGETS.has(changedPath)) { return false; @@ -530,7 +545,8 @@ export function resolveChangedTestTargetPlan(changedPaths) { return { mode: "broad", targets: [] }; } const targets = changedPaths.flatMap((changedPath) => { - const mappedTargets = SOURCE_TEST_TARGETS.get(changedPath); + const mappedTargets = + resolveToolingTestTargets(changedPath) ?? SOURCE_TEST_TARGETS.get(changedPath); if (mappedTargets) { return mappedTargets; } diff --git a/src/plugins/contracts/inventory/bundled-capability-metadata.ts b/src/plugins/contracts/inventory/bundled-capability-metadata.ts index 3bced92a5ac..403908f5a9b 100644 --- a/src/plugins/contracts/inventory/bundled-capability-metadata.ts +++ b/src/plugins/contracts/inventory/bundled-capability-metadata.ts @@ -16,6 +16,7 @@ export type BundledPluginContractSnapshot = { pluginId: string; cliBackendIds: string[]; providerIds: string[]; + providerAuthEnvVars: Record; speechProviderIds: string[]; realtimeTranscriptionProviderIds: string[]; realtimeVoiceProviderIds: string[]; @@ -47,6 +48,7 @@ export type BundledCapabilityManifest = Pick< | "cliBackends" | "contracts" | "legacyPluginIds" + | "providerAuthEnvVars" | "providers" >; @@ -98,6 +100,26 @@ function listBundledCapabilityManifests(): readonly BundledCapabilityManifest[] const BUNDLED_CAPABILITY_MANIFESTS = listBundledCapabilityManifests(); +function normalizeStringListRecord(record: unknown): Record { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return {}; + } + return Object.fromEntries( + Object.entries(record) + .map( + ([key, values]) => + [ + key.trim(), + uniqueStrings(Array.isArray(values) ? values : [], (value) => + typeof value === "string" ? value.trim() : "", + ), + ] as const, + ) + .filter(([key, values]) => key && values.length > 0) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + export function buildBundledPluginContractSnapshot( manifest: BundledCapabilityManifest, ): BundledPluginContractSnapshot { @@ -105,6 +127,7 @@ export function buildBundledPluginContractSnapshot( pluginId: manifest.id, cliBackendIds: uniqueStrings(manifest.cliBackends, (value) => value.trim()), providerIds: uniqueStrings(manifest.providers, (value) => value.trim()), + providerAuthEnvVars: normalizeStringListRecord(manifest.providerAuthEnvVars), speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders, (value) => value.trim()), realtimeTranscriptionProviderIds: uniqueStrings( manifest.contracts?.realtimeTranscriptionProviders, @@ -188,8 +211,13 @@ export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries( ).toSorted(([left], [right]) => left.localeCompare(right)), ) as Readonly>; +type BundledContractIdSnapshotKey = Exclude< + keyof Omit, + "providerAuthEnvVars" +>; + export function resolveBundledContractSnapshotPluginIds( - key: keyof Omit, + key: BundledContractIdSnapshotKey, ): string[] { return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) => entry[key].length > 0) .map((entry) => entry.pluginId) diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 961e127a419..6d13d204c29 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -75,12 +75,24 @@ type ManifestContractKey = type ManifestRegistryContractKey = "webFetchProviders" | "webSearchProviders"; +function normalizeProviderAuthEnvVars( + providerAuthEnvVars: Record | undefined, +): Record { + return Object.fromEntries( + Object.entries(providerAuthEnvVars ?? {}).map(([providerId, envVars]) => [ + providerId, + uniqueStrings(envVars), + ]), + ); +} + function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { if (process.env.VITEST) { return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.map((entry) => ({ pluginId: entry.pluginId, cliBackendIds: [...entry.cliBackendIds], providerIds: [...entry.providerIds], + providerAuthEnvVars: normalizeProviderAuthEnvVars(entry.providerAuthEnvVars), speechProviderIds: [...entry.speechProviderIds], realtimeTranscriptionProviderIds: [...entry.realtimeTranscriptionProviderIds], realtimeVoiceProviderIds: [...entry.realtimeVoiceProviderIds], @@ -118,6 +130,7 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { pluginId: plugin.id, cliBackendIds: uniqueStrings(plugin.cliBackends), providerIds: uniqueStrings(plugin.providers), + providerAuthEnvVars: normalizeProviderAuthEnvVars(plugin.providerAuthEnvVars), speechProviderIds: uniqueStrings(plugin.contracts?.speechProviders ?? []), realtimeTranscriptionProviderIds: uniqueStrings( plugin.contracts?.realtimeTranscriptionProviders ?? [], diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index 342791fb29e..e0d6ddcbbcb 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "../../../src/plugins/contracts/inventory/bundled-capability-metadata.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; @@ -37,16 +38,12 @@ let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; const SPEECH_PROVIDER_ENV_KEYS = [ - "ELEVENLABS_API_KEY", - "GEMINI_API_KEY", - "GOOGLE_API_KEY", - "GRADIUM_API_KEY", - "MINIMAX_API_KEY", - "OPENAI_API_KEY", - "VYDRA_API_KEY", - "XAI_API_KEY", - "XI_API_KEY", -] as const; + ...new Set( + BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.flatMap((entry) => + entry.speechProviderIds.flatMap((providerId) => entry.providerAuthEnvVars[providerId] ?? []), + ), + ), +].toSorted((left, right) => left.localeCompare(right)); function isolatedSpeechProviderEnv( overrides: Record = {}, diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 476bbc333be..7f00db5ff7a 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -137,6 +137,17 @@ describe("scripts/test-projects changed-target routing", () => { ).toBeNull(); }); + it("routes precise plugin contract helpers without broad-running every shard", () => { + expect( + resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [ + "test/helpers/plugins/tts-contract-suites.ts", + ]), + ).toEqual([ + "src/plugins/contracts/core-extension-facade-boundary.test.ts", + "src/plugins/contracts/tts.contract.test.ts", + ]); + }); + it("keeps the broad changed run for unknown root surfaces", () => { expect( resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [ From 3f63ba8fd808f46566aabbca194fa54c2d6b4871 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:10:49 +0100 Subject: [PATCH 02/93] fix(webchat): hide heartbeat history artifacts --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 3 + src/gateway/session-history-state.test.ts | 119 ++++++++++++++++++++++ src/gateway/session-history-state.ts | 106 ++++++++++++++++++- ui/src/ui/chat/message-extract.test.ts | 21 ++++ ui/src/ui/chat/message-extract.ts | 8 +- ui/src/ui/controllers/chat.test.ts | 33 ++++++ ui/src/ui/controllers/chat.ts | 62 ++++++++++- 8 files changed, 346 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8c16fc50b..3a89bfea1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. - Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920. - Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 8bc9f63cfcd..008c44782ad 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -265,6 +265,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele send chat output to, and it is disabled by `typingMode: "never"`. - Heartbeat-only replies do **not** keep the session alive; the last `updatedAt` is restored so idle expiry behaves normally. +- Control UI and WebChat history hide heartbeat prompts and OK-only + acknowledgments. The underlying session transcript can still contain those + turns for audit/replay. - Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task. ## Visibility controls diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index a7a847a148f..95d827f727c 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from "vitest"; +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js"; import * as sessionUtils from "./session-utils.js"; @@ -107,4 +108,122 @@ describe("SessionHistorySseState", () => { ).content?.[0]?.text, ).toBe("visible ask"); }); + + test("drops internal-only user messages after envelope stripping", () => { + const snapshot = buildSessionHistorySnapshot({ + rawMessages: [ + { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "subagent completion payload", + "<<>>", + ].join("\n"), + }, + ], + __openclaw: { seq: 1 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + __openclaw: { seq: 2 }, + }, + ], + }); + + expect(snapshot.history.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + __openclaw: { seq: 2 }, + }, + ]); + }); + + test("hides heartbeat prompt and ok acknowledgements from visible history", () => { + const snapshot = buildSessionHistorySnapshot({ + rawMessages: [ + { + role: "user", + content: `${HEARTBEAT_PROMPT}\nWhen reading HEARTBEAT.md, use workspace file /tmp/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.`, + __openclaw: { seq: 1 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "HEARTBEAT_OK" }], + __openclaw: { seq: 2 }, + }, + { + role: "user", + content: HEARTBEAT_PROMPT, + __openclaw: { seq: 3 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "Disk usage crossed 95 percent." }], + __openclaw: { seq: 4 }, + }, + ], + }); + + expect(snapshot.history.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Disk usage crossed 95 percent." }], + __openclaw: { seq: 4 }, + }, + ]); + expect(snapshot.rawTranscriptSeq).toBe(4); + }); + + test("does not append heartbeat or internal-only SSE messages", () => { + const state = SessionHistorySseState.fromRawSnapshot({ + target: { sessionId: "sess-main" }, + rawMessages: [ + { + role: "assistant", + content: [{ type: "text", text: "already visible" }], + __openclaw: { seq: 1 }, + }, + ], + }); + + expect( + state.appendInlineMessage({ + message: { + role: "user", + content: HEARTBEAT_PROMPT, + }, + }), + ).toBeNull(); + expect( + state.appendInlineMessage({ + message: { + role: "assistant", + content: [{ type: "text", text: "HEARTBEAT_OK" }], + }, + }), + ).toBeNull(); + expect( + state.appendInlineMessage({ + message: { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "runtime details", + "<<>>", + ].join("\n"), + }, + ], + }, + }), + ).toBeNull(); + expect(state.snapshot().messages).toHaveLength(1); + }); }); diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 8c076afb5ff..c66fa4ab75c 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -1,3 +1,5 @@ +import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, @@ -31,6 +33,102 @@ type SessionHistoryTranscriptTarget = { sessionFile?: string; }; +type RoleContentMessage = { + role: string; + content?: unknown; +}; + +function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null { + const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; + if (!role) { + return null; + } + return { + role, + ...(message.content !== undefined + ? { content: message.content } + : message.text !== undefined + ? { content: message.text } + : {}), + }; +} + +function isEmptyTextOnlyContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length === 0; + } + if (!Array.isArray(content)) { + return false; + } + if (content.length === 0) { + return true; + } + let sawText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; text?: unknown }; + if (entry.type !== "text") { + return false; + } + sawText = true; + if (typeof entry.text !== "string" || entry.text.trim().length > 0) { + return false; + } + } + return sawText; +} + +function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean { + const roleContent = asRoleContentMessage(message); + if (!roleContent) { + return false; + } + if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) { + return true; + } + if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) { + return true; + } + return isHeartbeatOkResponse(roleContent); +} + +function filterVisibleSessionHistoryMessages( + messages: SessionHistoryMessage[], +): SessionHistoryMessage[] { + if (messages.length === 0) { + return messages; + } + let changed = false; + const visible: SessionHistoryMessage[] = []; + for (let i = 0; i < messages.length; i++) { + const current = messages[i]; + if (!current) { + continue; + } + const currentRoleContent = asRoleContentMessage(current); + const next = messages[i + 1]; + const nextRoleContent = next ? asRoleContentMessage(next) : null; + if ( + currentRoleContent && + nextRoleContent && + isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) && + isHeartbeatOkResponse(nextRoleContent) + ) { + changed = true; + i++; + continue; + } + if (shouldHideSanitizedHistoryMessage(current)) { + changed = true; + continue; + } + visible.push(current); + } + return changed ? visible : messages; +} + function resolveCursorSeq(cursor: string | undefined): number | undefined { if (!cursor) { return undefined; @@ -100,16 +198,15 @@ export function buildSessionHistorySnapshot(params: { limit?: number; cursor?: string; }): SessionHistorySnapshot { - const history = paginateSessionMessages( + const visibleMessages = filterVisibleSessionHistoryMessages( toSessionHistoryMessages( sanitizeChatHistoryMessages( stripEnvelopeFromMessages(params.rawMessages), params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, ), ), - params.limit, - params.cursor, ); + const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor); const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages); return { history, @@ -190,6 +287,9 @@ export class SessionHistorySseState { if (!sanitizedMessage) { return null; } + if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) { + return null; + } const nextMessages = [...this.sentHistory.messages, sanitizedMessage]; this.sentHistory = buildPaginatedSessionHistory({ messages: nextMessages, diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts index 6455255f46c..bf97875d156 100644 --- a/ui/src/ui/chat/message-extract.test.ts +++ b/ui/src/ui/chat/message-extract.test.ts @@ -77,6 +77,27 @@ describe("extractTextCached", () => { expect(extractText(message)).toBeNull(); expect(extractTextCached(message)).toBeNull(); }); + + it("strips internal runtime context blocks from user text", () => { + const message = { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "internal subagent payload", + "<<>>", + "", + "visible ask", + ].join("\n"), + }, + ], + }; + + expect(extractText(message)).toBe("visible ask"); + expect(extractTextCached(message)).toBe("visible ask"); + }); }); describe("extractThinkingCached", () => { diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index e743d8c305a..bfae47a98e6 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,3 +1,4 @@ +import { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js"; import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js"; @@ -9,12 +10,13 @@ const thinkingCache = new WeakMap(); function processMessageText(text: string, role: string): string { const shouldStripInboundMetadata = normalizeLowercaseStringOrEmpty(role) === "user"; + const withoutInternalContext = stripInternalRuntimeContext(text); if (role === "assistant") { - return stripThinkingTags(text); + return stripThinkingTags(withoutInternalContext); } return shouldStripInboundMetadata - ? stripInboundMetadata(stripEnvelope(text)) - : stripEnvelope(text); + ? stripInboundMetadata(stripEnvelope(withoutInternalContext)) + : stripEnvelope(withoutInternalContext); } export function extractText(message: unknown): string | null { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index f761f0813b1..c74a4689814 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -753,6 +753,39 @@ describe("loadChatHistory", () => { expect(state.lastError).toBeNull(); }); + it("filters heartbeat acknowledgements and internal-only user messages", async () => { + const request = vi.fn().mockResolvedValue({ + messages: [ + { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] }, + { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "subagent completion payload", + "<<>>", + ].join("\n"), + }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "visible answer" }] }, + ], + thinkingLevel: "low", + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + await loadChatHistory(state); + + expect(state.chatMessages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "visible answer" }] }, + ]); + }); + it("shows a targeted message when chat history is unauthorized", async () => { const request = vi.fn().mockRejectedValue( new GatewayRequestError({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 8b48d848109..ffac5f18502 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,3 +1,4 @@ +import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js"; import { resetToolStream } from "../app-tool-stream.ts"; import { extractText } from "../chat/message-extract.ts"; import { formatConnectError } from "../connect-error.ts"; @@ -71,8 +72,67 @@ function isSyntheticTranscriptRepairToolResult(message: unknown): boolean { return typeof text === "string" && text.trim() === SYNTHETIC_TRANSCRIPT_REPAIR_RESULT; } +function isTextOnlyContent(content: unknown): boolean { + if (typeof content === "string") { + return true; + } + if (!Array.isArray(content)) { + return false; + } + if (content.length === 0) { + return true; + } + let sawText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; text?: unknown }; + if (entry.type !== "text") { + return false; + } + sawText = true; + if (typeof entry.text !== "string") { + return false; + } + } + return sawText; +} + +function isEmptyUserTextOnlyMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as Record; + if (normalizeLowercaseStringOrEmpty(entry.role) !== "user") { + return false; + } + if (!isTextOnlyContent(entry.content ?? entry.text)) { + return false; + } + return (extractText(message)?.trim() ?? "") === ""; +} + +function isAssistantHeartbeatAck(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as Record; + const role = normalizeLowercaseStringOrEmpty(entry.role); + if (role !== "assistant") { + return false; + } + const content = entry.content ?? entry.text; + return isHeartbeatOkResponse({ role, content }); +} + function shouldHideHistoryMessage(message: unknown): boolean { - return isAssistantSilentReply(message) || isSyntheticTranscriptRepairToolResult(message); + return ( + isAssistantSilentReply(message) || + isAssistantHeartbeatAck(message) || + isSyntheticTranscriptRepairToolResult(message) || + isEmptyUserTextOnlyMessage(message) + ); } function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError { From 8acc92c881abc1cc02ff5695e15b75b94e294b07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:11:15 +0100 Subject: [PATCH 03/93] feat(google): support Gemini TTS style profile --- CHANGELOG.md | 1 + docs/providers/google.md | 12 +++++-- docs/tools/tts.md | 2 ++ extensions/google/speech-provider.test.ts | 37 +++++++++++++++++++++ extensions/google/speech-provider.ts | 40 ++++++++++++++++++++++- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a89bfea1c8..097034b461f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete. - Plugins/Bonjour: move LAN Gateway discovery advertising into a default-enabled bundled plugin with its own `@homebridge/ciao` dependency, so users can disable Bonjour without cutting wide-area discovery. Thanks @vincentkoc. - Providers/Google: add a Gemini Live realtime voice provider for backend Voice Call and Google Meet audio bridges, with bidirectional audio and function-call support. Thanks @steipete. +- Providers/Google: let Gemini TTS prepend configured `audioProfile` and `speakerName` prompt text for reusable speech style control. Thanks @tdack. - Plugins/Google Meet: let realtime Meet sessions consult the full OpenClaw agent for deeper answers while staying in the live voice loop. Thanks @steipete. - Gateway/VoiceClaw: add a realtime brain WebSocket endpoint backed by Gemini Live, with owner-auth gating and async OpenClaw tool handoff. (#70938) Thanks @yagudaev. - Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default. Thanks @lsdsjy. diff --git a/docs/providers/google.md b/docs/providers/google.md index ae8e2625a59..661cc7b7f2c 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -267,6 +267,7 @@ To use Google as the default TTS provider: google: { model: "gemini-3.1-flash-tts-preview", voiceName: "Kore", + audioProfile: "Speak professionally with a calm tone.", }, }, }, @@ -274,9 +275,14 @@ To use Google as the default TTS provider: } ``` -Gemini API TTS accepts expressive square-bracket audio tags in the text, such as -`[whispers]` or `[laughs]`. To keep tags out of the visible chat reply while -sending them to TTS, put them inside a `[[tts:text]]...[[/tts:text]]` block: +Gemini API TTS uses natural-language prompting for style control. Set +`audioProfile` to prepend a reusable style prompt before the spoken text. Set +`speakerName` when your prompt text refers to a named speaker. + +Gemini API TTS also accepts expressive square-bracket audio tags in the text, +such as `[whispers]` or `[laughs]`. To keep tags out of the visible chat reply +while sending them to TTS, put them inside a `[[tts:text]]...[[/tts:text]]` +block: ```text Here is the clean reply text. diff --git a/docs/tools/tts.md b/docs/tools/tts.md index 30a7104b3c3..16780a098f0 100644 --- a/docs/tools/tts.md +++ b/docs/tools/tts.md @@ -379,6 +379,8 @@ Then run: - `providers.minimax.pitch`: integer pitch shift `-12..12` (default 0). Fractional values are truncated before calling MiniMax T2A because the API rejects non-integer pitch values. - `providers.google.model`: Gemini TTS model (default `gemini-3.1-flash-tts-preview`). - `providers.google.voiceName`: Gemini prebuilt voice name (default `Kore`; `voice` is also accepted). +- `providers.google.audioProfile`: natural-language style prompt prepended before the spoken text. +- `providers.google.speakerName`: optional speaker label prepended before the spoken text when your TTS prompt uses a named speaker. - `providers.google.baseUrl`: override the Gemini API base URL. Only `https://generativelanguage.googleapis.com` is accepted. - If `messages.tts.providers.google.apiKey` is omitted, TTS can reuse `models.providers.google.apiKey` before env fallback. - `providers.gradium.baseUrl`: override Gradium API base URL (default `https://api.gradium.ai`). diff --git a/extensions/google/speech-provider.test.ts b/extensions/google/speech-provider.test.ts index c55deb20610..f64e90364f4 100644 --- a/extensions/google/speech-provider.test.ts +++ b/extensions/google/speech-provider.test.ts @@ -166,6 +166,39 @@ describe("Google speech provider", () => { }); }); + it("prepends configured Gemini TTS profile text", async () => { + const fetchMock = installGoogleTtsFetchMock(); + const provider = buildGoogleSpeechProvider(); + + await provider.synthesize({ + text: "Status update starts now.", + cfg: {}, + providerConfig: { + apiKey: "google-test-key", + audioProfile: "Speak professionally with a calm executive tone.", + speakerName: "Alex", + }, + target: "audio-file", + timeoutMs: 10_000, + }); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(String(init.body))).toMatchObject({ + contents: [ + { + parts: [ + { + text: + "Speak professionally with a calm executive tone.\n\n" + + "Speaker name: Alex\n\n" + + "Status update starts now.", + }, + ], + }, + ], + }); + }); + it("resolves provider config and directive overrides", () => { const provider = buildGoogleSpeechProvider(); @@ -178,6 +211,8 @@ describe("Google speech provider", () => { apiKey: "configured-key", model: "google/gemini-3.1-flash-tts-preview", voice: "Leda", + audioProfile: "Speak warmly.", + speakerName: "Narrator", }, }, }, @@ -185,8 +220,10 @@ describe("Google speech provider", () => { }), ).toEqual({ apiKey: "configured-key", + audioProfile: "Speak warmly.", baseUrl: undefined, model: "gemini-3.1-flash-tts-preview", + speakerName: "Narrator", voiceName: "Leda", }); diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index ff91c57664a..a34a8907916 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -55,11 +55,15 @@ type GoogleTtsProviderConfig = { baseUrl?: string; model: string; voiceName: string; + audioProfile?: string; + speakerName?: string; }; type GoogleTtsProviderOverrides = { model?: string; voiceName?: string; + audioProfile?: string; + speakerName?: string; }; type Maybe = T | undefined; @@ -148,6 +152,8 @@ function normalizeGoogleTtsProviderConfig( baseUrl: trimToUndefined(raw?.baseUrl), model: normalizeGoogleTtsModel(raw?.model), voiceName: normalizeGoogleTtsVoiceName(raw?.voiceName ?? raw?.voice), + audioProfile: trimToUndefined(raw?.audioProfile), + speakerName: trimToUndefined(raw?.speakerName), }; } @@ -160,6 +166,8 @@ function readGoogleTtsProviderConfig(config: SpeechProviderConfig): GoogleTtsPro voiceName: normalizeGoogleTtsVoiceName( config.voiceName ?? config.voice ?? normalized.voiceName, ), + audioProfile: trimToUndefined(config.audioProfile) ?? normalized.audioProfile, + speakerName: trimToUndefined(config.speakerName) ?? normalized.speakerName, }; } @@ -172,9 +180,25 @@ function readGoogleTtsOverrides( return { model: normalizeOptionalString(overrides.model), voiceName: normalizeOptionalString(overrides.voiceName ?? overrides.voice), + audioProfile: normalizeOptionalString(overrides.audioProfile), + speakerName: normalizeOptionalString(overrides.speakerName), }; } +function composeGoogleTtsText(params: { + text: string; + audioProfile?: string; + speakerName?: string; +}): string { + return [ + trimToUndefined(params.audioProfile), + trimToUndefined(params.speakerName) ? `Speaker name: ${params.speakerName}` : undefined, + params.text, + ] + .filter((part): part is string => part !== undefined) + .join("\n\n"); +} + function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { handled: boolean; overrides?: SpeechProviderOverrides; @@ -242,6 +266,8 @@ async function synthesizeGoogleTtsPcm(params: { baseUrl?: string; model: string; voiceName: string; + audioProfile?: string; + speakerName?: string; timeoutMs: number; }): Promise { const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = @@ -259,7 +285,15 @@ async function synthesizeGoogleTtsPcm(params: { contents: [ { role: "user", - parts: [{ text: params.text }], + parts: [ + { + text: composeGoogleTtsText({ + text: params.text, + audioProfile: params.audioProfile, + speakerName: params.speakerName, + }), + }, + ], }, ], generationConfig: { @@ -347,6 +381,8 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin { baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }), model: normalizeGoogleTtsModel(overrides.model ?? config.model), voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName), + audioProfile: overrides.audioProfile ?? config.audioProfile, + speakerName: overrides.speakerName ?? config.speakerName, timeoutMs: req.timeoutMs, }); return { @@ -371,6 +407,8 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin { baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }), model: config.model, voiceName: config.voiceName, + audioProfile: config.audioProfile, + speakerName: config.speakerName, timeoutMs: req.timeoutMs, }); return { From 7a9584f0f9f033e7b90878350ee8d1bc5151c24c Mon Sep 17 00:00:00 2001 From: alexlomt Date: Sat, 25 Apr 2026 07:13:30 +0200 Subject: [PATCH 04/93] fix(ci): harden release checks workflow inputs (#66884) Merged via squash. Prepared head SHA: d4e00973012eed2c60a027e3c4be472e0c0a4663 Co-authored-by: alexlomt <181166594+alexlomt@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- ...nclaw-cross-os-release-checks-reusable.yml | 33 ++++++++++++------- CHANGELOG.md | 9 +++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml index 6ee07b4c6f1..474139e7b75 100644 --- a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml +++ b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml @@ -432,24 +432,35 @@ jobs: OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }} OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }} OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }} + CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }} + CANDIDATE_VERSION: ${{ needs.prepare.outputs.candidate_version }} + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} + BASELINE_SPEC: ${{ needs.prepare.outputs.baseline_spec }} + PREVIOUS_VERSION: ${{ inputs.previous_version }} + BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }} + PROVIDER: ${{ inputs.provider }} + MODE: ${{ matrix.lane }} + SUITE: ${{ matrix.suite }} + REF: ${{ inputs.ref }} + OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }} run: | DISCORD_ARGS=() if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then DISCORD_ARGS+=(--run-discord-roundtrip true) fi pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \ - --candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \ - --candidate-version "${{ needs.prepare.outputs.candidate_version }}" \ - --source-sha "${{ needs.prepare.outputs.source_sha }}" \ - --baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \ - --previous-version "${{ inputs.previous_version }}" \ - --baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \ - --provider "${{ inputs.provider }}" \ - --mode "${{ matrix.lane }}" \ - --suite "${{ matrix.suite }}" \ - --ref "${{ inputs.ref }}" \ + --candidate-tgz "${CANDIDATE_TGZ}" \ + --candidate-version "${CANDIDATE_VERSION}" \ + --source-sha "${SOURCE_SHA}" \ + --baseline-spec "${BASELINE_SPEC}" \ + --previous-version "${PREVIOUS_VERSION}" \ + --baseline-tgz "${BASELINE_TGZ}" \ + --provider "${PROVIDER}" \ + --mode "${MODE}" \ + --suite "${SUITE}" \ + --ref "${REF}" \ "${DISCORD_ARGS[@]}" \ - --output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}" + --output-dir "${OUTPUT_DIR}" - name: Summarize release checks if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 097034b461f..566689b1c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +### Fixes + +- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. +- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt + ## 2026.4.24 (Unreleased) ### Breaking From f0ceb4b68f5d0bc69eb7e580baa485df726232b7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:15:19 -0700 Subject: [PATCH 05/93] fix(cron): isolate fresh cron session state * fix(cron): isolate fresh cron session state * fix(cron): deep-copy isolated session state * fix(cron): reset isolated session context * test(providers): avoid shared mock races * test(providers): type injected stream fakes * ci: refresh package boundary on reply runtime changes --------- Co-authored-by: Peter Steinberger --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + extensions/anthropic-vertex/api.test.ts | 70 +++--- extensions/anthropic-vertex/api.ts | 7 +- .../anthropic-vertex/stream-runtime.test.ts | 121 ++++++----- extensions/anthropic-vertex/stream-runtime.ts | 28 ++- extensions/microsoft/tts.test.ts | 54 ++--- extensions/microsoft/tts.ts | 47 ++-- ...e-extension-package-boundary-artifacts.mjs | 1 + .../isolated-agent/run-session-state.test.ts | 92 ++++++++ src/cron/isolated-agent/run-session-state.ts | 9 +- src/cron/isolated-agent/session.test.ts | 200 ++++++++++++++++++ src/cron/isolated-agent/session.ts | 117 ++++++++-- 13 files changed, 595 insertions(+), 154 deletions(-) create mode 100644 src/cron/isolated-agent/run-session-state.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a24eb8e3e2b..573a3efb308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1587,7 +1587,7 @@ jobs: packages/plugin-sdk/dist extensions/*/dist/.boundary-tsc.tsbuildinfo extensions/*/dist/.boundary-tsc.stamp - key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }} + key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-extension-package-boundary-v1- diff --git a/CHANGELOG.md b/CHANGELOG.md index 566689b1c67..52d853b4c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: tell agents to recover already-open Meet tabs after browser timeouts, and make the dev CLI release its build lock if compiler spawning fails. Thanks @steipete. - Plugins/Google Meet: return structured manual-action details when browser-based meeting creation needs login or permissions, so agents can guide the operator without opening duplicate Meet tabs. Thanks @steipete. - Plugins/CLI: provide Gateway-backed node inspection to plugin commands, so `googlemeet recover-tab` can inspect paired browser nodes from the terminal. Thanks @steipete. +- Cron/isolated sessions: clear stale runtime, lifecycle, auth, model, exec, heartbeat, usage, privilege, routing, and delivery artifacts when creating a fresh isolated run, and persist per-run session rows as snapshots so old base-session state no longer leaks into new cron executions. Thanks @vincentkoc. - Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi. - Codex approvals: sanitize MCP elicitation approval titles, descriptions, and display parameters before forwarding them to OpenClaw approval prompts. (#71343) Thanks @Lucenx9. - Codex approvals: keep command approval responses within Codex app-server `availableDecisions`, including deny/cancel fallbacks for prompts that do not offer `decline`. (#71338) Thanks @Lucenx9. diff --git a/extensions/anthropic-vertex/api.test.ts b/extensions/anthropic-vertex/api.test.ts index d177f8833ed..e276dd8ef49 100644 --- a/extensions/anthropic-vertex/api.test.ts +++ b/extensions/anthropic-vertex/api.test.ts @@ -1,31 +1,30 @@ -import type { Model } from "@mariozechner/pi-ai"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; -const hoisted = vi.hoisted(() => { - const streamAnthropicMock = vi.fn(() => Symbol("anthropic-vertex-stream")); +function createStreamDeps(): { + deps: AnthropicVertexStreamDeps; + streamAnthropicMock: ReturnType; + anthropicVertexCtorMock: ReturnType; +} { + const streamAnthropicMock = vi.fn( + (..._args: Parameters) => + createAssistantMessageEventStream(), + ); const anthropicVertexCtorMock = vi.fn(); + const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) { + anthropicVertexCtorMock(options); + } as unknown as AnthropicVertexStreamDeps["AnthropicVertex"]; return { + deps: { + AnthropicVertex: MockAnthropicVertex, + streamAnthropic: streamAnthropicMock, + }, streamAnthropicMock, anthropicVertexCtorMock, }; -}); - -vi.mock("@mariozechner/pi-ai", async () => { - const original = - await vi.importActual("@mariozechner/pi-ai"); - return { - ...original, - streamAnthropic: hoisted.streamAnthropicMock, - }; -}); - -vi.mock("@anthropic-ai/vertex-sdk", () => ({ - AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) { - hoisted.anthropicVertexCtorMock(options); - return { options }; - }), -})); +} let createAnthropicVertexStreamFn: typeof import("./api.js").createAnthropicVertexStreamFn; let createAnthropicVertexStreamFnForModel: typeof import("./api.js").createAnthropicVertexStreamFnForModel; @@ -45,33 +44,34 @@ describe("Anthropic Vertex API stream factories", () => { await import("./api.js")); }); - beforeEach(() => { - hoisted.streamAnthropicMock.mockClear(); - hoisted.anthropicVertexCtorMock.mockClear(); - }); - it("reuses the runtime stream factory across direct stream calls", async () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel(); await streamFn(model, { messages: [] }, {}); await streamFn(model, { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2); + expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1); + expect(streamAnthropicMock).toHaveBeenCalledTimes(2); }); it("reuses the runtime stream factory across model-derived stream calls", async () => { - const streamFn = createAnthropicVertexStreamFnForModel(makeModel(), { - ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project", - GOOGLE_CLOUD_LOCATION: "us-east5", - } as NodeJS.ProcessEnv); + const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFnForModel( + makeModel(), + { + ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project", + GOOGLE_CLOUD_LOCATION: "us-east5", + } as NodeJS.ProcessEnv, + deps, + ); const model = makeModel(); await streamFn(model, { messages: [] }, {}); await streamFn(model, { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2); + expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1); + expect(streamAnthropicMock).toHaveBeenCalledTimes(2); }); }); diff --git a/extensions/anthropic-vertex/api.ts b/extensions/anthropic-vertex/api.ts index e44b82b045a..35f639e5462 100644 --- a/extensions/anthropic-vertex/api.ts +++ b/extensions/anthropic-vertex/api.ts @@ -1,4 +1,5 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; export { ANTHROPIC_VERTEX_DEFAULT_MODEL_ID, @@ -47,9 +48,10 @@ export function createAnthropicVertexStreamFn( projectId: string | undefined, region: string, baseURL?: string, + deps?: AnthropicVertexStreamDeps, ): StreamFn { const streamFnPromise = import("./stream-runtime.js").then((runtime) => - runtime.createAnthropicVertexStreamFn(projectId, region, baseURL), + runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps), ); return async (model, context, options) => { const streamFn = await streamFnPromise; @@ -60,9 +62,10 @@ export function createAnthropicVertexStreamFn( export function createAnthropicVertexStreamFnForModel( model: { baseUrl?: string }, env: NodeJS.ProcessEnv = process.env, + deps?: AnthropicVertexStreamDeps, ): StreamFn { const streamFnPromise = import("./stream-runtime.js").then((runtime) => - runtime.createAnthropicVertexStreamFnForModel(model, env), + runtime.createAnthropicVertexStreamFnForModel(model, env, deps), ); return async (...args) => { const streamFn = await streamFnPromise; diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index bddf6e4ccf4..bcbcda146cd 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -1,36 +1,32 @@ -import type { Model } from "@mariozechner/pi-ai"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n\n"; -const hoisted = vi.hoisted(() => { - const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>( - () => Symbol("anthropic-vertex-stream"), +function createStreamDeps(): { + deps: AnthropicVertexStreamDeps; + streamAnthropicMock: ReturnType; + anthropicVertexCtorMock: ReturnType; +} { + const streamAnthropicMock = vi.fn( + (..._args: Parameters) => + createAssistantMessageEventStream(), ); const anthropicVertexCtorMock = vi.fn(); + const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) { + anthropicVertexCtorMock(options); + } as unknown as AnthropicVertexStreamDeps["AnthropicVertex"]; return { + deps: { + AnthropicVertex: MockAnthropicVertex, + streamAnthropic: streamAnthropicMock, + }, streamAnthropicMock, anthropicVertexCtorMock, }; -}); - -vi.mock("@mariozechner/pi-ai", async () => { - const original = - await vi.importActual("@mariozechner/pi-ai"); - return { - ...original, - streamAnthropic: (model: unknown, context: unknown, options: unknown) => - hoisted.streamAnthropicMock(model, context, options), - }; -}); - -vi.mock("@anthropic-ai/vertex-sdk", () => ({ - AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) { - hoisted.anthropicVertexCtorMock(options); - return { options }; - }), -})); +} let createAnthropicVertexStreamFn: typeof import("./stream-runtime.js").createAnthropicVertexStreamFn; let createAnthropicVertexStreamFnForModel: typeof import("./stream-runtime.js").createAnthropicVertexStreamFnForModel; @@ -48,8 +44,12 @@ const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynam type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise; -function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); +function captureCacheBoundaryPayloadHook( + onPayload: PayloadHook, + deps: AnthropicVertexStreamDeps, + streamAnthropicMock: ReturnType, +) { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }); void streamFn( @@ -64,7 +64,7 @@ function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) { } as never, ); - const transportOptions = hoisted.streamAnthropicMock.mock.calls[0]?.[2] as { + const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as { onPayload?: PayloadHook; }; @@ -105,31 +105,29 @@ describe("createAnthropicVertexStreamFn", () => { await import("./stream-runtime.js")); }); - beforeEach(() => { - hoisted.streamAnthropicMock.mockClear(); - hoisted.anthropicVertexCtorMock.mockClear(); - }); - it("omits projectId when ADC credentials are used without an explicit project", () => { - const streamFn = createAnthropicVertexStreamFn(undefined, "global"); + const { deps, anthropicVertexCtorMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ region: "global", }); }); it("passes an explicit baseURL through to the Vertex client", () => { + const { deps, anthropicVertexCtorMock } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFn( "vertex-project", "us-east5", "https://proxy.example.test/vertex/v1", + deps, ); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ projectId: "vertex-project", region: "us-east5", baseURL: "https://proxy.example.test/vertex/v1", @@ -137,12 +135,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("defaults maxTokens to the model limit instead of the old 32000 cap", () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 }); void streamFn(model, { messages: [] }, {}); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + expect(streamAnthropicMock).toHaveBeenCalledWith( model, { messages: [] }, expect.objectContaining({ @@ -152,12 +151,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("clamps explicit maxTokens to the selected model limit", () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }); void streamFn(model, { messages: [] }, { maxTokens: 999999 }); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + expect(streamAnthropicMock).toHaveBeenCalledWith( model, { messages: [] }, expect.objectContaining({ @@ -167,12 +167,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("maps xhigh reasoning to max effort for adaptive Opus models", () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 }); void streamFn(model, { messages: [] }, { reasoning: "xhigh" }); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + expect(streamAnthropicMock).toHaveBeenCalledWith( model, { messages: [] }, expect.objectContaining({ @@ -183,12 +184,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("maps xhigh reasoning to xhigh effort for Opus 4.7", () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-opus-4-7", maxTokens: 64000 }); void streamFn(model, { messages: [] }, { reasoning: "xhigh" }); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + expect(streamAnthropicMock).toHaveBeenCalledWith( model, { messages: [] }, expect.objectContaining({ @@ -199,8 +201,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => { + const { deps, streamAnthropicMock } = createStreamDeps(); const onPayload = vi.fn(async (payload: unknown) => payload); - const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload); + const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook( + onPayload, + deps, + streamAnthropicMock, + ); const payload = { system: [ { @@ -220,6 +227,7 @@ describe("createAnthropicVertexStreamFn", () => { }); it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => { + const { deps, streamAnthropicMock } = createStreamDeps(); const onPayload = vi.fn(async () => ({ system: [ { @@ -229,7 +237,11 @@ describe("createAnthropicVertexStreamFn", () => { ], messages: [{ role: "user", content: "Hello again" }], })); - const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload); + const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook( + onPayload, + deps, + streamAnthropicMock, + ); const nextPayload = await transportPayloadHook?.( { @@ -248,12 +260,13 @@ describe("createAnthropicVertexStreamFn", () => { }); it("omits maxTokens when neither the model nor request provide a finite limit", () => { - const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); const model = makeModel({ id: "claude-sonnet-4-6" }); void streamFn(model, { messages: [] }, { maxTokens: Number.NaN }); - expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + expect(streamAnthropicMock).toHaveBeenCalledWith( model, { messages: [] }, expect.not.objectContaining({ @@ -264,19 +277,17 @@ describe("createAnthropicVertexStreamFn", () => { }); describe("createAnthropicVertexStreamFnForModel", () => { - beforeEach(() => { - hoisted.anthropicVertexCtorMock.mockClear(); - }); - it("derives project and region from the model and env", () => { + const { deps, anthropicVertexCtorMock } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://europe-west4-aiplatform.googleapis.com" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, ); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ projectId: "vertex-project", region: "europe-west4", baseURL: "https://europe-west4-aiplatform.googleapis.com/v1", @@ -284,14 +295,16 @@ describe("createAnthropicVertexStreamFnForModel", () => { }); it("preserves explicit custom provider base URLs", () => { + const { deps, anthropicVertexCtorMock } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://proxy.example.test/custom-root/v1" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, ); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ projectId: "vertex-project", region: "global", baseURL: "https://proxy.example.test/custom-root/v1", @@ -299,14 +312,16 @@ describe("createAnthropicVertexStreamFnForModel", () => { }); it("adds /v1 for path-prefixed custom provider base URLs", () => { + const { deps, anthropicVertexCtorMock } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://proxy.example.test/custom-root" }, { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, ); void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); - expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ projectId: "vertex-project", region: "global", baseURL: "https://proxy.example.test/custom-root/v1", diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index f399cc35ae1..fb48634da20 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -1,6 +1,10 @@ -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk"; import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai"; +import { + streamAnthropic as streamAnthropicDefault, + type AnthropicOptions, + type Model, +} from "@mariozechner/pi-ai"; import { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, @@ -9,6 +13,17 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f type AnthropicVertexEffort = NonNullable; type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh"; +type AnthropicVertexClientOptions = ConstructorParameters[0]; + +export type AnthropicVertexStreamDeps = { + AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown; + streamAnthropic: typeof streamAnthropicDefault; +}; + +const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = { + AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"], + streamAnthropic: streamAnthropicDefault, +}; function isClaudeOpus47Model(modelId: string): boolean { return modelId.includes("opus-4-7") || modelId.includes("opus-4.7"); @@ -104,8 +119,9 @@ export function createAnthropicVertexStreamFn( projectId: string | undefined, region: string, baseURL?: string, + deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps, ): StreamFn { - const client = new AnthropicVertex({ + const client = new deps.AnthropicVertex({ region, ...(baseURL ? { baseURL } : {}), ...(projectId ? { projectId } : {}), @@ -122,7 +138,7 @@ export function createAnthropicVertexStreamFn( requestedMaxTokens: options?.maxTokens, }); const opts: AnthropicOptions = { - client: client as unknown as AnthropicOptions["client"], + client: client as AnthropicOptions["client"], temperature: options?.temperature, ...(maxTokens !== undefined ? { maxTokens } : {}), signal: options?.signal, @@ -157,7 +173,7 @@ export function createAnthropicVertexStreamFn( opts.thinkingEnabled = false; } - return streamAnthropic(transportModel, context, opts); + return deps.streamAnthropic(transportModel, context, opts); }; } @@ -187,6 +203,7 @@ function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined export function createAnthropicVertexStreamFnForModel( model: { baseUrl?: string }, env: NodeJS.ProcessEnv = process.env, + deps?: AnthropicVertexStreamDeps, ): StreamFn { return createAnthropicVertexStreamFn( resolveAnthropicVertexProjectId(env), @@ -195,5 +212,6 @@ export function createAnthropicVertexStreamFnForModel( env, }), resolveAnthropicVertexSdkBaseUrl(model.baseUrl), + deps, ); } diff --git a/extensions/microsoft/tts.test.ts b/extensions/microsoft/tts.test.ts index f73c76a507d..521ef742130 100644 --- a/extensions/microsoft/tts.test.ts +++ b/extensions/microsoft/tts.test.ts @@ -1,19 +1,19 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; let edgeTTS: typeof import("./tts.js").edgeTTS; -let mockTtsPromise = vi.fn<(text: string, filePath: string) => Promise>(); - -vi.mock("node-edge-tts", () => ({ - EdgeTTS: class { - ttsPromise(text: string, filePath: string) { - return mockTtsPromise(text, filePath); - } - }, -})); +function createEdgeTTSDeps(ttsPromise: (text: string, filePath: string) => Promise) { + return { + EdgeTTS: class { + ttsPromise(text: string, filePath: string) { + return ttsPromise(text, filePath); + } + }, + }; +} const baseEdgeConfig = { voice: "en-US-MichelleNeural", @@ -40,17 +40,20 @@ describe("edgeTTS empty audio validation", () => { tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-")); const outputPath = path.join(tempDir, "voice.mp3"); - mockTtsPromise = vi.fn(async (_text: string, filePath: string) => { + const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { writeFileSync(filePath, ""); }); await expect( - edgeTTS({ - text: "Hello", - outputPath, - config: baseEdgeConfig, - timeoutMs: 10000, - }), + edgeTTS( + { + text: "Hello", + outputPath, + config: baseEdgeConfig, + timeoutMs: 10000, + }, + deps, + ), ).rejects.toThrow("Edge TTS produced empty audio file"); }); @@ -58,17 +61,20 @@ describe("edgeTTS empty audio validation", () => { tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-")); const outputPath = path.join(tempDir, "voice.mp3"); - mockTtsPromise = vi.fn(async (_text: string, filePath: string) => { + const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); }); await expect( - edgeTTS({ - text: "Hello", - outputPath, - config: baseEdgeConfig, - timeoutMs: 10000, - }), + edgeTTS( + { + text: "Hello", + outputPath, + config: baseEdgeConfig, + timeoutMs: 10000, + }, + deps, + ), ).resolves.toBeUndefined(); }); }); diff --git a/extensions/microsoft/tts.ts b/extensions/microsoft/tts.ts index d4905025b7f..63ed73d8bd6 100644 --- a/extensions/microsoft/tts.ts +++ b/extensions/microsoft/tts.ts @@ -2,6 +2,16 @@ import { statSync } from "node:fs"; import { EdgeTTS } from "node-edge-tts"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +type EdgeTTSDeps = { + EdgeTTS: new (config: ConstructorParameters[0]) => { + ttsPromise: (text: string, outputPath: string) => Promise; + }; +}; + +const defaultEdgeTTSDeps: EdgeTTSDeps = { + EdgeTTS, +}; + export function inferEdgeExtension(outputFormat: string): string { const normalized = normalizeLowercaseStringOrEmpty(outputFormat); if (normalized.includes("webm")) { @@ -19,24 +29,27 @@ export function inferEdgeExtension(outputFormat: string): string { return ".mp3"; } -export async function edgeTTS(params: { - text: string; - outputPath: string; - config: { - voice: string; - lang: string; - outputFormat: string; - saveSubtitles: boolean; - proxy?: string; - rate?: string; - pitch?: string; - volume?: string; - timeoutMs?: number; - }; - timeoutMs: number; -}): Promise { +export async function edgeTTS( + params: { + text: string; + outputPath: string; + config: { + voice: string; + lang: string; + outputFormat: string; + saveSubtitles: boolean; + proxy?: string; + rate?: string; + pitch?: string; + volume?: string; + timeoutMs?: number; + }; + timeoutMs: number; + }, + deps: EdgeTTSDeps = defaultEdgeTTSDeps, +): Promise { const { text, outputPath, config, timeoutMs } = params; - const tts = new EdgeTTS({ + const tts = new deps.EdgeTTS({ voice: config.voice, lang: config.lang, outputFormat: config.outputFormat, diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index ab36f22c573..8f62cf329d4 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -15,6 +15,7 @@ const VALID_MODES = new Set(["all", "package-boundary"]); const PLUGIN_SDK_TYPE_INPUTS = [ "tsconfig.json", "src/plugin-sdk", + "src/auto-reply", "src/video-generation/dashscope-compatible.ts", "src/video-generation/types.ts", "src/types", diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts new file mode 100644 index 00000000000..9415e043a59 --- /dev/null +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import { createPersistCronSessionEntry, type MutableCronSession } from "./run-session-state.js"; + +function makeSessionEntry(overrides?: Partial): SessionEntry { + return { + sessionId: "run-session-id", + updatedAt: 1000, + systemSent: true, + ...overrides, + }; +} + +function makeCronSession(entry = makeSessionEntry()): MutableCronSession { + return { + storePath: "/tmp/sessions.json", + store: {}, + sessionEntry: entry, + systemSent: true, + isNewSession: true, + previousSessionId: undefined, + } as MutableCronSession; +} + +describe("createPersistCronSessionEntry", () => { + it("persists a distinct run-session snapshot for isolated cron runs", async () => { + const cronSession = makeCronSession( + makeSessionEntry({ + status: "running", + startedAt: 900, + skillsSnapshot: { + prompt: "old prompt", + skills: [{ name: "memory" }], + }, + }), + ); + const updateSessionStore = vi.fn( + async (_storePath, update: (store: Record) => void) => { + const store: Record = {}; + update(store); + expect(store["agent:main:cron:job"]).toBe(cronSession.sessionEntry); + expect(store["agent:main:cron:job:run:run-session-id"]).not.toBe(cronSession.sessionEntry); + expect(store["agent:main:cron:job:run:run-session-id"]).toEqual(cronSession.sessionEntry); + }, + ); + + const persist = createPersistCronSessionEntry({ + isFastTestEnv: false, + cronSession, + agentSessionKey: "agent:main:cron:job", + runSessionKey: "agent:main:cron:job:run:run-session-id", + updateSessionStore, + }); + + await persist(); + + expect(cronSession.store["agent:main:cron:job"]).toBe(cronSession.sessionEntry); + expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).not.toBe( + cronSession.sessionEntry, + ); + + cronSession.sessionEntry.status = "done"; + cronSession.sessionEntry.skillsSnapshot!.skills[0].name = "changed"; + expect(cronSession.store["agent:main:cron:job:run:run-session-id"]?.status).toBe("running"); + expect( + cronSession.store["agent:main:cron:job:run:run-session-id"]?.skillsSnapshot?.skills[0]?.name, + ).toBe("memory"); + }); + + it("uses the shared session entry when the run key is the agent session key", async () => { + const cronSession = makeCronSession(); + const updateSessionStore = vi.fn( + async (_storePath, update: (store: Record) => void) => { + const store: Record = {}; + update(store); + expect(store["agent:main:session"]).toBe(cronSession.sessionEntry); + }, + ); + + const persist = createPersistCronSessionEntry({ + isFastTestEnv: false, + cronSession, + agentSessionKey: "agent:main:session", + runSessionKey: "agent:main:session", + updateSessionStore, + }); + + await persist(); + + expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry); + }); +}); diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index 80824f29497..adeeb7ef513 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -19,6 +19,10 @@ type UpdateSessionStore = ( export type PersistCronSessionEntry = () => Promise; +function cloneSessionEntry(entry: MutableCronSessionEntry): MutableCronSessionEntry { + return globalThis.structuredClone(entry); +} + export function createPersistCronSessionEntry(params: { isFastTestEnv: boolean; cronSession: MutableCronSession; @@ -30,14 +34,15 @@ export function createPersistCronSessionEntry(params: { if (params.isFastTestEnv) { return; } + const runSessionEntry = cloneSessionEntry(params.cronSession.sessionEntry); params.cronSession.store[params.agentSessionKey] = params.cronSession.sessionEntry; if (params.runSessionKey !== params.agentSessionKey) { - params.cronSession.store[params.runSessionKey] = params.cronSession.sessionEntry; + params.cronSession.store[params.runSessionKey] = runSessionEntry; } await params.updateSessionStore(params.cronSession.storePath, (store) => { store[params.agentSessionKey] = params.cronSession.sessionEntry; if (params.runSessionKey !== params.agentSessionKey) { - store[params.runSessionKey] = params.cronSession.sessionEntry; + store[params.runSessionKey] = runSessionEntry; } }); }; diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index eb4022698d0..fcd8b379520 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -222,6 +222,206 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.modelOverride).toBe("gpt-5.4"); }); + it("clears stale run-scoped state when forceNew rolls to a fresh session", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-987", + updatedAt: NOW_MS - 1000, + status: "done", + startedAt: NOW_MS - 10_000, + endedAt: NOW_MS - 1_000, + runtimeMs: 9_000, + lastHeartbeatText: "old heartbeat", + lastHeartbeatSentAt: NOW_MS - 1_000, + heartbeatIsolatedBaseSessionKey: "agent:main:cron:old", + model: "claude-opus-4-6", + modelProvider: "anthropic", + agentHarnessId: "claude-cli", + agentRuntimeOverride: "claude-cli", + cliSessionIds: { anthropic: "old-cli-session" }, + cliSessionBindings: {}, + claudeCliSessionId: "old-claude-session", + liveModelSwitchPending: true, + fallbackNoticeSelectedModel: "anthropic/claude-opus-4-6", + fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", + fallbackNoticeReason: "rate limit", + inputTokens: 1, + outputTokens: 2, + totalTokens: 3, + totalTokensFresh: true, + estimatedCostUsd: 0.01, + execAsk: "always", + execHost: "gateway", + execNode: "node-1", + execSecurity: "allowlist", + cacheRead: 4, + cacheWrite: 5, + contextTokens: 200_000, + compactionCount: 9, + memoryFlushAt: NOW_MS - 500, + abortCutoffMessageSid: "old-message", + spawnedBy: "agent:main:session:parent", + skillsSnapshot: { + prompt: "old skills", + skills: [{ name: "stale-skill" }], + }, + systemPromptReport: { + source: "run", + generatedAt: NOW_MS, + systemPrompt: { + chars: 1, + projectContextChars: 0, + nonProjectContextChars: 1, + }, + injectedWorkspaceFiles: [], + skills: { promptChars: 0, entries: [] }, + tools: { listChars: 0, schemaChars: 0, entries: [] }, + }, + pluginDebugEntries: [{ pluginId: "test", lines: ["old"] }], + elevatedLevel: "full", + sendPolicy: "deny", + groupActivation: "always", + groupActivationNeedsSystemIntro: true, + queueMode: "interrupt", + queueDebounceMs: 500, + queueCap: 25, + queueDrop: "old", + channel: "telegram" as never, + groupId: "group-1", + subject: "old subject", + groupChannel: "ops", + space: "team", + origin: { + provider: "telegram", + to: "old-chat", + }, + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "old-acp", + mode: "persistent", + state: "idle", + lastActivityAt: NOW_MS - 1_000, + }, + authProfileOverride: "auto-auth", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + modelOverride: "auto-model", + providerOverride: "anthropic", + modelOverrideSource: "auto", + }, + fresh: true, + forceNew: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionEntry.status).toBeUndefined(); + expect(result.sessionEntry.startedAt).toBeUndefined(); + expect(result.sessionEntry.endedAt).toBeUndefined(); + expect(result.sessionEntry.runtimeMs).toBeUndefined(); + expect(result.sessionEntry.lastHeartbeatText).toBeUndefined(); + expect(result.sessionEntry.lastHeartbeatSentAt).toBeUndefined(); + expect(result.sessionEntry.heartbeatIsolatedBaseSessionKey).toBeUndefined(); + expect(result.sessionEntry.model).toBeUndefined(); + expect(result.sessionEntry.modelProvider).toBeUndefined(); + expect(result.sessionEntry.agentHarnessId).toBeUndefined(); + expect(result.sessionEntry.agentRuntimeOverride).toBeUndefined(); + expect(result.sessionEntry.cliSessionIds).toBeUndefined(); + expect(result.sessionEntry.cliSessionBindings).toBeUndefined(); + expect(result.sessionEntry.claudeCliSessionId).toBeUndefined(); + expect(result.sessionEntry.liveModelSwitchPending).toBeUndefined(); + expect(result.sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); + expect(result.sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); + expect(result.sessionEntry.fallbackNoticeReason).toBeUndefined(); + expect(result.sessionEntry.inputTokens).toBeUndefined(); + expect(result.sessionEntry.outputTokens).toBeUndefined(); + expect(result.sessionEntry.totalTokens).toBeUndefined(); + expect(result.sessionEntry.totalTokensFresh).toBeUndefined(); + expect(result.sessionEntry.estimatedCostUsd).toBeUndefined(); + expect(result.sessionEntry.execAsk).toBeUndefined(); + expect(result.sessionEntry.execHost).toBeUndefined(); + expect(result.sessionEntry.execNode).toBeUndefined(); + expect(result.sessionEntry.execSecurity).toBeUndefined(); + expect(result.sessionEntry.cacheRead).toBeUndefined(); + expect(result.sessionEntry.cacheWrite).toBeUndefined(); + expect(result.sessionEntry.contextTokens).toBeUndefined(); + expect(result.sessionEntry.compactionCount).toBeUndefined(); + expect(result.sessionEntry.memoryFlushAt).toBeUndefined(); + expect(result.sessionEntry.abortCutoffMessageSid).toBeUndefined(); + expect(result.sessionEntry.spawnedBy).toBeUndefined(); + expect(result.sessionEntry.skillsSnapshot).toBeUndefined(); + expect(result.sessionEntry.systemPromptReport).toBeUndefined(); + expect(result.sessionEntry.pluginDebugEntries).toBeUndefined(); + expect(result.sessionEntry.elevatedLevel).toBeUndefined(); + expect(result.sessionEntry.sendPolicy).toBeUndefined(); + expect(result.sessionEntry.groupActivation).toBeUndefined(); + expect(result.sessionEntry.groupActivationNeedsSystemIntro).toBeUndefined(); + expect(result.sessionEntry.queueMode).toBeUndefined(); + expect(result.sessionEntry.queueDebounceMs).toBeUndefined(); + expect(result.sessionEntry.queueCap).toBeUndefined(); + expect(result.sessionEntry.queueDrop).toBeUndefined(); + expect(result.sessionEntry.channel).toBeUndefined(); + expect(result.sessionEntry.groupId).toBeUndefined(); + expect(result.sessionEntry.subject).toBeUndefined(); + expect(result.sessionEntry.groupChannel).toBeUndefined(); + expect(result.sessionEntry.space).toBeUndefined(); + expect(result.sessionEntry.origin).toBeUndefined(); + expect(result.sessionEntry.acp).toBeUndefined(); + expect(result.sessionEntry.authProfileOverride).toBeUndefined(); + expect(result.sessionEntry.authProfileOverrideSource).toBeUndefined(); + expect(result.sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); + expect(result.sessionEntry.modelOverride).toBeUndefined(); + expect(result.sessionEntry.providerOverride).toBeUndefined(); + expect(result.sessionEntry.modelOverrideSource).toBeUndefined(); + }); + + it("preserves user-selected model and auth overrides for fresh cron sessions", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-654", + updatedAt: NOW_MS - 1000, + modelOverride: "claude-sonnet-4-6", + providerOverride: "anthropic", + modelOverrideSource: "user", + authProfileOverride: "work-profile", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 3, + }, + fresh: true, + forceNew: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionEntry.modelOverride).toBe("claude-sonnet-4-6"); + expect(result.sessionEntry.providerOverride).toBe("anthropic"); + expect(result.sessionEntry.modelOverrideSource).toBe("user"); + expect(result.sessionEntry.authProfileOverride).toBe("work-profile"); + expect(result.sessionEntry.authProfileOverrideSource).toBe("user"); + expect(result.sessionEntry.authProfileOverrideCompactionCount).toBe(3); + }); + + it("preserves session context for stale non-isolated rollovers", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-321", + updatedAt: NOW_MS - 1000, + elevatedLevel: "full", + sendPolicy: "deny", + queueMode: "collect", + channel: "discord" as never, + origin: { provider: "discord", to: "old-channel" }, + }, + fresh: false, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionEntry.elevatedLevel).toBe("full"); + expect(result.sessionEntry.sendPolicy).toBe("deny"); + expect(result.sessionEntry.queueMode).toBe("collect"); + expect(result.sessionEntry.channel).toBe("discord"); + expect(result.sessionEntry.origin).toEqual({ provider: "discord", to: "old-channel" }); + }); + it("clears delivery routing metadata when session is stale", () => { const result = resolveWithStoredEntry({ entry: { diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 247e7263fe6..8042309d9a5 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -9,6 +9,98 @@ import { loadSessionStore } from "../../config/sessions/store-load.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +type FreshCronSessionSanitizeMode = "isolated-force-new" | "stale-rollover"; + +const FRESH_CRON_SAFE_PREFERENCE_FIELDS = [ + "heartbeatTaskState", + "chatType", + "thinkingLevel", + "fastMode", + "verboseLevel", + "traceLevel", + "reasoningLevel", + "ttsAuto", + "responseUsage", + "label", + "displayName", +] as const satisfies readonly (keyof SessionEntry)[]; + +const STALE_SESSION_CONTEXT_PRESERVED_FIELDS = [ + "elevatedLevel", + "groupActivation", + "groupActivationNeedsSystemIntro", + "sendPolicy", + "queueMode", + "queueDebounceMs", + "queueCap", + "queueDrop", + "channel", + "groupId", + "subject", + "groupChannel", + "space", + "origin", + "acp", +] as const satisfies readonly (keyof SessionEntry)[]; + +function cloneSessionField(value: T): T { + return globalThis.structuredClone(value); +} + +function copySessionFields( + target: SessionEntry, + entry: SessionEntry, + fields: readonly (keyof SessionEntry)[], +): void { + for (const field of fields) { + if (entry[field] !== undefined) { + target[field] = cloneSessionField(entry[field]) as never; + } + } +} + +function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry): void { + if (entry.modelOverrideSource !== "auto") { + if (entry.modelOverride !== undefined) { + target.modelOverride = entry.modelOverride; + } + if (entry.providerOverride !== undefined) { + target.providerOverride = entry.providerOverride; + } + if (entry.modelOverrideSource !== undefined) { + target.modelOverrideSource = entry.modelOverrideSource; + } + } +} + +function preserveUserAuthOverride(target: SessionEntry, entry: SessionEntry): void { + if (entry.authProfileOverrideSource === "user") { + if (entry.authProfileOverride !== undefined) { + target.authProfileOverride = entry.authProfileOverride; + } + target.authProfileOverrideSource = entry.authProfileOverrideSource; + if (entry.authProfileOverrideCompactionCount !== undefined) { + target.authProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; + } + } +} + +function sanitizeFreshCronSessionEntry( + entry: SessionEntry, + mode: FreshCronSessionSanitizeMode, +): SessionEntry { + const next = {} as SessionEntry; + + copySessionFields(next, entry, FRESH_CRON_SAFE_PREFERENCE_FIELDS); + if (mode === "stale-rollover") { + copySessionFields(next, entry, STALE_SESSION_CONTEXT_PRESERVED_FIELDS); + } + preserveNonAutoModelOverride(next, entry); + preserveUserAuthOverride(next, entry); + + return next; +} + export function resolveCronSession(params: { cfg: OpenClawConfig; sessionKey: string; @@ -65,27 +157,22 @@ export function resolveCronSession(params: { previousSessionId, }); + const baseEntry = entry + ? isNewSession + ? sanitizeFreshCronSessionEntry( + entry, + params.forceNew ? "isolated-force-new" : "stale-rollover", + ) + : entry + : undefined; + const sessionEntry: SessionEntry = { // Preserve existing per-session overrides even when rolling to a new sessionId. - ...entry, + ...baseEntry, // Always update these core fields sessionId, updatedAt: params.nowMs, systemSent, - // When starting a fresh session (forceNew / isolated), clear delivery routing - // state inherited from prior sessions. Without this, lastThreadId leaks into - // the new session and causes announce-mode cron deliveries to post as thread - // replies instead of channel top-level messages. - // deliveryContext must also be cleared because normalizeSessionEntryDelivery - // repopulates lastThreadId from deliveryContext.threadId on store writes. - ...(isNewSession && { - lastChannel: undefined, - lastTo: undefined, - lastAccountId: undefined, - lastThreadId: undefined, - deliveryContext: undefined, - sessionFile: undefined, - }), }; return { storePath, store, sessionEntry, systemSent, isNewSession, previousSessionId }; } From 86dc820560ee0d8eed9044723e1f692e9715f548 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:15:24 -0700 Subject: [PATCH 06/93] feat(plugins): add compatibility registry --- CHANGELOG.md | 1 + docs/docs.json | 1 + docs/plugins/compatibility.md | 74 +++++++++ src/plugins/compat/registry.test.ts | 40 +++++ src/plugins/compat/registry.ts | 242 ++++++++++++++++++++++++++++ src/plugins/compat/types.ts | 27 ++++ src/plugins/status.test-helpers.ts | 2 + src/plugins/status.ts | 4 + src/wizard/setup.test.ts | 1 + 9 files changed, 392 insertions(+) create mode 100644 docs/plugins/compatibility.md create mode 100644 src/plugins/compat/registry.test.ts create mode 100644 src/plugins/compat/registry.ts create mode 100644 src/plugins/compat/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d853b4c8b..c4e0d42afac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Gateway/VoiceClaw: add a realtime brain WebSocket endpoint backed by Gemini Live, with owner-auth gating and async OpenClaw tool handoff. (#70938) Thanks @yagudaev. - Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default. Thanks @lsdsjy. - CLI/Gateway: make `gateway status` start faster by skipping plugin loading on the read-only status path. (#71364) Thanks @andyylin. +- Plugins/compatibility: add a central plugin compatibility registry and docs for SDK/config/setup/runtime deprecation records, including dated migration metadata for legacy harness naming and other plugin-facing aliases. Thanks @vincentkoc. ### Fixes diff --git a/docs/docs.json b/docs/docs.json index 487f357e6a9..0df3e09b929 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1165,6 +1165,7 @@ "plugins/hooks", "plugins/sdk-channel-plugins", "plugins/sdk-provider-plugins", + "plugins/compatibility", "plugins/sdk-migration" ] }, diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md new file mode 100644 index 00000000000..ac9e92f536f --- /dev/null +++ b/docs/plugins/compatibility.md @@ -0,0 +1,74 @@ +--- +summary: "Plugin compatibility contracts, deprecation metadata, and migration expectations" +title: "Plugin compatibility" +read_when: + - You maintain an OpenClaw plugin + - You see a plugin compatibility warning + - You are planning a plugin SDK or manifest migration +--- + +OpenClaw keeps older plugin contracts wired through named compatibility +adapters before removing them. This protects existing bundled and external +plugins while the SDK, manifest, setup, config, and agent runtime contracts +evolve. + +## Compatibility registry + +Plugin compatibility contracts are tracked in the core registry at +`src/plugins/compat/registry.ts`. + +Each record has: + +- a stable compatibility code +- status: `active`, `deprecated`, `removal-pending`, or `removed` +- owner: SDK, config, setup, channel, provider, plugin execution, agent runtime, + or core +- introduction and deprecation dates when applicable +- replacement guidance +- docs, diagnostics, and tests that cover the old and new behavior + +The registry is the source for maintainer planning and future plugin inspector +checks. If a plugin-facing behavior changes, add or update the compatibility +record in the same change that adds the adapter. + +## Deprecation policy + +OpenClaw should not remove a documented plugin contract in the same release +that introduces its replacement. + +The migration sequence is: + +1. Add the new contract. +2. Keep the old behavior wired through a named compatibility adapter. +3. Emit diagnostics or warnings when plugin authors can act. +4. Document the replacement and timeline. +5. Test both old and new paths. +6. Wait through the announced migration window. +7. Remove only with explicit breaking-release approval. + +Deprecated records must include a warning start date, replacement, docs link, +and target removal date when known. + +## Current compatibility areas + +Current compatibility records include: + +- legacy broad SDK imports such as `openclaw/plugin-sdk/compat` +- legacy hook-only plugin shapes and `before_agent_start` +- bundled plugin allowlist and enablement behavior +- legacy provider/channel env-var manifest metadata +- activation hints that are being replaced by manifest contribution ownership +- `embeddedHarness` and `agent-harness` naming aliases while public naming moves + toward `agentRuntime` +- generated bundled channel config metadata fallback while registry-first + `channelConfigs` metadata lands + +New plugin code should prefer the replacement listed in the registry and in the +specific migration guide. Existing plugins can keep using a compatibility path +until the docs, diagnostics, and release notes announce a removal window. + +## Release notes + +Release notes should include upcoming plugin deprecations with target dates and +links to migration docs. That warning needs to happen before a compatibility +path moves to `removal-pending` or `removed`. diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts new file mode 100644 index 00000000000..f90737161fc --- /dev/null +++ b/src/plugins/compat/registry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + getPluginCompatRecord, + isPluginCompatCode, + listDeprecatedPluginCompatRecords, + listPluginCompatRecords, +} from "./registry.js"; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/u; + +describe("plugin compatibility registry", () => { + it("keeps compatibility codes unique and lookup-safe", () => { + const records = listPluginCompatRecords(); + const codes = records.map((record) => record.code); + + expect(new Set(codes).size).toBe(codes.length); + expect(isPluginCompatCode("legacy-root-sdk-import")).toBe(true); + expect(isPluginCompatCode("missing-code")).toBe(false); + expect(getPluginCompatRecord("legacy-root-sdk-import").owner).toBe("sdk"); + }); + + it("requires dated deprecation metadata for deprecated records", () => { + for (const record of listDeprecatedPluginCompatRecords()) { + expect(record.deprecated, record.code).toMatch(datePattern); + expect(record.warningStarts, record.code).toMatch(datePattern); + expect(record.replacement, record.code).toBeTruthy(); + expect(record.docsPath, record.code).toMatch(/^\//u); + } + }); + + it("keeps every record actionable", () => { + for (const record of listPluginCompatRecords()) { + expect(record.introduced, record.code).toMatch(datePattern); + expect(record.docsPath, record.code).toMatch(/^\//u); + expect(record.surfaces.length, record.code).toBeGreaterThan(0); + expect(record.diagnostics.length, record.code).toBeGreaterThan(0); + expect(record.tests.length, record.code).toBeGreaterThan(0); + } + }); +}); diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts new file mode 100644 index 00000000000..d7473508ad2 --- /dev/null +++ b/src/plugins/compat/registry.ts @@ -0,0 +1,242 @@ +import type { PluginCompatRecord } from "./types.js"; + +export const PLUGIN_COMPAT_RECORDS = [ + { + code: "legacy-before-agent-start", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-24", + warningStarts: "2026-04-24", + replacement: "`before_model_resolve` and `before_prompt_build` hooks", + docsPath: "/plugins/sdk-migration", + surfaces: ["plugin hooks", "plugins inspect", "status diagnostics"], + diagnostics: ["plugin compatibility notice"], + tests: ["src/plugins/status.test.ts", "src/plugins/contracts/shape.contract.test.ts"], + releaseNote: + "Legacy `before_agent_start` hook compatibility remains wired while plugins migrate to modern hook stages.", + }, + { + code: "hook-only-plugin-shape", + status: "active", + owner: "sdk", + introduced: "2026-04-24", + replacement: "explicit capability registration", + docsPath: "/plugins/sdk-migration", + surfaces: ["plugin shape inspection", "plugins inspect", "status diagnostics"], + diagnostics: ["plugin compatibility notice"], + tests: ["src/plugins/status.test.ts", "src/plugins/contracts/shape.contract.test.ts"], + }, + { + code: "legacy-root-sdk-import", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-24", + warningStarts: "2026-04-24", + replacement: "focused `openclaw/plugin-sdk/` imports", + docsPath: "/plugins/sdk-migration", + surfaces: ["openclaw/plugin-sdk", "openclaw/plugin-sdk/compat"], + diagnostics: ["OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED"], + tests: [ + "src/plugins/contracts/plugin-sdk-index.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + }, + { + code: "bundled-plugin-allowlist", + status: "active", + owner: "config", + introduced: "2026-04-24", + replacement: "manifest-owned plugin enablement and scoped load plans", + docsPath: "/plugins/architecture", + surfaces: ["plugins.allow", "bundled provider startup", "plugins status"], + diagnostics: ["plugin status report"], + tests: ["src/plugins/status.test.ts", "src/plugins/config-state.test.ts"], + }, + { + code: "bundled-plugin-enablement", + status: "active", + owner: "config", + introduced: "2026-04-24", + replacement: "manifest-owned plugin defaults and scoped load plans", + docsPath: "/plugins/architecture", + surfaces: ["plugins.entries", "bundled provider startup", "plugins status"], + diagnostics: ["plugin status report"], + tests: ["src/plugins/status.test.ts", "src/plugins/config-state.test.ts"], + }, + { + code: "bundled-plugin-vitest-defaults", + status: "active", + owner: "config", + introduced: "2026-04-24", + replacement: "explicit test plugin config fixtures", + docsPath: "/plugins/architecture", + surfaces: ["Vitest plugin defaults", "bundled provider tests"], + diagnostics: ["test-only compatibility path"], + tests: ["src/plugins/config-state.test.ts"], + }, + { + code: "provider-auth-env-vars", + status: "deprecated", + owner: "setup", + introduced: "2026-04-24", + deprecated: "2026-04-24", + warningStarts: "2026-04-24", + replacement: "`setup.providers[].envVars` and `providerAuthChoices`", + docsPath: "/plugins/manifest", + surfaces: ["openclaw.plugin.json providerAuthEnvVars", "provider setup"], + diagnostics: ["manifest compatibility diagnostic"], + tests: ["src/plugins/setup-registry.test.ts", "src/plugins/provider-auth-choices.test.ts"], + }, + { + code: "channel-env-vars", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-24", + warningStarts: "2026-04-24", + replacement: "`channelConfigs..schema` and setup descriptors", + docsPath: "/plugins/manifest", + surfaces: ["openclaw.plugin.json channelEnvVars", "channel setup"], + diagnostics: ["manifest compatibility diagnostic"], + tests: [ + "src/plugins/setup-registry.test.ts", + "src/channels/plugins/setup-group-access.test.ts", + ], + }, + { + code: "activation-provider-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: "`providers[]` manifest ownership", + docsPath: "/plugins/manifest", + surfaces: ["activation.onProviders", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, + { + code: "activation-channel-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: "`channels[]` manifest ownership", + docsPath: "/plugins/manifest", + surfaces: ["activation.onChannels", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, + { + code: "activation-command-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: "`commandAliases` or command contribution metadata", + docsPath: "/plugins/manifest", + surfaces: ["activation.onCommands", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, + { + code: "activation-route-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: "HTTP route contribution metadata", + docsPath: "/plugins/manifest", + surfaces: ["activation.onRoutes", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, + { + code: "activation-capability-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: "manifest contribution ownership", + docsPath: "/plugins/manifest", + surfaces: ["activation.onCapabilities", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, + { + code: "embedded-harness-config-alias", + status: "deprecated", + owner: "agent-runtime", + introduced: "2026-04-24", + deprecated: "2026-04-25", + warningStarts: "2026-04-25", + replacement: "`agentRuntime` config naming", + docsPath: "/plugins/sdk-agent-harness", + surfaces: ["agents.defaults.embeddedHarness", "model/provider runtime selection"], + diagnostics: ["agent runtime config compatibility"], + tests: ["src/agents/config.test.ts", "src/agents/runtime-selection.test.ts"], + }, + { + code: "agent-harness-sdk-alias", + status: "deprecated", + owner: "agent-runtime", + introduced: "2026-04-24", + deprecated: "2026-04-25", + warningStarts: "2026-04-25", + replacement: "`openclaw/plugin-sdk/agent-runtime`", + docsPath: "/plugins/sdk-agent-harness", + surfaces: ["openclaw/plugin-sdk/agent-harness", "openclaw/plugin-sdk/agent-harness-runtime"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, + { + code: "agent-harness-id-alias", + status: "deprecated", + owner: "agent-runtime", + introduced: "2026-04-24", + deprecated: "2026-04-25", + warningStarts: "2026-04-25", + replacement: "`agentRuntime` ids and policy metadata", + docsPath: "/plugins/sdk-agent-harness", + surfaces: ["manifest/catalog execution policy", "runtime selection"], + diagnostics: ["agent runtime compatibility warning"], + tests: ["src/plugins/provider-runtime.test.ts", "src/web/provider-runtime-shared.test.ts"], + }, + { + code: "generated-bundled-channel-config-fallback", + status: "active", + owner: "channel", + introduced: "2026-04-24", + replacement: "manifest registry `channelConfigs` metadata", + docsPath: "/plugins/manifest", + surfaces: ["generated bundled channel config metadata", "channel config validation"], + diagnostics: ["channel config metadata fallback"], + tests: ["src/plugins/contracts/config-footprint-guardrails.test.ts"], + }, +] as const satisfies readonly PluginCompatRecord[]; + +export type PluginCompatCode = (typeof PLUGIN_COMPAT_RECORDS)[number]["code"]; +export type KnownPluginCompatRecord = PluginCompatRecord; + +const pluginCompatRecordByCode = new Map( + PLUGIN_COMPAT_RECORDS.map((record) => [record.code, record]), +); + +export function listPluginCompatRecords(): readonly KnownPluginCompatRecord[] { + return PLUGIN_COMPAT_RECORDS; +} + +export function getPluginCompatRecord(code: PluginCompatCode): KnownPluginCompatRecord { + const record = pluginCompatRecordByCode.get(code); + if (!record) { + throw new Error(`Unknown plugin compatibility code: ${code}`); + } + return record; +} + +export function isPluginCompatCode(code: string): code is PluginCompatCode { + return pluginCompatRecordByCode.has(code as PluginCompatCode); +} + +export function listDeprecatedPluginCompatRecords(): readonly KnownPluginCompatRecord[] { + return PLUGIN_COMPAT_RECORDS.filter((record) => + (["deprecated", "removal-pending"] as readonly string[]).includes(record.status), + ); +} diff --git a/src/plugins/compat/types.ts b/src/plugins/compat/types.ts new file mode 100644 index 00000000000..67b3a76de4c --- /dev/null +++ b/src/plugins/compat/types.ts @@ -0,0 +1,27 @@ +export type PluginCompatStatus = "active" | "deprecated" | "removal-pending" | "removed"; + +export type PluginCompatOwner = + | "agent-runtime" + | "channel" + | "config" + | "core" + | "plugin-execution" + | "provider" + | "sdk" + | "setup"; + +export type PluginCompatRecord = { + code: Code; + status: PluginCompatStatus; + owner: PluginCompatOwner; + introduced: string; + deprecated?: string; + warningStarts?: string; + removeAfter?: string; + replacement?: string; + docsPath: string; + surfaces: readonly string[]; + diagnostics: readonly string[]; + tests: readonly string[]; + releaseNote?: string; +}; diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index d51746e7d77..d063049f53e 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -15,6 +15,7 @@ export function createCompatibilityNotice( return { pluginId: params.pluginId, code: params.code, + compatCode: "legacy-before-agent-start", severity: "warn", message: LEGACY_BEFORE_AGENT_START_MESSAGE, }; @@ -23,6 +24,7 @@ export function createCompatibilityNotice( return { pluginId: params.pluginId, code: params.code, + compatCode: "hook-only-plugin-shape", severity: "info", message: HOOK_ONLY_MESSAGE, }; diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 46c136a454e..af2d522357c 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -10,6 +10,7 @@ import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; +import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig } from "./config-state.js"; import { buildPluginShapeSummary, @@ -37,6 +38,7 @@ export type { PluginCapabilityKind, PluginInspectShape } from "./inspect-shape.j export type PluginCompatibilityNotice = { pluginId: string; code: "legacy-before-agent-start" | "hook-only"; + compatCode: PluginCompatCode; severity: "warn" | "info"; message: string; }; @@ -100,6 +102,7 @@ function buildCompatibilityNoticesForInspect( warnings.push({ pluginId: inspect.plugin.id, code: "legacy-before-agent-start", + compatCode: "legacy-before-agent-start", severity: "warn", message: "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", @@ -109,6 +112,7 @@ function buildCompatibilityNoticesForInspect( warnings.push({ pluginId: inspect.plugin.id, code: "hook-only", + compatCode: "hook-only-plugin-shape", severity: "info", message: "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.", diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 8d2526ded16..088aedee456 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -748,6 +748,7 @@ describe("runSetupWizard", () => { { pluginId: "legacy-plugin", code: "legacy-before-agent-start", + compatCode: "legacy-before-agent-start", severity: "warn", message: "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", From 1afbfdf451d09a93b9be579d80c16bdddf7271f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:15:47 +0100 Subject: [PATCH 07/93] fix(control-ui): preserve optimistic chat tail --- CHANGELOG.md | 1 + docs/web/control-ui.md | 4 ++ ui/src/ui/controllers/chat.test.ts | 62 ++++++++++++++++++++++++ ui/src/ui/controllers/chat.ts | 78 +++++++++++++++++++++++++++++- 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e0d42afac..1e11dd01a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. +- Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. - Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920. - Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index addde88a9fb..c586cb71eb5 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -154,6 +154,10 @@ Cron jobs panel notes: - `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`). - Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response. - `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `...`, `...`, `...`, `...`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`. +- During an active send and the final history refresh, the chat view keeps local + optimistic user/assistant messages visible if `chat.history` briefly returns + an older snapshot; the canonical transcript replaces those local messages once + the Gateway history catches up. - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - 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. - When fresh Gateway session usage reports show high context pressure, the chat diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index c74a4689814..9f21e6c7567 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -786,6 +786,68 @@ describe("loadChatHistory", () => { ]); }); + it("keeps local optimistic tail messages when history reload returns a stale snapshot", async () => { + const persistedUser = { + role: "user", + content: [{ type: "text", text: "first" }], + __openclaw: { seq: 1 }, + }; + const optimisticUser = { + role: "user", + content: [{ type: "text", text: "latest ask" }], + timestamp: 10, + }; + const optimisticAssistant = { + role: "assistant", + content: [{ type: "text", text: "latest answer" }], + timestamp: 11, + }; + const request = vi.fn().mockResolvedValue({ + messages: [persistedUser], + thinkingLevel: "low", + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + chatMessages: [persistedUser, optimisticUser, optimisticAssistant], + }); + + await loadChatHistory(state); + + expect(state.chatMessages).toEqual([persistedUser, optimisticUser, optimisticAssistant]); + expect(state.chatStream).toBeNull(); + }); + + it("does not duplicate optimistic tail messages after history catches up", async () => { + const optimisticUser = { + role: "user", + content: [{ type: "text", text: "latest ask" }], + timestamp: 10, + }; + const historyUser = { + role: "user", + content: [{ type: "text", text: "latest ask" }], + __openclaw: { seq: 1 }, + }; + const historyAssistant = { + role: "assistant", + content: [{ type: "text", text: "latest answer" }], + __openclaw: { seq: 2 }, + }; + const request = vi.fn().mockResolvedValue({ + messages: [historyUser, historyAssistant], + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + chatMessages: [optimisticUser], + }); + + await loadChatHistory(state); + + expect(state.chatMessages).toEqual([historyUser, historyAssistant]); + }); + it("shows a targeted message when chat history is unauthorized", async () => { const request = vi.fn().mockRejectedValue( new GatewayRequestError({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index ffac5f18502..44ff6681149 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -135,6 +135,80 @@ function shouldHideHistoryMessage(message: unknown): boolean { ); } +function hasTranscriptMeta(message: unknown): boolean { + return Boolean( + message && + typeof message === "object" && + (message as { __openclaw?: unknown }).__openclaw && + typeof (message as { __openclaw?: unknown }).__openclaw === "object", + ); +} + +function isLocallyOptimisticHistoryMessage(message: unknown): boolean { + if (!message || typeof message !== "object" || hasTranscriptMeta(message)) { + return false; + } + const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role); + return role === "user" || role === "assistant"; +} + +function messageDisplaySignature(message: unknown): string | null { + if (!message || typeof message !== "object") { + return null; + } + const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role); + if (!role) { + return null; + } + const text = extractText(message)?.trim(); + if (text) { + return `${role}:text:${text}`; + } + try { + const content = JSON.stringify((message as { content?: unknown }).content ?? null); + return `${role}:content:${content}`; + } catch { + return null; + } +} + +function preserveOptimisticTailMessages( + historyMessages: unknown[], + previousMessages: unknown[], +): unknown[] { + if (historyMessages.length === 0 || previousMessages.length === 0) { + return historyMessages; + } + const historySignatures = new Set( + historyMessages + .map((message) => messageDisplaySignature(message)) + .filter((signature): signature is string => Boolean(signature)), + ); + let sharedPreviousIndex = -1; + for (let index = previousMessages.length - 1; index >= 0; index--) { + const signature = messageDisplaySignature(previousMessages[index]); + if (signature && historySignatures.has(signature)) { + sharedPreviousIndex = index; + break; + } + } + if (sharedPreviousIndex < 0) { + return historyMessages; + } + const optimisticTail: unknown[] = []; + for (const message of previousMessages.slice(sharedPreviousIndex + 1)) { + if (!isLocallyOptimisticHistoryMessage(message) || shouldHideHistoryMessage(message)) { + return historyMessages; + } + const signature = messageDisplaySignature(message); + if (!signature || historySignatures.has(signature)) { + return historyMessages; + } + optimisticTail.push(message); + } + return optimisticTail.length > 0 ? [...historyMessages, ...optimisticTail] : historyMessages; +} + function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError { if (!(err instanceof GatewayRequestError)) { return false; @@ -203,6 +277,7 @@ export async function loadChatHistory(state: ChatState) { const sessionKey = state.sessionKey; const requestVersion = beginChatHistoryRequest(state); const startedAt = Date.now(); + const previousMessages = state.chatMessages; state.chatLoading = true; state.lastError = null; try { @@ -237,7 +312,8 @@ export async function loadChatHistory(state: ChatState) { return; } const messages = Array.isArray(res.messages) ? res.messages : []; - state.chatMessages = messages.filter((message) => !shouldHideHistoryMessage(message)); + const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message)); + state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages); state.chatThinkingLevel = res.thinkingLevel ?? null; // Clear all streaming state — history includes tool results and text // inline, so keeping streaming artifacts would cause duplicates. From 52ebdabcfdc647806483a089fbff12d5bc7653de Mon Sep 17 00:00:00 2001 From: TC500 Date: Sat, 25 Apr 2026 11:28:25 +0800 Subject: [PATCH 08/93] fix(discord): use undici form data for multipart uploads --- extensions/discord/src/client.proxy.test.ts | 55 +++++++++++++++++++ .../discord/src/proxy-request-client.ts | 3 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/client.proxy.test.ts b/extensions/discord/src/client.proxy.test.ts index 3d5e583547d..bf6dbf2a977 100644 --- a/extensions/discord/src/client.proxy.test.ts +++ b/extensions/discord/src/client.proxy.test.ts @@ -1,6 +1,9 @@ +import http from "node:http"; +import { fetch as undiciFetch } from "undici"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; +import { createDiscordRequestClient } from "./proxy-request-client.js"; const makeProxyFetchMock = vi.hoisted(() => vi.fn()); @@ -118,4 +121,56 @@ describe("createDiscordRestClient proxy support", () => { expect(makeProxyFetchMock).toHaveBeenCalledWith("http://[::1]:8080"); expect(requestClient.options?.fetch).toEqual(expect.any(Function)); }); + + it("serializes multipart media with undici-compatible FormData for proxy fetches", async () => { + const received = await new Promise<{ + contentType: string | undefined; + body: string; + }>((resolve, reject) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("error", reject); + req.on("end", () => { + resolve({ + contentType: req.headers["content-type"], + body: Buffer.concat(chunks).toString("utf8"), + }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ id: "message-id", channel_id: "channel-id" })); + server.close(); + }); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("failed to bind test server")); + server.close(); + return; + } + const rest = createDiscordRequestClient("test-token", { + baseUrl: `http://127.0.0.1:${address.port}`, + fetch: undiciFetch as typeof fetch, + queueRequests: false, + }); + void rest + .post("/channels/123/messages", { + body: { + content: "with image", + files: [{ data: Buffer.from("png-data"), name: "image.png" }], + }, + }) + .catch((err: unknown) => { + reject(err); + server.close(); + }); + }); + }); + + expect(received.contentType).toMatch(/^multipart\/form-data; boundary=/); + expect(received.body).toContain('name="files[0]"; filename="image.png"'); + expect(received.body).toContain('name="payload_json"'); + expect(received.body).toContain('"attachments":[{"id":0,"filename":"image.png"}]'); + }); }); diff --git a/extensions/discord/src/proxy-request-client.ts b/extensions/discord/src/proxy-request-client.ts index bd68846dd3f..a78bc25aae2 100644 --- a/extensions/discord/src/proxy-request-client.ts +++ b/extensions/discord/src/proxy-request-client.ts @@ -7,6 +7,7 @@ import { type RequestClientOptions, } from "@buape/carbon"; import { isRecord } from "openclaw/plugin-sdk/text-runtime"; +import { FormData as UndiciFormData } from "undici"; export type ProxyRequestClientOptions = RequestClientOptions & { fetch?: typeof fetch; @@ -281,7 +282,7 @@ class ProxyRequestClientCompat { typeof payload === "string" ? { content: payload, attachments: [] } : { ...payload, attachments: [] }; - const formData = new FormData(); + const formData = new UndiciFormData(); const files = getMultipartFiles(payload); for (const [index, file] of files.entries()) { From 439f353cf62066f5ca4253e2208c282a271beed1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 05:44:38 +0100 Subject: [PATCH 09/93] fix(discord): bridge undici multipart types --- extensions/discord/src/client.proxy.test.ts | 2 +- extensions/discord/src/proxy-request-client.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/client.proxy.test.ts b/extensions/discord/src/client.proxy.test.ts index bf6dbf2a977..43012f35501 100644 --- a/extensions/discord/src/client.proxy.test.ts +++ b/extensions/discord/src/client.proxy.test.ts @@ -151,7 +151,7 @@ describe("createDiscordRestClient proxy support", () => { } const rest = createDiscordRequestClient("test-token", { baseUrl: `http://127.0.0.1:${address.port}`, - fetch: undiciFetch as typeof fetch, + fetch: undiciFetch as unknown as typeof fetch, queueRequests: false, }); void rest diff --git a/extensions/discord/src/proxy-request-client.ts b/extensions/discord/src/proxy-request-client.ts index a78bc25aae2..7ecf777f448 100644 --- a/extensions/discord/src/proxy-request-client.ts +++ b/extensions/discord/src/proxy-request-client.ts @@ -301,7 +301,7 @@ class ProxyRequestClientCompat { files: undefined, }; formData.append("payload_json", JSON.stringify(cleanedBody)); - body = formData; + body = formData as unknown as BodyInit; } else if (data?.body != null) { headers.set("Content-Type", "application/json"); body = data.rawBody ? (data.body as BodyInit) : JSON.stringify(data.body); From 88ca1859ed01aef6e7a1f2f9aa3c880860ac04e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 05:54:19 +0100 Subject: [PATCH 10/93] fix(discord): use undici multipart form data (#71383) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e11dd01a83..dcae15dc382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: copy the oversized `sessions.json` to a rotation backup before the atomic rewrite instead of renaming the live store away, so a crash during rotation keeps the existing session-to-transcript mapping authoritative. Fixes #68229. Thanks @jjjojoj. - Providers/OpenAI-compatible: strip OpenAI-only Completions `store` from proxy payloads and allow `extra_body`/`extraBody` passthrough params for provider-specific request fields. Fixes #61826 and #69717. - Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka. +- Discord/proxy: serialize proxied multipart attachment uploads with undici `FormData`, so Discord media sends work through configured REST proxies. (#71383) Thanks @TC500. - Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete. - Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler. - Plugins/Voice Call: reuse the webhook runtime across in-process plugin contexts, avoiding `EADDRINUSE` when agent tools or CLI commands run while the Gateway already owns the voice webhook port. Fixes #58115. Thanks @sfbrian. From c948c63bbdf4e9a896102691b29b4ade5a5f708e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:18:09 -0700 Subject: [PATCH 11/93] docs: unify casing and replace path-as-text links across recent doc surfaces Sweep recent (last ~5h) doc edits for two readability/uniformity issues: - Replace 42 path-as-text links of the form '[/foo/bar](/foo/bar)' with descriptive labels derived from each target page's frontmatter title (e.g. '[Anthropic]', '[Token use and costs]', '[OpenAI-compatible endpoints]'). Affected files include gateway/troubleshooting, concepts/oauth, reference/session-management-compaction, and reference/transcript-hygiene. - Sentence-case Title-Cased headings and link text in Related sections across codex-harness, model-providers, tools/plugin, sdk-runtime, sdk-setup, prompt-caching, ci, cli/config, google-meet, browser, rich-output-protocol, subagents, web/control-ui, while preserving brand and proper-noun capitalization (OpenAI, Codex, Chrome, Parallels, Z.AI, etc.). --- docs/ci.md | 2 +- docs/cli/config.md | 4 +- docs/concepts/model-providers.md | 10 +-- docs/concepts/oauth.md | 4 +- docs/gateway/troubleshooting.md | 78 +++++++++---------- docs/plugins/codex-harness.md | 6 +- docs/plugins/google-meet.md | 2 +- docs/plugins/sdk-runtime.md | 6 +- docs/plugins/sdk-setup.md | 6 +- docs/reference/prompt-caching.md | 6 +- docs/reference/rich-output-protocol.md | 2 +- .../session-management-compaction.md | 12 +-- docs/reference/transcript-hygiene.md | 2 +- docs/tools/browser.md | 2 +- docs/tools/plugin.md | 14 ++-- docs/tools/subagents.md | 2 +- docs/web/control-ui.md | 2 +- 17 files changed, 80 insertions(+), 80 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index debbcfc63f4..1350ab0358e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -79,7 +79,7 @@ gh workflow run duplicate-after-merge.yml \ | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | | `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | -## Fail-Fast Order +## Fail-fast order Jobs are ordered so cheap checks fail before expensive ones run: diff --git a/docs/cli/config.md b/docs/cli/config.md index c35c07f59a7..705975f1972 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -186,7 +186,7 @@ openclaw config set secrets.providers.vaultfile \ --strict-json ``` -## Provider Builder Flags +## Provider builder flags Provider builder targets must use `secrets.providers.` as the path. @@ -279,7 +279,7 @@ Dry-run behavior: - `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set - `errors`: structured schema/resolvability failures when `ok=false` -### JSON Output Shape +### JSON output shape ```json5 { diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index fa5fc768464..b879b4e7a4d 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -7,7 +7,7 @@ title: "Model providers" --- This page covers **LLM/model providers** (not chat channels like WhatsApp/Telegram). -For model selection rules, see [/concepts/models](/concepts/models). +For model selection rules, see [Models](/concepts/models). ## Quick rules @@ -160,7 +160,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - [Qwen Cloud](/providers/qwen): Qwen Cloud provider surface plus Alibaba DashScope and Coding Plan endpoint mapping - [MiniMax](/providers/minimax): MiniMax Coding Plan OAuth or API key access -- [GLM Models](/providers/glm): Z.AI Coding Plan or general API endpoints +- [GLM models](/providers/glm): Z.AI Coding Plan or general API endpoints ### OpenCode @@ -646,11 +646,11 @@ openclaw models set opencode/claude-opus-4-6 openclaw models list ``` -See also: [/gateway/configuration](/gateway/configuration) for full configuration examples. +See also: [Configuration](/gateway/configuration) for full configuration examples. ## Related - [Models](/concepts/models) — model configuration and aliases -- [Model Failover](/concepts/model-failover) — fallback chains and retry behavior -- [Configuration Reference](/gateway/config-agents#agent-defaults) — model config keys +- [Model failover](/concepts/model-failover) — fallback chains and retry behavior +- [Configuration reference](/gateway/config-agents#agent-defaults) — model config keys - [Providers](/providers) — per-provider setup guides diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 9d098dcb95e..f243c20e731 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -171,8 +171,8 @@ How to see what profile IDs exist: Related docs: -- [/concepts/model-failover](/concepts/model-failover) (rotation + cooldown rules) -- [/tools/slash-commands](/tools/slash-commands) (command surface) +- [Model failover](/concepts/model-failover) (rotation + cooldown rules) +- [Slash commands](/tools/slash-commands) (command surface) ## Related diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 7aaf92fe18c..eced49a8ffa 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -55,9 +55,9 @@ Fix options: Related: -- [/providers/anthropic](/providers/anthropic) -- [/reference/token-use](/reference/token-use) -- [/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic](/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic) +- [Anthropic](/providers/anthropic) +- [Token use and costs](/reference/token-use) +- [Why am I seeing HTTP 429 from Anthropic?](/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic) ## Local OpenAI-compatible backend passes direct probes but agent runs fail @@ -110,9 +110,9 @@ Fix options: Related: -- [/gateway/local-models](/gateway/local-models) -- [/gateway/configuration](/gateway/configuration) -- [/gateway/configuration-reference#openai-compatible-endpoints](/gateway/configuration-reference#openai-compatible-endpoints) +- [Local models](/gateway/local-models) +- [Configuration](/gateway/configuration) +- [OpenAI-compatible endpoints](/gateway/configuration-reference#openai-compatible-endpoints) ## No replies @@ -140,9 +140,9 @@ Common signatures: Related: -- [/channels/troubleshooting](/channels/troubleshooting) -- [/channels/pairing](/channels/pairing) -- [/channels/groups](/channels/groups) +- [Channel troubleshooting](/channels/troubleshooting) +- [Pairing](/channels/pairing) +- [Groups](/channels/groups) ## Dashboard control ui connectivity @@ -223,11 +223,11 @@ If `openclaw devices rotate` / `revoke` / `remove` is denied unexpectedly: Related: -- [/web/control-ui](/web/control-ui) -- [/gateway/configuration](/gateway/configuration) (gateway auth modes) -- [/gateway/trusted-proxy-auth](/gateway/trusted-proxy-auth) -- [/gateway/remote](/gateway/remote) -- [/cli/devices](/cli/devices) +- [Control UI](/web/control-ui) +- [Configuration](/gateway/configuration) (gateway auth modes) +- [Trusted proxy auth](/gateway/trusted-proxy-auth) +- [Remote access](/gateway/remote) +- [Devices](/cli/devices) ## Gateway service not running @@ -258,9 +258,9 @@ Common signatures: Related: -- [/gateway/background-process](/gateway/background-process) -- [/gateway/configuration](/gateway/configuration) -- [/gateway/doctor](/gateway/doctor) +- [Background exec and process tool](/gateway/background-process) +- [Configuration](/gateway/configuration) +- [Doctor](/gateway/doctor) ## Gateway restored last-known-good config @@ -318,10 +318,10 @@ Fix options: Related: -- [/gateway/configuration#strict-validation](/gateway/configuration#strict-validation) -- [/gateway/configuration#config-hot-reload](/gateway/configuration#config-hot-reload) -- [/cli/config](/cli/config) -- [/gateway/doctor](/gateway/doctor) +- [Configuration: strict validation](/gateway/configuration#strict-validation) +- [Configuration: hot reload](/gateway/configuration#config-hot-reload) +- [Config](/cli/config) +- [Doctor](/gateway/doctor) ## Gateway probe warnings @@ -348,9 +348,9 @@ Common signatures: Related: -- [/cli/gateway](/cli/gateway) -- [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host) -- [/gateway/remote](/gateway/remote) +- [Gateway](/cli/gateway) +- [Multiple gateways on the same host](/gateway#multiple-gateways-same-host) +- [Remote access](/gateway/remote) ## Channel connected messages not flowing @@ -378,10 +378,10 @@ Common signatures: Related: -- [/channels/troubleshooting](/channels/troubleshooting) -- [/channels/whatsapp](/channels/whatsapp) -- [/channels/telegram](/channels/telegram) -- [/channels/discord](/channels/discord) +- [Channel troubleshooting](/channels/troubleshooting) +- [WhatsApp](/channels/whatsapp) +- [Telegram](/channels/telegram) +- [Discord](/channels/discord) ## Cron and heartbeat delivery @@ -413,9 +413,9 @@ Common signatures: Related: -- [/automation/cron-jobs#troubleshooting](/automation/cron-jobs#troubleshooting) -- [/automation/cron-jobs](/automation/cron-jobs) -- [/gateway/heartbeat](/gateway/heartbeat) +- [Scheduled tasks: troubleshooting](/automation/cron-jobs#troubleshooting) +- [Scheduled tasks](/automation/cron-jobs) +- [Heartbeat](/gateway/heartbeat) ## Node paired tool fails @@ -444,9 +444,9 @@ Common signatures: Related: -- [/nodes/troubleshooting](/nodes/troubleshooting) -- [/nodes/index](/nodes/index) -- [/tools/exec-approvals](/tools/exec-approvals) +- [Node troubleshooting](/nodes/troubleshooting) +- [Nodes](/nodes/index) +- [Exec approvals](/tools/exec-approvals) ## Browser tool fails @@ -492,8 +492,8 @@ Common signatures: Related: -- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) -- [/tools/browser](/tools/browser) +- [Browser troubleshooting](/tools/browser-linux-troubleshooting) +- [Browser (OpenClaw-managed)](/tools/browser) ## If you upgraded and something suddenly broke @@ -566,9 +566,9 @@ openclaw gateway restart Related: -- [/gateway/pairing](/gateway/pairing) -- [/gateway/authentication](/gateway/authentication) -- [/gateway/background-process](/gateway/background-process) +- [Gateway-owned pairing](/gateway/pairing) +- [Authentication](/gateway/authentication) +- [Background exec and process tool](/gateway/background-process) ## Related diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index ef5ea20e5b9..cd4883e83bf 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -693,11 +693,11 @@ turn for that agent must be a Codex-supported OpenAI model. ## Related -- [Agent Harness Plugins](/plugins/sdk-agent-harness) +- [Agent harness plugins](/plugins/sdk-agent-harness) - [Agent runtimes](/concepts/agent-runtimes) -- [Model Providers](/concepts/model-providers) +- [Model providers](/concepts/model-providers) - [OpenAI provider](/providers/openai) - [Status](/cli/status) - [Plugin hooks](/plugins/hooks) -- [Configuration Reference](/gateway/configuration-reference) +- [Configuration reference](/gateway/configuration-reference) - [Testing](/help/testing-live#live-codex-app-server-harness-smoke) diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 9b411206c36..727c67606f1 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -158,7 +158,7 @@ the microphone/speaker path used by OpenClaw. For clean duplex audio, use separate virtual devices or a Loopback-style graph; a single BlackHole device is enough for a first smoke test but can echo. -### Local Gateway + Parallels Chrome +### Local gateway + Parallels Chrome You do **not** need a full OpenClaw Gateway or model API key inside a macOS VM just to make the VM own Chrome. Run the Gateway and agent locally, then run a diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index eb1ea20e5d0..b58c8c6c2fe 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -462,6 +462,6 @@ Beyond `api.runtime`, the API object also provides: ## Related -- [SDK Overview](/plugins/sdk-overview) -- subpath reference -- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` options -- [Plugin Internals](/plugins/architecture) -- capability model and registry +- [SDK overview](/plugins/sdk-overview) — subpath reference +- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` options +- [Plugin internals](/plugins/architecture) — capability model and registry diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 8d2e53a3af7..6cedd59560f 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -566,6 +566,6 @@ startup installs; keep using the explicit plugin installer. ## Related -- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` and `defineChannelPluginEntry` -- [Plugin Manifest](/plugins/manifest) -- full manifest schema reference -- [Building Plugins](/plugins/building-plugins) -- step-by-step getting started guide +- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` +- [Plugin manifest](/plugins/manifest) — full manifest schema reference +- [Building plugins](/plugins/building-plugins) — step-by-step getting started guide diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 7b4e5ab2c33..cac3b1789d8 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -338,9 +338,9 @@ Defaults: Related docs: - [Anthropic](/providers/anthropic) -- [Token Use and Costs](/reference/token-use) -- [Session Pruning](/concepts/session-pruning) -- [Gateway Configuration Reference](/gateway/configuration-reference) +- [Token use and costs](/reference/token-use) +- [Session pruning](/concepts/session-pruning) +- [Gateway configuration reference](/gateway/configuration-reference) ## Related diff --git a/docs/reference/rich-output-protocol.md b/docs/reference/rich-output-protocol.md index 4703cfefaf6..bd5ec4c8c0a 100644 --- a/docs/reference/rich-output-protocol.md +++ b/docs/reference/rich-output-protocol.md @@ -39,7 +39,7 @@ Rules: - The web UI strips the shortcode from visible text and renders the embed inline. - `MEDIA:` is not an embed alias and should not be used for rich embed rendering. -## Stored Rendering Shape +## Stored rendering shape The normalized/stored assistant content block is a structured `canvas` item: diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 679b87ddba8..7518c74832e 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -21,12 +21,12 @@ This document explains how OpenClaw manages sessions end-to-end: If you want a higher-level overview first, start with: -- [/concepts/session](/concepts/session) -- [/concepts/compaction](/concepts/compaction) -- [/concepts/memory](/concepts/memory) -- [/concepts/memory-search](/concepts/memory-search) -- [/concepts/session-pruning](/concepts/session-pruning) -- [/reference/transcript-hygiene](/reference/transcript-hygiene) +- [Session management](/concepts/session) +- [Compaction](/concepts/compaction) +- [Memory overview](/concepts/memory) +- [Memory search](/concepts/memory-search) +- [Session pruning](/concepts/session-pruning) +- [Transcript hygiene](/reference/transcript-hygiene) --- diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index e195150b9e9..7d4bdbb069b 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -27,7 +27,7 @@ Scope includes: If you need transcript storage details, see: -- [/reference/session-management-compaction](/reference/session-management-compaction) +- [Session management deep dive](/reference/session-management-compaction) --- diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 51f5d514b62..aa3ad316d1c 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -427,7 +427,7 @@ Defaults: All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Existing-session via Chrome DevTools MCP +## Existing session via Chrome DevTools MCP OpenClaw can also attach to a running Chromium-based browser profile through the official Chrome DevTools MCP server. This reuses the tabs and login state diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6b24e48d5d2..05fab1728f6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -423,13 +423,13 @@ observe results through `after_tool_call`, and participate in Codex arguments yet. The exact Codex runtime support boundary lives in the [Codex harness v1 support contract](/plugins/codex-harness#v1-support-contract). -For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics). +For full typed hook behavior, see [SDK overview](/plugins/sdk-overview#hook-decision-semantics). ## Related -- [Building Plugins](/plugins/building-plugins) — create your own plugin -- [Plugin Bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility -- [Plugin Manifest](/plugins/manifest) — manifest schema -- [Registering Tools](/plugins/building-plugins#registering-agent-tools) — add agent tools in a plugin -- [Plugin Internals](/plugins/architecture) — capability model and load pipeline -- [Community Plugins](/plugins/community) — third-party listings +- [Building plugins](/plugins/building-plugins) — create your own plugin +- [Plugin bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility +- [Plugin manifest](/plugins/manifest) — manifest schema +- [Registering tools](/plugins/building-plugins#registering-agent-tools) — add agent tools in a plugin +- [Plugin internals](/plugins/architecture) — capability model and load pipeline +- [Community plugins](/plugins/community) — third-party listings diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 5de2bcc3670..70331775d85 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -170,7 +170,7 @@ Auto-archive: - Auto-archive applies equally to depth-1 and depth-2 sessions. - Browser cleanup is separate from archive cleanup: tracked browser tabs/processes are best-effort closed when the run finishes, even if the transcript/session record is kept. -## Nested Sub-Agents +## Nested sub-agents By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index c586cb71eb5..2a54872c27c 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -318,7 +318,7 @@ Trusted-proxy note: device identity - this does **not** extend to node-role Control UI sessions - same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see - [Trusted Proxy Auth](/gateway/trusted-proxy-auth) + [Trusted proxy auth](/gateway/trusted-proxy-auth) See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. From 2f097c47f89074e4cd9538d5a678e7af52f1b583 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:15:39 +0100 Subject: [PATCH 12/93] ci: route narrow ci changes through fast path --- .github/workflows/ci.yml | 88 ++++++++++++++++++++-------- docs/ci.md | 1 + scripts/ci-changed-scope.mjs | 53 +++++++++++++++++ src/scripts/ci-changed-scope.test.ts | 58 +++++++++++++++++- 4 files changed, 175 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 573a3efb308..5ec6378774e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }} changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }} run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }} + run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }} run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }} checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }} channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }} @@ -130,6 +131,9 @@ jobs: OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }} OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }} OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ steps.changed_scope.outputs.run_node_fast_only || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }} OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }} OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }} OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }} @@ -173,12 +177,23 @@ jobs: const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; + const runNodeFastOnly = + runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY); + const runNodeFull = runNode && !runNodeFastOnly; + const runNodeFastPluginContracts = + runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS); + const runNodeFastCiRouting = + runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING); + const runChecksFastCore = runNodeFull || runNodeFastPluginContracts || runNodeFastCiRouting; const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository; const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository; const runWindows = - parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && isCanonicalRepository; + parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && + !docsOnly && + !runNodeFastOnly && + isCanonicalRepository; const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly; const runControlUiI18n = parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly; @@ -191,7 +206,7 @@ jobs: ? DEFAULT_EXTENSION_TEST_SHARD_COUNT : Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36); const extensionShardMatrix = createMatrix( - runNode + runNodeFull ? createExtensionTestShards({ shardCount: extensionTestShardCount, }).map((shard) => ({ @@ -207,7 +222,33 @@ jobs: })) : [], ); - const nodeTestShards = runNode + const checksFastCoreTasks = []; + if (runNodeFull) { + checksFastCoreTasks.push( + { check_name: "checks-fast-bundled", runtime: "node", task: "bundled" }, + { + check_name: "checks-fast-contracts-plugins", + runtime: "node", + task: "contracts-plugins", + }, + ); + } else { + if (runNodeFastPluginContracts) { + checksFastCoreTasks.push({ + check_name: "checks-fast-contracts-plugins", + runtime: "node", + task: runNodeFastCiRouting ? "contracts-plugins-ci-routing" : "contracts-plugins", + }); + } else if (runNodeFastCiRouting) { + checksFastCoreTasks.push({ + check_name: "checks-fast-ci-routing", + runtime: "node", + task: "ci-routing", + }); + } + } + + const nodeTestShards = runNodeFull ? createNodeTestShards().map((shard) => ({ check_name: shard.checkName, runtime: "node", @@ -232,25 +273,17 @@ jobs: run_windows: runWindows, has_changed_extensions: hasChangedExtensions, changed_extensions_matrix: changedExtensionsMatrix, - run_build_artifacts: runNode, - run_checks_fast: runNode, - checks_fast_core_matrix: createMatrix( - runNode - ? [ - { check_name: "checks-fast-bundled", runtime: "node", task: "bundled" }, - { - check_name: "checks-fast-contracts-plugins", - runtime: "node", - task: "contracts-plugins", - }, - ] - : [], + run_build_artifacts: runNodeFull, + run_checks_fast_core: runChecksFastCore, + run_checks_fast: runNodeFull, + checks_fast_core_matrix: createMatrix(checksFastCoreTasks), + channel_contracts_matrix: createMatrix( + runNodeFull ? createChannelContractTestShards() : [], ), - channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []), checks_node_extensions_matrix: extensionShardMatrix, - run_checks: runNode, + run_checks: runNodeFull, checks_matrix: createMatrix( - runNode + runNodeFull ? [ { check_name: "checks-node-channels", runtime: "node", task: "channels" }, ] @@ -269,9 +302,9 @@ jobs: })) : [], ), - run_check: runNode, - run_check_additional: runNode, - run_build_smoke: runNode, + run_check: runNodeFull, + run_check_additional: runNodeFull, + run_build_smoke: runNodeFull, run_check_docs: docsChanged, run_control_ui_i18n: runControlUiI18n, run_skills_python_job: runSkillsPython, @@ -662,7 +695,7 @@ jobs: contents: read name: ${{ matrix.check_name }} needs: [preflight] - if: needs.preflight.outputs.run_checks_fast == 'true' + if: needs.preflight.outputs.run_checks_fast_core == 'true' runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: @@ -739,6 +772,13 @@ jobs: contracts-plugins) pnpm test:contracts:plugins ;; + contracts-plugins-ci-routing) + pnpm test:contracts:plugins + pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + ;; + ci-routing) + pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + ;; *) echo "Unsupported checks-fast task: $TASK" >&2 exit 1 @@ -1044,7 +1084,7 @@ jobs: contents: read name: checks-node-compat-node22 needs: [preflight] - if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push' + if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push' runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 steps: diff --git a/docs/ci.md b/docs/ci.md index 1350ab0358e..d67783bfeec 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -90,6 +90,7 @@ Jobs are ordered so cheap checks fail before expensive ones run: Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. +CI routing-only edits and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 120-second command timeout. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 427cf9dcb9c..c4bff734296 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; /** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */ +/** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */ /** @typedef {{ runFastInstallSmoke: boolean; runFullInstallSmoke: boolean }} InstallSmokeScope */ const FULL_SCOPE = { @@ -49,6 +50,13 @@ const FAST_INSTALL_SMOKE_SCOPE_RE = const FULL_INSTALL_SMOKE_SCOPE_RE = /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/install\.sh$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|qr-import-docker\.sh|bun-global-install-smoke\.sh)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = /^src\/(?:channels|gateway|plugin-sdk|plugins)\//; +const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE = + /^(src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry)\.ts$|test\/helpers\/plugins\/tts-contract-suites\.ts$|scripts\/test-projects(?:\.test-support)?\.mjs$|test\/scripts\/test-projects\.test\.ts$)/; +const NODE_FAST_CI_ROUTING_SCOPE_RE = + /^(scripts\/ci-changed-scope\.mjs$|src\/scripts\/ci-changed-scope\.test\.ts$|\.github\/workflows\/ci\.yml$)/; +const NODE_FAST_SCOPE_RE = new RegExp( + `${NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.source}|${NODE_FAST_CI_ROUTING_SCOPE_RE.source}`, +); /** * @param {string[]} changedPaths @@ -144,6 +152,42 @@ export function detectChangedScope(changedPaths) { }; } +/** + * @param {string[]} changedPaths + * @returns {NodeFastScope} + */ +export function detectNodeFastScope(changedPaths) { + if (!Array.isArray(changedPaths) || changedPaths.length === 0) { + return { runFastOnly: false, runPluginContracts: false, runCiRouting: false }; + } + + let hasNonDocs = false; + let runPluginContracts = false; + let runCiRouting = false; + + for (const rawPath of changedPaths) { + const path = rawPath.trim(); + if (!path || DOCS_PATH_RE.test(path)) { + continue; + } + + hasNonDocs = true; + runPluginContracts ||= NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.test(path); + runCiRouting ||= NODE_FAST_CI_ROUTING_SCOPE_RE.test(path); + + if (!NODE_FAST_SCOPE_RE.test(path)) { + return { runFastOnly: false, runPluginContracts: false, runCiRouting: false }; + } + } + + const runFastOnly = hasNonDocs && (runPluginContracts || runCiRouting); + return { + runFastOnly, + runPluginContracts: runFastOnly && runPluginContracts, + runCiRouting: runFastOnly && runCiRouting, + }; +} + /** * @param {string} path * @returns {InstallSmokeScope} @@ -211,6 +255,7 @@ export function writeGitHubOutput( runFastInstallSmoke: scope.runChangedSmoke, runFullInstallSmoke: scope.runChangedSmoke, }, + nodeFastScope = { runFastOnly: false, runPluginContracts: false, runCiRouting: false }, ) { if (!outputPath) { throw new Error("GITHUB_OUTPUT is required"); @@ -221,6 +266,13 @@ export function writeGitHubOutput( appendFileSync(outputPath, `run_windows=${scope.runWindows}\n`, "utf8"); appendFileSync(outputPath, `run_skills_python=${scope.runSkillsPython}\n`, "utf8"); appendFileSync(outputPath, `run_changed_smoke=${scope.runChangedSmoke}\n`, "utf8"); + appendFileSync(outputPath, `run_node_fast_only=${nodeFastScope.runFastOnly}\n`, "utf8"); + appendFileSync( + outputPath, + `run_node_fast_plugin_contracts=${nodeFastScope.runPluginContracts}\n`, + "utf8", + ); + appendFileSync(outputPath, `run_node_fast_ci_routing=${nodeFastScope.runCiRouting}\n`, "utf8"); appendFileSync( outputPath, `run_fast_install_smoke=${installSmokeScope.runFastInstallSmoke}\n`, @@ -268,6 +320,7 @@ if (isDirectRun()) { detectChangedScope(changedPaths), process.env.GITHUB_OUTPUT, detectInstallSmokeScope(changedPaths), + detectNodeFastScope(changedPaths), ); } catch { writeGitHubOutput(FULL_SCOPE); diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 9fa5d34c525..7834ad2a39b 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { bundledPluginFile } from "../../test/helpers/bundled-plugin-paths.js"; -const { detectChangedScope, detectInstallSmokeScope, listChangedPaths } = +const { detectChangedScope, detectInstallSmokeScope, detectNodeFastScope, listChangedPaths } = (await import("../../scripts/ci-changed-scope.mjs")) as unknown as { detectChangedScope: (paths: string[]) => { runNode: boolean; @@ -20,6 +20,11 @@ const { detectChangedScope, detectInstallSmokeScope, listChangedPaths } = runFastInstallSmoke: boolean; runFullInstallSmoke: boolean; }; + detectNodeFastScope: (paths: string[]) => { + runFastOnly: boolean; + runPluginContracts: boolean; + runCiRouting: boolean; + }; listChangedPaths: (base: string, head?: string) => string[]; }; @@ -486,6 +491,54 @@ describe("detectChangedScope", () => { }); }); + it("identifies plugin contract helper changes as fast Node-only CI scope", () => { + const bundledCapabilityMetadataPath = [ + "src/plugins/contracts", + "inventory/bundled-capability-metadata.ts", + ].join("/"); + expect( + detectNodeFastScope([ + bundledCapabilityMetadataPath, + "src/plugins/contracts/registry.ts", + "test/helpers/plugins/tts-contract-suites.ts", + "scripts/test-projects.test-support.mjs", + "test/scripts/test-projects.test.ts", + ]), + ).toEqual({ + runFastOnly: true, + runPluginContracts: true, + runCiRouting: false, + }); + }); + + it("identifies CI routing changes as fast Node-only CI scope", () => { + expect( + detectNodeFastScope([ + ".github/workflows/ci.yml", + "scripts/ci-changed-scope.mjs", + "src/scripts/ci-changed-scope.test.ts", + "docs/ci.md", + ]), + ).toEqual({ + runFastOnly: true, + runPluginContracts: false, + runCiRouting: true, + }); + }); + + it("keeps broad source changes on the full Node CI scope", () => { + expect( + detectNodeFastScope([ + "src/plugins/contracts/manifest-loader.ts", + "src/plugins/contracts/registry.ts", + ]), + ).toEqual({ + runFastOnly: false, + runPluginContracts: false, + runCiRouting: false, + }); + }); + it("treats base and head as literal git args", () => { const markerPath = path.join( os.tmpdir(), @@ -527,6 +580,9 @@ describe("detectChangedScope", () => { run_windows: "false", run_skills_python: "false", run_changed_smoke: "false", + run_node_fast_only: "false", + run_node_fast_plugin_contracts: "false", + run_node_fast_ci_routing: "false", run_fast_install_smoke: "false", run_full_install_smoke: "false", run_control_ui_i18n: "false", From f00d65a30496335fb068f01fe529dbba484d4312 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:18:26 +0100 Subject: [PATCH 13/93] test: align status scan compatibility fixture --- src/commands/status.scan-result.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/status.scan-result.test.ts b/src/commands/status.scan-result.test.ts index 2a9225ece2c..3dcdd21ea28 100644 --- a/src/commands/status.scan-result.test.ts +++ b/src/commands/status.scan-result.test.ts @@ -87,6 +87,7 @@ describe("buildStatusScanResult", () => { { pluginId: "legacy", code: "legacy-before-agent-start" as const, + compatCode: "legacy-before-agent-start" as const, severity: "warn" as const, message: "warn", }, From a35333abe1641603fa148b5126e7afe8db642f09 Mon Sep 17 00:00:00 2001 From: Sean Coley Date: Sat, 25 Apr 2026 17:21:49 +1200 Subject: [PATCH 14/93] fix(browser): recover stale Chromium profile locks (#62935) (#62935) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/tools/browser-linux-troubleshooting.md | 10 + .../src/browser/chrome.internal.test.ts | 60 +++++ extensions/browser/src/browser/chrome.test.ts | 50 ++++ extensions/browser/src/browser/chrome.ts | 234 ++++++++++++++---- 5 files changed, 300 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcae15dc382..109dddddf82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao. - Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech. - Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries. +- Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index b8df2b2f26f..9362d937bb7 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -25,6 +25,16 @@ chromium-browser is already the newest version (2:1snap1-0ubuntu2). This is NOT a real browser - it's just a wrapper. +Other common Linux launch failures: + +- `The profile appears to be in use by another Chromium process` means Chrome + found stale `Singleton*` lock files in the managed profile directory. OpenClaw + removes those locks and retries once when the lock points at a dead or + different-host process. +- `Missing X server or $DISPLAY` means OpenClaw is trying to launch a visible + browser on a host without a desktop session. Use `browser.headless: true`, + start `Xvfb`, or run OpenClaw in a real desktop session. + ### Solution 1: Install Google Chrome (Recommended) Install the official Google Chrome `.deb` package, which is not sandboxed by snap: diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 3b9d340fb24..aa6136addc7 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -433,6 +433,66 @@ describe("chrome.ts internal", () => { } }); + it("clears stale singleton locks and retries once after profile-in-use launch failure", async () => { + let cdpReachable = false; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (!cdpReachable) { + throw new Error("ECONNREFUSED"); + } + return { + ok: true, + json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), + } as unknown as Response; + }), + ); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s === "/tmp/profile-chrome" || s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return false; + }); + + let spawnCalls = 0; + const firstProc = makeFakeProc(); + const secondProc = makeFakeProc(); + spawnMock.mockImplementation(() => { + spawnCalls += 1; + if (spawnCalls === 1) { + setTimeout(() => { + firstProc.stderr.emit( + "data", + Buffer.from("The profile appears to be in use by another Chromium process"), + ); + }, 0); + return firstProc; + } + cdpReachable = true; + return secondProc; + }); + + const profile = { ...makeProfile(18888), executablePath: "/tmp/profile-chrome" }; + const userDataDir = resolveOpenClawUserDataDir(profile.name); + await fsp.mkdir(userDataDir, { recursive: true }); + await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie"); + await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket"); + await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock")); + + try { + const running = await launchOpenClawChrome(makeResolved(), profile); + expect(running.proc).toBe(secondProc); + expect(firstProc.kill).toHaveBeenCalledWith("SIGKILL"); + expect(spawnCalls).toBe(2); + expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false); + expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false); + running.proc.kill?.("SIGTERM"); + } finally { + await fsp.rm(userDataDir, { recursive: true, force: true }); + } + }); + it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "linux" }); diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 4d650e21064..c9b2cebed09 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -11,6 +11,7 @@ import { resolveGoogleChromeExecutableForPlatform, } from "./chrome.executables.js"; import { + clearStaleChromeSingletonLocks, decorateOpenClawProfile, diagnoseChromeCdp, ensureProfileCleanExit, @@ -212,6 +213,55 @@ describe("browser chrome profile decoration", () => { const profile = prefs.profile as Record; expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); }); + + it("clears stale singleton artifacts when the lock points at another host", async () => { + const userDataDir = await createUserDataDir(); + await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie"); + await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket"); + await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock")); + + expect(clearStaleChromeSingletonLocks(userDataDir, "local-host")).toBe(true); + expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false); + expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false); + expect(fs.existsSync(path.join(userDataDir, "SingletonCookie"))).toBe(false); + }); + + it("clears stale singleton artifacts when the lock PID is dead on the current host", async () => { + const userDataDir = await createUserDataDir(); + const deadPid = 2147483646; + await fsp.symlink(`${os.hostname()}-${deadPid}`, path.join(userDataDir, "SingletonLock")); + + expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(true); + expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false); + }); + + it("keeps singleton artifacts when the lock points at a current-host live process", async () => { + const userDataDir = await createUserDataDir(); + await fsp.symlink(`${os.hostname()}-${process.pid}`, path.join(userDataDir, "SingletonLock")); + + expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false); + expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true); + }); + + it("keeps singleton artifacts when the lock PID exists but cannot be signaled", async () => { + const userDataDir = await createUserDataDir(); + await fsp.symlink(`${os.hostname()}-12345`, path.join(userDataDir, "SingletonLock")); + const err = new Error("operation not permitted") as NodeJS.ErrnoException; + err.code = "EPERM"; + const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid, signal) => { + if (pid === 12345 && signal === 0) { + throw err; + } + return true; + }) as typeof process.kill); + + try { + expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false); + expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true); + } finally { + killSpy.mockRestore(); + } + }); }); describe("browser chrome helpers", () => { diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 95c046ebe7e..086f460fcb5 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -53,6 +53,13 @@ import { } from "./constants.js"; const log = createSubsystemLogger("browser").child("chrome"); +const CHROME_SINGLETON_LOCK_PATHS = [ + "SingletonLock", + "SingletonSocket", + "SingletonCookie", +] as const; +const CHROME_SINGLETON_IN_USE_PATTERN = /profile appears to be in use by another chromium process/i; +const CHROME_MISSING_DISPLAY_PATTERN = /missing x server|\$DISPLAY/i; export type { BrowserExecutable } from "./chrome.executables.js"; export { @@ -81,6 +88,109 @@ function exists(filePath: string) { } } +function processExists(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EPERM") { + return true; + } + return false; + } +} + +function clearChromeSingletonArtifacts(userDataDir: string) { + for (const basename of CHROME_SINGLETON_LOCK_PATHS) { + try { + fs.rmSync(path.join(userDataDir, basename), { force: true }); + } catch { + // ignore best-effort cleanup + } + } +} + +export function clearStaleChromeSingletonLocks( + userDataDir: string, + hostname = os.hostname(), +): boolean { + const lockPath = path.join(userDataDir, "SingletonLock"); + let target: string; + try { + target = fs.readlinkSync(lockPath); + } catch { + return false; + } + + const match = /^(?.+)-(?\d+)$/.exec(target); + if (!match?.groups) { + return false; + } + + const lockHost = normalizeOptionalString(match.groups.lockHost) ?? ""; + const pid = Number.parseInt(match.groups.pid ?? "", 10); + if (lockHost === hostname && processExists(pid)) { + return false; + } + + clearChromeSingletonArtifacts(userDataDir); + return true; +} + +async function waitForChromeProcessExit(proc: ChildProcess, timeoutMs: number): Promise { + if (proc.exitCode != null || proc.signalCode != null || proc.killed) { + return; + } + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc.off("exit", onExit); + proc.off("close", onExit); + resolve(); + }, timeoutMs); + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + proc.once("exit", onExit); + proc.once("close", onExit); + }); +} + +async function terminateChromeForRetry(proc: ChildProcess, userDataDir: string) { + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + await waitForChromeProcessExit(proc, CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS); + clearStaleChromeSingletonLocks(userDataDir); +} + +function chromeLaunchHints(params: { + stderrOutput: string; + resolved: ResolvedBrowserConfig; + profile: ResolvedBrowserProfile; +}): string { + const hints: string[] = []; + if (process.platform === "linux" && !params.resolved.noSandbox) { + hints.push("If running in a container or as root, try setting browser.noSandbox: true."); + } + if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !params.profile.headless) { + hints.push( + "No DISPLAY/X server was detected. Enable browser.headless: true, start Xvfb, or run the Gateway in a desktop session.", + ); + } + if (CHROME_SINGLETON_IN_USE_PATTERN.test(params.stderrOutput)) { + hints.push( + `The Chromium profile "${params.profile.name}" is locked. Stop the existing browser or remove stale Singleton* lock files under ~/.openclaw/browser/${params.profile.name}/user-data.`, + ); + } + return hints.length > 0 ? `\nHint: ${hints.join("\nHint: ")}` : ""; +} + export type RunningChrome = { pid: number; exe: BrowserExecutable; @@ -363,66 +473,80 @@ export async function launchOpenClawChrome( log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`); } - const proc = spawnOnce(); + const launchOnceAndWait = async (allowSingletonRecovery: boolean): Promise => { + const proc = spawnOnce(); - // Collect stderr for diagnostics in case Chrome fails to start. - // The listener is removed on success to avoid unbounded memory growth - // from a long-lived Chrome process that emits periodic warnings. - const stderrChunks: Buffer[] = []; - const onStderr = (chunk: Buffer) => { - stderrChunks.push(chunk); - }; - proc.stderr?.on("data", onStderr); + // Collect stderr for diagnostics in case Chrome fails to start. + // The listener is removed on success to avoid unbounded memory growth + // from a long-lived Chrome process that emits periodic warnings. + const stderrChunks: Buffer[] = []; + const onStderr = (chunk: Buffer) => { + stderrChunks.push(chunk); + }; + proc.stderr?.on("data", onStderr); - // Wait for CDP to come up. - const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS; - while (Date.now() < readyDeadline) { - if (await isChromeReachable(profile.cdpUrl)) { - break; - } - await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS)); - } - - if (!(await isChromeReachable(profile.cdpUrl))) { - const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl) - .then(formatChromeCdpDiagnostic) - .catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`); - const stderrOutput = - normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? ""; - const stderrHint = stderrOutput - ? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}` - : ""; - const sandboxHint = - process.platform === "linux" && !resolved.noSandbox - ? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config." - : ""; try { - proc.kill("SIGKILL"); - } catch { - // ignore + const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS; + while (Date.now() < readyDeadline) { + if (await isChromeReachable(profile.cdpUrl)) { + break; + } + await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS)); + } + + if (!(await isChromeReachable(profile.cdpUrl))) { + const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl) + .then(formatChromeCdpDiagnostic) + .catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`); + const stderrOutput = + normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? ""; + if ( + allowSingletonRecovery && + CHROME_SINGLETON_IN_USE_PATTERN.test(stderrOutput) && + clearStaleChromeSingletonLocks(userDataDir) + ) { + log.warn( + `Removed stale Chromium Singleton* locks for profile "${profile.name}" and retrying launch.`, + ); + await terminateChromeForRetry(proc, userDataDir); + return await launchOnceAndWait(false); + } + const stderrHint = stderrOutput + ? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}` + : ""; + const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile }); + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + throw new Error( + `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${launchHints}${stderrHint}`, + ); + } + + const pid = proc.pid ?? -1; + log.info( + `🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, + ); + + return { + pid, + exe, + userDataDir, + cdpPort: profile.cdpPort, + startedAt, + proc, + }; + } finally { + // Chrome started successfully or launch failed — detach the stderr listener + // and release the buffer. + proc.stderr?.off("data", onStderr); + stderrChunks.length = 0; } - throw new Error( - `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${sandboxHint}${stderrHint}`, - ); - } - - // Chrome started successfully — detach the stderr listener and release the buffer. - proc.stderr?.off("data", onStderr); - stderrChunks.length = 0; - - const pid = proc.pid ?? -1; - log.info( - `🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, - ); - - return { - pid, - exe, - userDataDir, - cdpPort: profile.cdpPort, - startedAt, - proc, }; + + return await launchOnceAndWait(true); } export async function stopOpenClawChrome( From ee3c32c1032e14f0653f32721f320619da74022a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:21:37 -0700 Subject: [PATCH 15/93] docs(plugins): clarify inspector package boundary --- docs/plugins/compatibility.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index ac9e92f536f..819fbe1b568 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -31,6 +31,30 @@ The registry is the source for maintainer planning and future plugin inspector checks. If a plugin-facing behavior changes, add or update the compatibility record in the same change that adds the adapter. +## Plugin inspector package + +The plugin inspector should live outside the core OpenClaw repo as a separate +package/repository backed by the versioned compatibility and manifest +contracts. + +The day-one CLI should be: + +```sh +openclaw-plugin-inspector ./my-plugin +``` + +It should emit: + +- manifest/schema validation +- the contract compatibility version being checked +- install/source metadata checks +- cold-path import checks +- deprecation and compatibility warnings + +Use `--json` for stable machine-readable output in CI annotations. OpenClaw +core should expose contracts and fixtures the inspector can consume, but should +not publish the inspector binary from the main `openclaw` package. + ## Deprecation policy OpenClaw should not remove a documented plugin contract in the same release From 576c6c240f48ab56a187163d7f91e28c79e53313 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:22:24 +0100 Subject: [PATCH 16/93] fix(discord): collapse cron announce text --- CHANGELOG.md | 1 + docs/automation/cron-jobs.md | 5 +++ docs/channels/discord.md | 3 ++ extensions/discord/src/channel.test.ts | 4 +++ extensions/discord/src/channel.ts | 1 + ...gent.direct-delivery-core-channels.test.ts | 34 ++++++++++++++++++- 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109dddddf82..cf83d44dee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 6f024f0f27a..7f549ee7e9f 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -94,6 +94,11 @@ When isolated cron runs orchestrate subagents, delivery also prefers the final descendant output over stale parent interim text. If descendants are still running, OpenClaw suppresses that partial parent update instead of announcing it. +For text-only Discord announce targets, OpenClaw sends the canonical final +assistant text once instead of replaying both streamed/intermediate text payloads +and the final answer. Media and structured Discord payloads are still delivered +as separate payloads so attachments and components are not dropped. + ### Payload options for isolated jobs - `--message`: prompt text (required for isolated) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ca440706749..7b61c82e740 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -267,6 +267,9 @@ Now create some channels on your Discord server and start chatting. Your agent c - Guild channels are isolated session keys (`agent::discord:channel:`). - Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). - Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. +- Text-only cron/heartbeat announce delivery to Discord uses the final + assistant-visible answer once. Media and structured component payloads remain + multi-message when the agent emits multiple deliverable payloads. ## Forum channels diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b02e0d64bc6..91499f1a0cf 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -115,6 +115,10 @@ describe("discordPlugin outbound", () => { expect(source).not.toContain('require("./channel-actions.js")'); }); + it("prefers final assistant text for text-only cron announce delivery", () => { + expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true); + }); + it("honors per-account replyToMode overrides", () => { const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode; if (!resolveReplyToMode) { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 3ccf4632f81..5f4b3603422 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -789,6 +789,7 @@ export const discordPlugin: ChannelPlugin }, outbound: { ...discordOutbound, + preferFinalAssistantVisibleText: true, shouldTreatDeliveredTextAsVisible: shouldTreatDiscordDeliveredTextAsVisible, shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => shouldSuppressLocalDiscordExecApprovalPrompt({ diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index 9fb54c41575..9a59c793c1c 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -157,10 +157,14 @@ function resolveCoreChannelSender( function createCliDelegatingOutbound(params: { channel: CoreChannel; deliveryMode?: ChannelOutboundAdapter["deliveryMode"]; + preferFinalAssistantVisibleText?: boolean; resolveTarget?: ChannelOutboundAdapter["resolveTarget"]; }): ChannelOutboundAdapter { return { deliveryMode: params.deliveryMode ?? "direct", + ...(params.preferFinalAssistantVisibleText !== undefined + ? { preferFinalAssistantVisibleText: params.preferFinalAssistantVisibleText } + : {}), ...(params.resolveTarget ? { resolveTarget: params.resolveTarget } : {}), sendText: async ({ cfg, to, text, accountId, deps }) => withRequiredMessageId( @@ -239,7 +243,10 @@ describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { pluginId: "discord", plugin: createOutboundTestPlugin({ id: "discord", - outbound: createCliDelegatingOutbound({ channel: "discord" }), + outbound: createCliDelegatingOutbound({ + channel: "discord", + preferFinalAssistantVisibleText: true, + }), }), source: "test", }, @@ -283,6 +290,31 @@ describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { }); }); + if (testCase.channel === "discord") { + it("collapses Discord text-only announce delivery to the final assistant text", async () => { + await expectCoreChannelAnnounceDelivery({ + testCase, + payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }], + meta: { + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + finalAssistantVisibleText: "Final weather summary", + }, + }, + assertSend: (sendFn) => { + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith( + testCase.expectedTo, + "Final weather summary", + expect.any(Object), + ); + }, + }); + }); + continue; + } + it(`preserves multi-payload text-only announce delivery for ${testCase.name} even when final assistant text exists`, async () => { await expectCoreChannelAnnounceDelivery({ testCase, From f9c268cf5689b0b0e6cc07d5d8853f7f9d9d0b8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:22:20 +0100 Subject: [PATCH 17/93] ci: keep fast fixture edits on narrow path --- .github/workflows/ci.yml | 4 ++-- docs/ci.md | 2 +- scripts/ci-changed-scope.mjs | 2 +- src/scripts/ci-changed-scope.test.ts | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ec6378774e..334253daa94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -774,10 +774,10 @@ jobs: ;; contracts-plugins-ci-routing) pnpm test:contracts:plugins - pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts ;; ci-routing) - pnpm test src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts + pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts ;; *) echo "Unsupported checks-fast task: $TASK" >&2 diff --git a/docs/ci.md b/docs/ci.md index d67783bfeec..44bebde5b73 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -90,7 +90,7 @@ Jobs are ordered so cheap checks fail before expensive ones run: Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. -CI routing-only edits and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. +CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 120-second command timeout. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index c4bff734296..390b2ba6651 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -53,7 +53,7 @@ const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = /^src\/(?:channels|gateway|plugin-sd const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE = /^(src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry)\.ts$|test\/helpers\/plugins\/tts-contract-suites\.ts$|scripts\/test-projects(?:\.test-support)?\.mjs$|test\/scripts\/test-projects\.test\.ts$)/; const NODE_FAST_CI_ROUTING_SCOPE_RE = - /^(scripts\/ci-changed-scope\.mjs$|src\/scripts\/ci-changed-scope\.test\.ts$|\.github\/workflows\/ci\.yml$)/; + /^(scripts\/ci-changed-scope\.mjs$|src\/commands\/status\.scan-result\.test\.ts$|src\/scripts\/ci-changed-scope\.test\.ts$|\.github\/workflows\/ci\.yml$)/; const NODE_FAST_SCOPE_RE = new RegExp( `${NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE.source}|${NODE_FAST_CI_ROUTING_SCOPE_RE.source}`, ); diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 7834ad2a39b..c2e52a6e795 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -516,6 +516,7 @@ describe("detectChangedScope", () => { detectNodeFastScope([ ".github/workflows/ci.yml", "scripts/ci-changed-scope.mjs", + "src/commands/status.scan-result.test.ts", "src/scripts/ci-changed-scope.test.ts", "docs/ci.md", ]), From 93346b00fbfa008a2cd9c3c3945e6906cfb3cf92 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:23:32 -0700 Subject: [PATCH 18/93] docs: drop redundant body H1s that duplicated frontmatter title - concepts/streaming.md: remove '# Streaming + chunking'. - reference/session-management-compaction.md: remove Title Case H1 '# Session Management & Compaction (Deep Dive)'. - plugins/voice-call.md: remove '# Voice Call (plugin)'. CLI pages keep their command-formatted body H1s since that is the repo convention and the formatting is not expressible in frontmatter. --- docs/concepts/streaming.md | 2 -- docs/plugins/voice-call.md | 2 -- docs/reference/session-management-compaction.md | 4 +--- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index cc487bc2c1b..ed4c89edb2d 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -7,8 +7,6 @@ read_when: title: "Streaming and chunking" --- -# Streaming + chunking - OpenClaw has two separate streaming layers: - **Block streaming (channels):** emit completed **blocks** as the assistant writes. These are normal channel messages (not token deltas). diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index ad14fc1f571..e0099108a01 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -6,8 +6,6 @@ read_when: title: "Voice call plugin" --- -# Voice Call (plugin) - Voice calls for OpenClaw via a plugin. Supports outbound notifications and multi-turn conversations with inbound policies. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 7518c74832e..196942c4334 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -7,9 +7,7 @@ read_when: title: "Session management deep dive" --- -# Session Management & Compaction (Deep Dive) - -This document explains how OpenClaw manages sessions end-to-end: +This page explains how OpenClaw manages sessions end-to-end: - **Session routing** (how inbound messages map to a `sessionKey`) - **Session store** (`sessions.json`) and what it tracks From 44ad970e48580e169ee3d93fe346eb6b907d999d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:25:03 -0700 Subject: [PATCH 19/93] docs: replace generic 'this page covers' intros with direct openings Four pages started with weak meta-descriptions ('This page covers...') that restate the frontmatter summary. Replace with direct content-first openings, and sentence-case a stray 'Slash Commands' link in configuration-reference. --- docs/concepts/model-providers.md | 3 +-- docs/gateway/configuration-reference.md | 4 ++-- docs/reference/prompt-caching.md | 2 +- docs/tools/acp-agents-setup.md | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index b879b4e7a4d..bf0bebb55d9 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -6,8 +6,7 @@ read_when: title: "Model providers" --- -This page covers **LLM/model providers** (not chat channels like WhatsApp/Telegram). -For model selection rules, see [Models](/concepts/models). +Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram). For model selection rules, see [Models](/concepts/models). ## Quick rules diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b47885db151..f3cf6d92f31 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -8,7 +8,7 @@ read_when: Core config reference for `~/.openclaw/openclaw.json`. For a task-oriented overview, see [Configuration](/gateway/configuration). -This page covers the main OpenClaw config surfaces and links out when a subsystem has its own deeper reference. It does **not** try to inline every channel/plugin-owned command catalog or every deep memory/QMD knob on one page. +Covers the main OpenClaw config surfaces and links out when a subsystem has its own deeper reference. Channel- and plugin-owned command catalogs and deep memory/QMD knobs live on their own pages rather than on this one. Code truth: @@ -19,7 +19,7 @@ Code truth: Dedicated deep references: - [Memory configuration reference](/reference/memory-config) for `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming` -- [Slash Commands](/tools/slash-commands) for the current built-in + bundled command catalog +- [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog - owning channel/plugin pages for channel-specific command surfaces Config format is **JSON5** (comments + trailing commas allowed). All fields are optional — OpenClaw uses safe defaults when omitted. diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index cac3b1789d8..3c5fa801106 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -16,7 +16,7 @@ cache values still take precedence over transcript fallback values. Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. -This page covers all cache-related knobs that affect prompt reuse and token cost. +The sections below cover every cache-related knob that affects prompt reuse and token cost. Provider references: diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md index 592be83264b..fed83c9c85f 100644 --- a/docs/tools/acp-agents-setup.md +++ b/docs/tools/acp-agents-setup.md @@ -8,8 +8,8 @@ title: "ACP agents — setup" --- For the overview, operator runbook, and concepts, see [ACP agents](/tools/acp-agents). -This page covers acpx harness config, plugin setup for the MCP bridges, and -permission configuration. + +The sections below cover acpx harness config, plugin setup for the MCP bridges, and permission configuration. ## acpx harness support (current) From 57f5b3b201b4fb4e74299c6eacb0a05d0ff9eff1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:27:05 -0700 Subject: [PATCH 20/93] fix(daemon): harden launchd restart handoff (#71409) --- CHANGELOG.md | 1 + src/daemon/launchd-restart-handoff.test.ts | 5 ++++ src/daemon/launchd-restart-handoff.ts | 33 ++++++++++++++-------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf83d44dee2..f5c29a04be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. +- macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index e0ed59ec4bc..6919b6ee7bf 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -63,6 +63,11 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => { const [, args] = spawnMock.mock.calls[0] as [string, string[]]; expect(args[7]).toBe("ai.openclaw.gateway"); + expect(args[1]).toContain('if launchctl print "$service_target" >/dev/null 2>&1; then'); + expect(args[1]).toContain("reason=launchd-auto-reload"); + expect(args[1]).toContain("print_retry_count=$((print_retry_count - 1))"); + expect(args[1]).toContain("sleep 0.2"); + expect(args[1]).toContain('if launchctl bootstrap "$domain" "$plist_path"; then'); expect(args[1]).toContain('if launchctl start "$label"; then'); expect(args[1]).not.toContain('basename "$service_target"'); }); diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index ed709a13f21..942120e6413 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -22,6 +22,9 @@ export type LaunchdRestartTarget = { serviceTarget: string; }; +const START_AFTER_EXIT_PRINT_RETRY_COUNT = 15; +const START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS = 0.2; + function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) { @@ -116,28 +119,36 @@ exit "$status" `; } + const verifyLaunchdReload = `print_retry_count="${START_AFTER_EXIT_PRINT_RETRY_COUNT}" +while [ "$print_retry_count" -gt 0 ]; do + if launchctl print "$service_target" >/dev/null 2>&1; then + printf '[%s] openclaw restart done source=launchd-handoff mode=${mode} reason=launchd-auto-reload\\n' "$(date -u +%FT%TZ)" >&2 + exit 0 + fi + print_retry_count=$((print_retry_count - 1)) + sleep ${START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS} +done +`; + // Restart is explicit operator intent; undo any previous `launchctl disable`. return `service_target="$1" domain="$2" plist_path="$3" ${waitForCallerPid} +${verifyLaunchdReload} status=0 launchctl enable "$service_target" -if launchctl start "$label"; then - status=0 -else - status=$? - if launchctl bootstrap "$domain" "$plist_path"; then - if launchctl start "$label"; then - status=0 - else - launchctl kickstart -k "$service_target" - status=$? - fi +if launchctl bootstrap "$domain" "$plist_path"; then + if launchctl start "$label"; then + status=0 else launchctl kickstart -k "$service_target" status=$? fi +else + status=$? + launchctl kickstart -k "$service_target" + status=$? fi if [ "$status" -eq 0 ]; then printf '[%s] openclaw restart done source=launchd-handoff mode=${mode}\\n' "$(date -u +%FT%TZ)" >&2 From 88ea3d839bbbd8dfda9300d665b99c2364e8708c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:27:02 -0700 Subject: [PATCH 21/93] docs: sentence-case Title Case table headers in Codex/runtime docs Three table headers introduced in recent agent-runtime / Codex-harness doc commits used Title Case despite the surrounding house style: - agent-runtimes.md L17: 'What It Means' -> 'What it means' - agent-runtimes.md L100: 'Why It Matters' -> 'Why it matters' - codex-harness.md L615: 'V1 Boundary' / 'Future Path' -> 'V1 boundary' / 'Future path' (V1 stays as the recognized acronym) --- docs/concepts/agent-runtimes.md | 4 ++-- docs/plugins/codex-harness.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index f8c41ed3a8f..56e1130e796 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -14,7 +14,7 @@ the finished turn to OpenClaw. Runtimes are easy to confuse with providers because both show up near model configuration. They are different layers: -| Layer | Examples | What It Means | +| Layer | Examples | What it means | | ------------- | ------------------------------------- | ------------------------------------------------------------------- | | Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. | | Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. | @@ -97,7 +97,7 @@ routed back to PI just because defaults used `fallback: "pi"`. When a runtime is not PI, it should document what OpenClaw surfaces it supports. Use this shape for runtime docs: -| Question | Why It Matters | +| Question | Why it matters | | -------------------------------------- | ------------------------------------------------------------------------------------------------- | | Who owns the model loop? | Determines where retries, tool continuation, and final answer decisions happen. | | Who owns canonical thread history? | Determines whether OpenClaw can edit history or only mirror it. | diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index cd4883e83bf..37dd240ae2b 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -612,7 +612,7 @@ Supported in Codex runtime v1: Not supported in Codex runtime v1: -| Surface | V1 Boundary | Future Path | +| Surface | V1 boundary | Future path | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Native tool argument mutation | Codex native pre-tool hooks can block, but OpenClaw does not rewrite Codex-native tool arguments. | Requires Codex hook/schema support for replacement tool input. | | Editable Codex-native transcript history | Codex owns canonical native thread history. OpenClaw owns a mirror and can project future context, but should not mutate unsupported internals. | Add explicit Codex app-server APIs if native thread surgery is needed. | From 5b59079fd4c278b1a3eda1fa137ce5f9aeb41631 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:27:00 +0100 Subject: [PATCH 22/93] fix(tts): preserve audio-only hook transcript --- CHANGELOG.md | 1 + docs/plugins/hooks.md | 5 ++ src/auto-reply/reply-payload.ts | 5 ++ src/auto-reply/reply/commands-tts.ts | 1 + src/auto-reply/reply/dispatch-acp.ts | 1 + src/auto-reply/reply/dispatch-from-config.ts | 4 +- src/infra/outbound/deliver.test.ts | 62 ++++++++++++++++++++ src/infra/outbound/deliver.ts | 26 ++++++-- src/infra/outbound/payloads.test.ts | 36 ++++++++++++ src/infra/outbound/payloads.ts | 4 ++ 10 files changed, 138 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c29a04be0..30646cbcea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. +- TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 48a9aaa8a54..8475f42211d 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -190,6 +190,11 @@ Use message hooks for channel-level routing and delivery policy: - `message_sending`: rewrite `content` or return `{ cancel: true }`. - `message_sent`: observe final success or failure. +For audio-only TTS replies, `content` may contain the hidden spoken transcript +even when the channel payload has no visible text/caption. Rewriting that +`content` updates the hook-visible transcript only; it is not rendered as a +media caption. + Message hook contexts expose stable correlation fields when available: `ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`, `ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Prefer diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 0aacb840371..dbb34451e18 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -27,6 +27,11 @@ export type ReplyPayload = { replyToCurrent?: boolean; /** Send audio as voice message (bubble) instead of audio file. Defaults to false. */ audioAsVoice?: boolean; + /** + * Text synthesized into an audio-only TTS payload. Exposed to hooks for + * archival/search use when no visible channel text is sent. + */ + spokenText?: string; isError?: boolean; /** Marks this payload as a reasoning/thinking block. Channels that do not * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index b6567857bd1..5dba5527f82 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -168,6 +168,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand mediaUrl: result.audioPath, audioAsVoice: result.voiceCompatible === true, trustedLocalMedia: true, + spokenText: args, }; return { shouldContinue: false, reply: payload }; } diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 9db3987ddd1..b7dafe94a1f 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -213,6 +213,7 @@ async function finalizeAcpTurnOutput(params: { const delivered = await params.delivery.deliver("final", { mediaUrl: ttsSyntheticReply.mediaUrl, audioAsVoice: ttsSyntheticReply.audioAsVoice, + spokenText: accumulatedBlockText, }); queuedFinal = queuedFinal || delivered; finalMediaDelivered = delivered; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index cb49054cae3..01d3ab2bd28 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1149,10 +1149,12 @@ export async function dispatchReplyFromConfig( }); // Only send if TTS was actually applied (mediaUrl exists) if (ttsSyntheticReply.mediaUrl) { - // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content + // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content. + // Keep the spoken text only for hooks/archive consumers. const ttsOnlyPayload: ReplyPayload = { mediaUrl: ttsSyntheticReply.mediaUrl, audioAsVoice: ttsSyntheticReply.audioAsVoice, + spokenText: accumulatedBlockText, }; const result = await routeReplyToOriginating(ttsOnlyPayload); if (result) { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0110441bddc..990539e656e 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -850,6 +850,68 @@ describe("deliverOutboundPayloads", () => { ); }); + it("exposes audio-only spokenText to hooks without rendering it as media caption", async () => { + hookMocks.runner.hasHooks.mockReturnValue(true); + hookMocks.runner.runMessageSending.mockResolvedValue({ + content: "rewritten hidden transcript", + }); + const sendMedia = vi.fn(async () => ({ + channel: "matrix" as const, + messageId: "mx-voice", + roomId: "!room:example", + })); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendText: vi.fn(), + sendMedia, + }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg: { channels: { matrix: {} } } as OpenClawConfig, + channel: "matrix", + to: "room:!room:example", + payloads: [ + { + mediaUrl: "file:///tmp/clip.opus", + audioAsVoice: true, + spokenText: "original hidden transcript", + }, + ], + }); + + expect(hookMocks.runner.runMessageSending).toHaveBeenCalledWith( + expect.objectContaining({ + content: "original hidden transcript", + }), + expect.objectContaining({ channelId: "matrix" }), + ); + expect(sendMedia).toHaveBeenCalledWith( + expect.objectContaining({ + text: "", + mediaUrl: "file:///tmp/clip.opus", + audioAsVoice: true, + }), + ); + expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + content: "rewritten hidden transcript", + success: true, + }), + expect.objectContaining({ channelId: "matrix" }), + ); + }); + it("chunks plugin text and returns all results", async () => { const { sendMatrix, results } = await runChunkedMatrixDelivery(); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 7f1aeb8323e..5049d8afafe 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -620,7 +620,7 @@ async function applyMessageSendingHook(params: { const sendingResult = await params.hookRunner!.runMessageSending( { to: params.to, - content: params.payloadSummary.text, + content: params.payloadSummary.hookContent ?? params.payloadSummary.text, replyToId: params.replyToId ?? undefined, threadId: params.threadId ?? undefined, metadata: { @@ -649,6 +649,20 @@ async function applyMessageSendingHook(params: { payloadSummary: params.payloadSummary, }; } + if (params.payloadSummary.hookContent && !params.payloadSummary.text) { + const spokenText = sendingResult.content; + return { + cancelled: false, + payload: { + ...params.payload, + spokenText, + }, + payloadSummary: { + ...params.payloadSummary, + hookContent: spokenText, + }, + }; + } const payload = { ...params.payload, text: sendingResult.content, @@ -943,7 +957,7 @@ async function deliverOutboundPayloadsCore( }); emitMessageSent({ success: true, - content: payloadSummary.text, + content: payloadSummary.hookContent ?? payloadSummary.text, messageId: delivery.messageId, }); continue; @@ -977,7 +991,7 @@ async function deliverOutboundPayloadsCore( }); emitMessageSent({ success: results.length > beforeCount, - content: payloadSummary.text, + content: payloadSummary.hookContent ?? payloadSummary.text, messageId, }); continue; @@ -1017,7 +1031,7 @@ async function deliverOutboundPayloadsCore( }); emitMessageSent({ success: results.length > beforeCount, - content: payloadSummary.text, + content: payloadSummary.hookContent ?? payloadSummary.text, messageId, }); continue; @@ -1058,13 +1072,13 @@ async function deliverOutboundPayloadsCore( }); emitMessageSent({ success: true, - content: payloadSummary.text, + content: payloadSummary.hookContent ?? payloadSummary.text, messageId: lastMessageId, }); } catch (err) { emitMessageSent({ success: false, - content: payloadSummary.text, + content: payloadSummary.hookContent ?? payloadSummary.text, error: formatErrorMessage(err), }); if (!params.bestEffort) { diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 2bb1cc862ab..cb0785ef187 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -13,6 +13,7 @@ import { projectOutboundPayloadPlanForJson, projectOutboundPayloadPlanForMirror, projectOutboundPayloadPlanForOutbound, + summarizeOutboundPayloadForTransport, } from "./payloads.js"; import { registerPendingSpawnedChildrenQuery } from "./pending-spawn-query.js"; @@ -676,3 +677,38 @@ describe("formatOutboundPayloadLog", () => { ).toBe(expected); }); }); + +describe("summarizeOutboundPayloadForTransport", () => { + it("keeps visible text as channel text and does not expose hook-only content", () => { + const summary = summarizeOutboundPayloadForTransport({ + text: "visible", + spokenText: "hidden transcript", + }); + + expect(summary.text).toBe("visible"); + expect(summary.hookContent).toBeUndefined(); + }); + + it("surfaces spokenText only as hook content for audio-only payloads", () => { + const summary = summarizeOutboundPayloadForTransport({ + mediaUrl: "/tmp/reply.opus", + audioAsVoice: true, + spokenText: "Hi Ivy, good morning.", + }); + + expect(summary.text).toBe(""); + expect(summary.hookContent).toBe("Hi Ivy, good morning."); + expect(summary.mediaUrls).toEqual(["/tmp/reply.opus"]); + expect(summary.audioAsVoice).toBe(true); + }); + + it("ignores blank spokenText", () => { + const summary = summarizeOutboundPayloadForTransport({ + mediaUrl: "/tmp/reply.opus", + spokenText: " ", + }); + + expect(summary.text).toBe(""); + expect(summary.hookContent).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index fdfca6846c8..c9a0fed521b 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -31,6 +31,8 @@ export type NormalizedOutboundPayload = { delivery?: ReplyPayloadDelivery; interactive?: InteractiveReply; channelData?: Record; + /** Hook-only content for audio-only TTS payloads. Never used as channel text/caption. */ + hookContent?: string; }; export type OutboundPayloadJson = { @@ -333,6 +335,7 @@ export function summarizeOutboundPayloadForTransport( payload: ReplyPayload, ): NormalizedOutboundPayload { const parts = resolveSendableOutboundReplyParts(payload); + const spokenText = payload.spokenText?.trim() ? payload.spokenText : undefined; return { text: parts.text, mediaUrls: parts.mediaUrls, @@ -341,6 +344,7 @@ export function summarizeOutboundPayloadForTransport( delivery: payload.delivery, interactive: payload.interactive, channelData: payload.channelData, + ...(parts.text || !spokenText ? {} : { hookContent: spokenText }), }; } From d4ed19dafc217ccd24c8cb1639e96d47b8f891f4 Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Sat, 25 Apr 2026 13:31:33 +0800 Subject: [PATCH 23/93] chore(changelog): move #66884 entry to 2026.4.24 (#71410) --- CHANGELOG.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30646cbcea3..c7aeccbbdf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,6 @@ Docs: https://docs.openclaw.ai -## Unreleased - -### Changes - -### Fixes - -- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. -- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt - ## 2026.4.24 (Unreleased) ### Breaking @@ -265,6 +256,7 @@ Docs: https://docs.openclaw.ai - Memory search: apply session visibility and agent-to-agent policy to session transcript hits, and keep `corpus=sessions` ranking scoped to session collections before result limiting. (#70761) Thanks @nefainl. - Agents/sessions: stop session write-lock timeouts from entering model failover, so local lock contention surfaces directly instead of cascading across providers. (#68700) Thanks @MonkeyLeeT. - Auto-reply: run inbound reply delivery through `message_sending` hooks so plugins can transform or cancel generated replies before they are sent. (#70118) Thanks @jzakirov. +- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. ## 2026.4.23 From 0970fc5da7b6155e589db5a01a574a135559265d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:31:33 +0100 Subject: [PATCH 24/93] ci: relax bundled channel fast smoke timeout --- .github/workflows/install-smoke.yml | 3 ++- docs/ci.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 0ef1442c35d..c051e13b0a0 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -349,4 +349,5 @@ jobs: - name: Run fast bundled plugin Docker E2E env: OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local - run: timeout 120s pnpm test:docker:bundled-channel-deps:fast + OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s + run: timeout 240s pnpm test:docker:bundled-channel-deps:fast diff --git a/docs/ci.md b/docs/ci.md index 44bebde5b73..29dbf34dcc5 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -92,7 +92,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. -The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 120-second command timeout. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. +The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all lanes. From 80b6da72f5f0ac5ade8262142f816b9287fb1cfe Mon Sep 17 00:00:00 2001 From: monsonego Date: Sat, 25 Apr 2026 08:32:47 +0300 Subject: [PATCH 25/93] test(ui): cover nested qualified chat model refs (#65340) Adds regression coverage for provider-qualified nested model ids such as nvidia/deepseek-ai/deepseek-v3.2. Validated: - pnpm test ui/src/ui/chat-model-ref.test.ts ui/src/ui/chat-model-select-state.test.ts Thanks @monsonego. --- ui/src/ui/chat-model-ref.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/ui/chat-model-ref.test.ts index 42192963525..28fbcd6e184 100644 --- a/ui/src/ui/chat-model-ref.test.ts +++ b/ui/src/ui/chat-model-ref.test.ts @@ -234,6 +234,20 @@ describe("chat-model-ref helpers", () => { ); }); + it("keeps nested provider-qualified server values stable when the catalog already confirms them", () => { + const nestedModel = { + id: "deepseek-ai/deepseek-v3.2", + name: "DeepSeek V3.2", + provider: "nvidia", + }; + + expect( + resolvePreferredServerChatModelValue("nvidia/deepseek-ai/deepseek-v3.2", "nvidia", [ + nestedModel, + ]), + ).toBe("nvidia/deepseek-ai/deepseek-v3.2"); + }); + it("uses catalog resolution for provider-less raw server model values", () => { expect(resolvePreferredServerChatModelValue("gpt-5-mini", null, [OPENAI_GPT5_MINI_MODEL])).toBe( "openai/gpt-5-mini", From c2a2a481b2e15a27c8c0d290b17150d1076d78ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:35:51 +0100 Subject: [PATCH 26/93] fix(whatsapp): preserve audio-as-voice payload intent --- CHANGELOG.md | 1 + docs/channels/whatsapp.md | 1 + .../src/outbound-adapter.sendpayload.test.ts | 22 ++++++++++++ extensions/whatsapp/src/outbound-base.test.ts | 34 +++++++++++++++++++ extensions/whatsapp/src/outbound-base.ts | 3 ++ extensions/whatsapp/src/send.ts | 1 + src/plugin-sdk/reply-payload.test.ts | 21 ++++++++++++ src/plugin-sdk/reply-payload.ts | 2 ++ 8 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7aeccbbdf0..981e07ec7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. +- WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 90391d73668..6649d68f816 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -361,6 +361,7 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s - supports image, video, audio (PTT voice-note), and document payloads + - reply payloads preserve `audioAsVoice`; WhatsApp sends audio media as Baileys PTT voice notes - `audio/ogg` is rewritten to `audio/ogg; codecs=opus` for voice-note compatibility - animated GIF playback is supported via `gifPlayback: true` on video sends - captions are applied to the first media item when sending multi-media reply payloads diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index f2ebf3ae7c3..1f568237f74 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -75,6 +75,28 @@ describe("whatsappOutbound sendPayload", () => { }); }); + it("preserves audioAsVoice from payload media sends", async () => { + const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); + + await whatsappOutbound.sendPayload!({ + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: { text: "voice", mediaUrl: "/tmp/voice.ogg", audioAsVoice: true }, + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "voice", { + verbose: false, + cfg: {}, + mediaUrl: "/tmp/voice.ogg", + mediaLocalRoots: undefined, + audioAsVoice: true, + accountId: undefined, + gifPlayback: undefined, + }); + }); + it("drops blank mediaUrls before sending payload media", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); diff --git a/extensions/whatsapp/src/outbound-base.test.ts b/extensions/whatsapp/src/outbound-base.test.ts index 9e735ca394b..b38bace239b 100644 --- a/extensions/whatsapp/src/outbound-base.test.ts +++ b/extensions/whatsapp/src/outbound-base.test.ts @@ -55,6 +55,40 @@ describe("createWhatsAppOutboundBase", () => { expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" }); }); + it("forwards audioAsVoice to sendMessageWhatsApp", async () => { + const sendMessageWhatsApp = vi.fn(async () => ({ + messageId: "msg-voice", + toJid: "15551234567@s.whatsapp.net", + })); + const outbound = createWhatsAppOutboundBase({ + chunker: (text) => [text], + sendMessageWhatsApp, + sendPollWhatsApp: vi.fn(), + shouldLogVerbose: () => false, + resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }), + }); + + await outbound.sendMedia!({ + cfg: {} as never, + to: "whatsapp:+15551234567", + text: "voice", + mediaUrl: "/tmp/workspace/voice.ogg", + audioAsVoice: true, + accountId: "default", + deps: { sendWhatsApp: sendMessageWhatsApp }, + }); + + expect(sendMessageWhatsApp).toHaveBeenCalledWith( + "whatsapp:+15551234567", + "voice", + expect.objectContaining({ + mediaUrl: "/tmp/workspace/voice.ogg", + audioAsVoice: true, + accountId: "default", + }), + ); + }); + it("uses the configured default account for quote metadata lookup when accountId is omitted", async () => { cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", { participant: "111@s.whatsapp.net", diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index 5bcb0f3986c..5741afd7df9 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -31,6 +31,7 @@ type WhatsAppSendTextOptions = { mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gifPlayback?: boolean; + audioAsVoice?: boolean; accountId?: string; quotedMessageKey?: { id: string; @@ -178,6 +179,7 @@ export function createWhatsAppOutboundBase({ mediaAccess, mediaLocalRoots, mediaReadFile, + audioAsVoice, accountId, deps, gifPlayback, @@ -200,6 +202,7 @@ export function createWhatsAppOutboundBase({ mediaAccess, mediaLocalRoots, mediaReadFile, + ...(audioAsVoice === undefined ? {} : { audioAsVoice }), accountId: accountId ?? undefined, gifPlayback, quotedMessageKey, diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 1cb5100621a..6e756950f67 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -67,6 +67,7 @@ export async function sendMessageWhatsApp( mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gifPlayback?: boolean; + audioAsVoice?: boolean; accountId?: string; quotedMessageKey?: { id: string; diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index ed8a469a982..f16fab9f171 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -139,6 +139,27 @@ describe("sendTextMediaPayload", () => { expect(sendMedia.mock.calls.map((call) => call[0].replyToId)).toEqual(["reply-1", undefined]); }); + it("preserves audioAsVoice on media fallback sends", async () => { + const sendMedia = vi.fn(async ({ mediaUrl }) => ({ channel: "test", messageId: mediaUrl })); + + await sendTextMediaPayload({ + channel: "test", + ctx: { + cfg: {}, + to: "target", + text: "", + payload: { + text: "caption", + mediaUrls: ["https://example.com/voice.ogg", "https://example.com/next.ogg"], + audioAsVoice: true, + }, + }, + adapter: { sendMedia }, + }); + + expect(sendMedia.mock.calls.map((call) => call[0].audioAsVoice)).toEqual([true, true]); + }); + it("keeps explicit reply tags independent from single-use implicit reply modes", async () => { const sendText = vi.fn(async ({ text }) => ({ channel: "test", messageId: text })); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index b0b663585a6..3832ee2f6e1 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -292,6 +292,7 @@ export async function sendTextMediaPayload(params: { } const nextReplyToId = createReplyToFanout(params.ctx); if (urls.length > 0) { + const audioAsVoice = params.ctx.payload.audioAsVoice ?? params.ctx.audioAsVoice; const lastResult = await sendPayloadMediaSequence({ text, mediaUrls: urls, @@ -300,6 +301,7 @@ export async function sendTextMediaPayload(params: { ...params.ctx, text, mediaUrl, + ...(audioAsVoice === undefined ? {} : { audioAsVoice }), replyToId: nextReplyToId(), }), }); From eaf6d3c1464a76e76e6a55f38f0c68c54497aacd Mon Sep 17 00:00:00 2001 From: Ziy Date: Sat, 25 Apr 2026 13:36:11 +0800 Subject: [PATCH 27/93] fix(dashboard): keep bearer token out of runtime logs Avoid logging tokenized Control UI URLs or SSH hints while preserving clipboard/browser token handoff.\n\nThanks @Ziy1-Tan! --- CHANGELOG.md | 1 + src/commands/dashboard.links.test.ts | 69 +++++++++++++++++++++++++++- src/commands/dashboard.ts | 11 +++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 981e07ec7ca..7c70e6e312d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index c8f067da82d..61fef0ab978 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -89,6 +89,7 @@ describe("dashboardCommand", () => { customBindHost: undefined, basePath: undefined, }); + // clipboard and browser still get the full authenticated URL expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); expect(runtime.log).toHaveBeenCalledWith( @@ -96,6 +97,34 @@ describe("dashboardCommand", () => { ); }); + it("never logs the gateway token in the dashboard URL (CVE regression)", async () => { + const secretToken = "super-secret-bearer-token"; + mockSnapshot(secretToken); + copyToClipboardMock.mockResolvedValue(true); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); + openUrlMock.mockResolvedValue(true); + + await dashboardCommand(runtime); + + // Clipboard and browser should still receive the tokenized URL. + expect(copyToClipboardMock).toHaveBeenCalledWith( + `http://127.0.0.1:18789/#token=${secretToken}`, + ); + expect(openUrlMock).toHaveBeenCalledWith(`http://127.0.0.1:18789/#token=${secretToken}`); + + // The logged output must never contain the token — it flows into + // console-captured log files readable by operator.read-scoped devices. + for (const call of runtime.log.mock.calls) { + const line = String(call[0]); + expect(line).not.toContain(secretToken); + expect(line).not.toContain("#token="); + } + + // Base URL should be logged without the fragment. + expect(runtime.log).toHaveBeenCalledWith("Dashboard URL: http://127.0.0.1:18789/"); + expect(runtime.log).toHaveBeenCalledWith("Token auto-auth included in browser/clipboard URL."); + }); + it("prints SSH hint when browser cannot open", async () => { mockSnapshot("shhhh"); copyToClipboardMock.mockResolvedValue(false); @@ -111,14 +140,50 @@ describe("dashboardCommand", () => { expect(runtime.log).toHaveBeenCalledWith("ssh hint"); }); - it("respects --no-open and skips browser attempts", async () => { - mockSnapshot(); + it("never passes token to SSH hint (CVE regression — SSH path)", async () => { + const secretToken = "super-secret-bearer-token"; + mockSnapshot(secretToken); + copyToClipboardMock.mockResolvedValue(false); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: false, reason: "ssh" }); + formatControlUiSshHintMock.mockReturnValue("ssh hint without token"); + + await dashboardCommand(runtime); + + // formatControlUiSshHint must NOT receive the token — the returned + // hint string is written to runtime.log, which flows into the same + // console-captured log file readable by operator.read-scoped devices. + expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ port: 18789, basePath: undefined }); + expect(formatControlUiSshHintMock).not.toHaveBeenCalledWith( + expect.objectContaining({ token: expect.anything() }), + ); + + // Double-check: no logged line contains the secret. + for (const call of runtime.log.mock.calls) { + const line = String(call[0]); + expect(line).not.toContain(secretToken); + expect(line).not.toContain("#token="); + } + }); + + it("respects --no-open and tells user token URL is in clipboard", async () => { + mockSnapshot("abc"); copyToClipboardMock.mockResolvedValue(true); await dashboardCommand(runtime, { noOpen: true }); expect(detectBrowserOpenSupportMock).not.toHaveBeenCalled(); expect(openUrlMock).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + "Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard.", + ); + }); + + it("respects --no-open with plain URL hint when clipboard fails", async () => { + mockSnapshot("abc"); + copyToClipboardMock.mockResolvedValue(false); + + await dashboardCommand(runtime, { noOpen: true }); + expect(runtime.log).toHaveBeenCalledWith( "Browser launch disabled (--no-open). Use the URL above.", ); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 6bd5ef03a40..f261c39b347 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -46,7 +46,10 @@ export async function dashboardCommand( ? `${links.httpUrl}#token=${encodeURIComponent(token)}` : links.httpUrl; - runtime.log(`Dashboard URL: ${dashboardUrl}`); + runtime.log(`Dashboard URL: ${links.httpUrl}`); + if (includeTokenInUrl) { + runtime.log("Token auto-auth included in browser/clipboard URL."); + } if (resolvedToken.secretRefConfigured && token) { runtime.log( "Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.", @@ -73,11 +76,13 @@ export async function dashboardCommand( hint = formatControlUiSshHint({ port, basePath, - token: includeTokenInUrl ? token || undefined : undefined, }); } } else { - hint = "Browser launch disabled (--no-open). Use the URL above."; + hint = + copied && includeTokenInUrl + ? "Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard." + : "Browser launch disabled (--no-open). Use the URL above."; } if (opened) { From f2745aa03ac9620dc488dc717d160ba0c78cb6b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:19:50 +0100 Subject: [PATCH 28/93] refactor(cron): clarify ambient session context rollover --- src/cron/isolated-agent/session.test.ts | 2 +- src/cron/isolated-agent/session.ts | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index fcd8b379520..8bfc20636c9 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -400,7 +400,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.authProfileOverrideCompactionCount).toBe(3); }); - it("preserves session context for stale non-isolated rollovers", () => { + it("preserves ambient session context for non-isolated expiration rollovers", () => { const result = resolveWithStoredEntry({ entry: { sessionId: "existing-session-id-321", diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 8042309d9a5..7772a3791a3 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -9,9 +9,7 @@ import { loadSessionStore } from "../../config/sessions/store-load.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -type FreshCronSessionSanitizeMode = "isolated-force-new" | "stale-rollover"; - -const FRESH_CRON_SAFE_PREFERENCE_FIELDS = [ +const FRESH_CRON_CARRIED_PREFERENCE_FIELDS = [ "heartbeatTaskState", "chatType", "thinkingLevel", @@ -25,7 +23,7 @@ const FRESH_CRON_SAFE_PREFERENCE_FIELDS = [ "displayName", ] as const satisfies readonly (keyof SessionEntry)[]; -const STALE_SESSION_CONTEXT_PRESERVED_FIELDS = [ +const AMBIENT_SESSION_CONTEXT_FIELDS = [ "elevatedLevel", "groupActivation", "groupActivationNeedsSystemIntro", @@ -87,13 +85,13 @@ function preserveUserAuthOverride(target: SessionEntry, entry: SessionEntry): vo function sanitizeFreshCronSessionEntry( entry: SessionEntry, - mode: FreshCronSessionSanitizeMode, + options: { preserveAmbientContext: boolean }, ): SessionEntry { const next = {} as SessionEntry; - copySessionFields(next, entry, FRESH_CRON_SAFE_PREFERENCE_FIELDS); - if (mode === "stale-rollover") { - copySessionFields(next, entry, STALE_SESSION_CONTEXT_PRESERVED_FIELDS); + copySessionFields(next, entry, FRESH_CRON_CARRIED_PREFERENCE_FIELDS); + if (options.preserveAmbientContext) { + copySessionFields(next, entry, AMBIENT_SESSION_CONTEXT_FIELDS); } preserveNonAutoModelOverride(next, entry); preserveUserAuthOverride(next, entry); @@ -159,10 +157,7 @@ export function resolveCronSession(params: { const baseEntry = entry ? isNewSession - ? sanitizeFreshCronSessionEntry( - entry, - params.forceNew ? "isolated-force-new" : "stale-rollover", - ) + ? sanitizeFreshCronSessionEntry(entry, { preserveAmbientContext: !params.forceNew }) : entry : undefined; From 29f7a2f441c6464df740ccebb24bae18783eb12a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:23:15 +0100 Subject: [PATCH 29/93] docs(cron): clarify isolated session context --- docs/automation/cron-jobs.md | 13 ++++++++----- docs/cli/cron.md | 15 ++++++++++----- docs/reference/session-management-compaction.md | 8 ++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 7f549ee7e9f..92559675856 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -86,6 +86,8 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use **Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. +For isolated jobs, “fresh session” means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:` when a recurring job should deliberately build on the same conversation context. + For isolated jobs, runtime teardown now includes best-effort browser cleanup for that cron session. Cleanup failures are ignored so the actual cron result still wins. Isolated cron runs also dispose any bundled MCP runtime instances created for the job through the shared runtime-cleanup path. This matches how main-session and custom-session MCP clients are torn down, so isolated cron jobs do not leak stdio child processes or long-lived MCP connections across runs. @@ -116,7 +118,7 @@ Model-selection precedence for isolated jobs is: 1. Gmail hook model override (when the run came from Gmail and that override is allowed) 2. Per-job payload `model` -3. Stored cron session model override +3. User-selected stored cron session model override 4. Agent/default model selection Fast mode follows the resolved live selection too. If the selected model config @@ -124,10 +126,11 @@ has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction. If an isolated run hits a live model-switch handoff, cron retries with the -switched provider/model and persists that live selection before retrying. When -the switch also carries a new auth profile, cron persists that auth profile -override too. Retries are bounded: after the initial attempt plus 2 switch -retries, cron aborts instead of looping forever. +switched provider/model and persists that live selection for the active run +before retrying. When the switch also carries a new auth profile, cron persists +that auth profile override for the active run too. Retries are bounded: after +the initial attempt plus 2 switch retries, cron aborts instead of looping +forever. ## Delivery and output diff --git a/docs/cli/cron.md b/docs/cli/cron.md index d2bcb45b1d9..b2acd31321f 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -33,6 +33,11 @@ Note: `--session` supports `main`, `isolated`, `current`, and `session:`. Use `current` to bind to the active session at creation time, or `session:` for an explicit persistent session key. +Note: `--session isolated` creates a fresh transcript/session id for each run. +Safe preferences and explicit user-selected model/auth overrides can carry, but +ambient conversation context does not: channel/group routing, send/queue policy, +elevation, origin, and ACP runtime binding are reset for the new isolated run. + Note: for one-shot CLI jobs, offset-less `--at` datetimes are treated as UTC unless you also pass `--tz `, which interprets that local wall-clock time in the given timezone. @@ -59,17 +64,17 @@ model override with no explicit per-job fallback list no longer appends the agent primary as a hidden extra retry target. Note: isolated cron model precedence is Gmail-hook override first, then per-job -`--model`, then any stored cron-session model override, then the normal -agent/default selection. +`--model`, then any user-selected stored cron-session model override, then the +normal agent/default selection. Note: isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config. Note: if an isolated run throws `LiveSessionModelSwitchError`, cron persists the -switched provider/model (and switched auth profile override when present) before -retrying. The outer retry loop is bounded to 2 switch retries after the initial -attempt, then aborts instead of looping forever. +switched provider/model (and switched auth profile override when present) for +the active run before retrying. The outer retry loop is bounded to 2 switch +retries after the initial attempt, then aborts instead of looping forever. Note: failure notifications use `delivery.failureDestination` first, then global `cron.failureDestination`, and finally fall back to the job's primary diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 196942c4334..016adf2ab86 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -101,6 +101,14 @@ Isolated cron runs also create session entries/transcripts, and they have dedica - `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables). - `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl` files (defaults: `2_000_000` bytes and `2000` lines). +When cron force-creates a new isolated run session, it sanitizes the previous +`cron:` session entry before writing the new row. It carries safe +preferences such as thinking/fast/verbose settings, labels, and explicit +user-selected model/auth overrides. It drops ambient conversation context such +as channel/group routing, send or queue policy, elevation, origin, and ACP +runtime binding so a fresh isolated run cannot inherit stale delivery or +runtime authority from an older run. + --- ## Session keys (`sessionKey`) From d79b9e0af4c0cbbfdc7b4f8d79939c06d13bc138 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:38:11 +0100 Subject: [PATCH 30/93] fix(openrouter): allow DeepSeek cache-ttl eligibility --- CHANGELOG.md | 1 + docs/concepts/model-providers.md | 2 +- docs/reference/prompt-caching.md | 7 ++++++- extensions/openrouter/index.ts | 1 + src/agents/pi-embedded-runner/cache-ttl.test.ts | 3 ++- .../pi-embedded-runner/proxy-stream-wrappers.test.ts | 8 ++++++++ 6 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c70e6e312d..470055e0b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. +- Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index bf0bebb55d9..bf233135cf9 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -269,7 +269,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details. Quirks worth knowing: -- **OpenRouter** applies its app-attribution headers and Anthropic `cache_control` markers only on verified `openrouter.ai` routes. As a proxy-style OpenAI-compatible path, it skips native-OpenAI-only shaping (`serviceTier`, Responses `store`, prompt-cache hints, OpenAI reasoning-compat). Gemini-backed refs keep proxy-Gemini thought-signature sanitation only. +- **OpenRouter** applies its app-attribution headers and Anthropic `cache_control` markers only on verified `openrouter.ai` routes. DeepSeek, Moonshot, and ZAI refs are cache-TTL eligible for OpenRouter-managed prompt caching but do not receive Anthropic cache markers. As a proxy-style OpenAI-compatible path, it skips native-OpenAI-only shaping (`serviceTier`, Responses `store`, prompt-cache hints, OpenAI reasoning-compat). Gemini-backed refs keep proxy-Gemini thought-signature sanitation only. - **Kilo Gateway** Gemini-backed refs follow the same proxy-Gemini sanitation path; `kilocode/kilo/auto` and other proxy-reasoning-unsupported refs skip proxy reasoning injection. - **MiniMax** API-key onboarding writes explicit text-only M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider. - **xAI** uses the xAI Responses path. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`. diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 3c5fa801106..794a3b8e9c7 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -123,7 +123,7 @@ Per-agent heartbeat is supported at `agents.list[].heartbeat`. - Anthropic Claude model refs (`amazon-bedrock/*anthropic.claude*`) support explicit `cacheRetention` pass-through. - Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. -### OpenRouter Anthropic models +### OpenRouter models For `openrouter/anthropic/*` model refs, OpenClaw injects Anthropic `cache_control` on system/developer prompt blocks to improve prompt-cache @@ -131,6 +131,11 @@ reuse only when the request is still targeting a verified OpenRouter route (`openrouter` on its default endpoint, or any provider/base URL that resolves to `openrouter.ai`). +For `openrouter/deepseek/*`, `openrouter/moonshot*/*`, and `openrouter/zai/*` +model refs, `contextPruning.mode: "cache-ttl"` is allowed because OpenRouter +handles provider-side prompt caching automatically. OpenClaw does not inject +Anthropic `cache_control` markers into those requests. + If you repoint the model at an arbitrary OpenAI-compatible proxy URL, OpenClaw stops injecting those OpenRouter-specific Anthropic cache markers. diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index e133576e983..02cf1da3b80 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -27,6 +27,7 @@ const PROVIDER_ID = "openrouter"; const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ "anthropic/", + "deepseek/", "moonshot/", "moonshotai/", "zai/", diff --git a/src/agents/pi-embedded-runner/cache-ttl.test.ts b/src/agents/pi-embedded-runner/cache-ttl.test.ts index 7c15193a99b..262967a863f 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.test.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.test.ts @@ -16,7 +16,7 @@ vi.mock("../../plugins/provider-runtime.js", async () => { return true; } if (params.context.provider === "openrouter") { - return ["anthropic/", "moonshot/", "moonshotai/", "zai/"].some((prefix) => + return ["anthropic/", "deepseek/", "moonshot/", "moonshotai/", "zai/"].some((prefix) => params.context.modelId.startsWith(prefix), ); } @@ -44,6 +44,7 @@ describe("isCacheTtlEligibleProvider", () => { it("allows openrouter cache-ttl models", () => { expect(isCacheTtlEligibleProvider("openrouter", "anthropic/claude-sonnet-4")).toBe(true); + expect(isCacheTtlEligibleProvider("openrouter", "deepseek/deepseek-v3.2")).toBe(true); expect(isCacheTtlEligibleProvider("openrouter", "moonshotai/kimi-k2.5")).toBe(true); expect(isCacheTtlEligibleProvider("openrouter", "moonshot/kimi-k2.5")).toBe(true); expect(isCacheTtlEligibleProvider("openrouter", "zai/glm-5")).toBe(true); diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts index 7b3757d0cf8..9db00c48096 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts @@ -79,6 +79,14 @@ describe("proxy stream wrappers", () => { expect(payload.messages[0]?.content).toBe("system prompt"); }); + it("does not inject Anthropic cache_control markers for automatic OpenRouter DeepSeek cache models", () => { + const payload = runSystemCacheWrapper({ + id: "deepseek/deepseek-v3.2", + }); + + expect(payload.messages[0]?.content).toBe("system prompt"); + }); + it("injects cache_control markers for native OpenRouter hosts behind custom provider ids", () => { const payload = runSystemCacheWrapper({ provider: "custom-openrouter", From 6c1d4414d9450d52c16d773c8a235f50363a995d Mon Sep 17 00:00:00 2001 From: Sukhdeep <46626869+sukhdeepjohar@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:39:14 +0800 Subject: [PATCH 31/93] fix(browser): dedupe concurrent lazy start (#61772) (#61772) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../browser/server-context.availability.ts | 14 +++++++- ...wser-available.waits-for-cdp-ready.test.ts | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 470055e0b8c..1ed675222b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai - Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao. - Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech. - Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries. +- Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar. - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index e72cab21a3f..27bc09f17fd 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -200,7 +200,9 @@ export function createProfileAvailability({ throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError)); }; - const ensureBrowserAvailable = async (): Promise => { + let inflightEnsureBrowserAvailable: Promise | null = null; + + const ensureBrowserAvailableOnce = async (): Promise => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { @@ -305,6 +307,16 @@ export function createProfileAvailability({ } }; + const ensureBrowserAvailable = async (): Promise => { + if (inflightEnsureBrowserAvailable) { + return inflightEnsureBrowserAvailable; + } + inflightEnsureBrowserAvailable = ensureBrowserAvailableOnce().finally(() => { + inflightEnsureBrowserAvailable = null; + }); + return inflightEnsureBrowserAvailable; + }; + const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index b6d64a9d511..9a044053834 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -88,6 +88,42 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(stopOpenClawChrome).toHaveBeenCalledTimes(1); }); + it("deduplicates concurrent lazy-start calls to prevent PortInUseError", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + setupEnsureBrowserAvailableHarness(); + isChromeCdpReady.mockResolvedValue(true); + mockLaunchedChrome(launchOpenClawChrome, 456); + + const first = profile.ensureBrowserAvailable(); + const second = profile.ensureBrowserAvailable(); + await vi.advanceTimersByTimeAsync(100); + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(1); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + + it("clears the concurrent lazy-start guard after launch failure", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + setupEnsureBrowserAvailableHarness(); + isChromeCdpReady.mockResolvedValue(true); + launchOpenClawChrome.mockRejectedValueOnce( + new Error("PortInUseError: listen EADDRINUSE 127.0.0.1:18800"), + ); + + const first = profile.ensureBrowserAvailable(); + const second = profile.ensureBrowserAvailable(); + await expect(Promise.all([first, second])).rejects.toThrow("PortInUseError"); + + mockLaunchedChrome(launchOpenClawChrome, 789); + const retry = profile.ensureBrowserAvailable(); + await vi.advanceTimersByTimeAsync(100); + await expect(retry).resolves.toBeUndefined(); + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(2); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + it("reuses a pre-existing loopback browser after an initial short probe miss", async () => { const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } = setupEnsureBrowserAvailableHarness(); From f44759073b3cf2937847660e4ee12d220627b207 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:35:31 +0100 Subject: [PATCH 32/93] feat(gateway): auto-approve trusted CIDR node pairing (#61004) (thanks @sahilsatralkar) --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/channels/pairing.md | 22 +++ docs/gateway/configuration-reference.md | 10 ++ docs/gateway/pairing.md | 28 ++++ docs/gateway/security/index.md | 13 ++ docs/platforms/android.md | 19 +++ docs/platforms/ios.md | 19 +++ ....gateway-node-pairing-auto-approve.test.ts | 76 +++++++++ src/config/schema.base.generated.ts | 28 ++++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/schema.tags.ts | 1 + src/config/types.gateway.ts | 11 ++ src/config/zod-schema.ts | 6 + src/gateway/node-pairing-auto-approve.test.ts | 158 ++++++++++++++++++ src/gateway/node-pairing-auto-approve.ts | 75 +++++++++ .../server.node-pairing-auto-approve.test.ts | 125 ++++++++++++++ .../server/ws-connection/message-handler.ts | 28 +++- 19 files changed, 627 insertions(+), 3 deletions(-) create mode 100644 src/config/config.gateway-node-pairing-auto-approve.test.ts create mode 100644 src/gateway/node-pairing-auto-approve.test.ts create mode 100644 src/gateway/node-pairing-auto-approve.ts create mode 100644 src/gateway/server.node-pairing-auto-approve.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed675222b9..9caed7a24cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar. - Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. - Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 89bd8e1b70c..6e0d27250ce 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -d885c14dea2c361123a97a0f6c854f6dbae8592f39daa211173ef7f1fe7d554a config-baseline.json -c991bb527d8efffb5c9a2c5e502113260a2873923d469289c82f7029257fddaf config-baseline.core.json +3b8ff208a31b04ea61391182444bd744357577872eac279136bbc284c3dc064a config-baseline.json +4dfeadeb814fb205f5a17d797cbbe3c07685009821fe8dbf8771ea428ed5b4dd config-baseline.core.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json 0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 18bcf083e78..90e5b14f8cc 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -104,6 +104,28 @@ existing approval as-is and creates a fresh pending upgrade request. Use `openclaw devices list` to compare the currently approved access with the newly requested access before you approve. +### Optional trusted-CIDR node auto-approve + +Device pairing remains manual by default. For tightly controlled node networks, +you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24"], + }, + }, + }, +} +``` + +This only applies to fresh `role: node` pairing requests with no requested +scopes. Operator, browser, Control UI, and WebChat clients still require manual +approval. Role, scope, metadata, and public-key changes still require manual +approval. + ### Node pairing state storage Stored under `~/.openclaw/devices/`: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index f3cf6d92f31..bace1d62c56 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -297,6 +297,14 @@ See [Plugins](/tools/plugin). trustedProxies: ["10.0.0.1"], // Optional. Default false. allowRealIpFallback: false, + nodes: { + pairing: { + // Optional. Default unset/disabled. + autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"], + }, + allowCommands: ["canvas.navigate"], + denyCommands: ["system.run"], + }, tools: { // Additional /tools/invoke HTTP denies deny: ["browser"], @@ -359,6 +367,8 @@ See [Plugins](/tools/plugin). - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`. - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. +- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades. +- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and allowlist evaluation. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). - `gateway.tools.allow`: remove tool names from the default HTTP deny list. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 3981c8c192e..4029912905f 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -115,6 +115,34 @@ The macOS app can optionally attempt a **silent approval** when: If silent approval fails, it falls back to the normal “Approve/Reject” prompt. +## Trusted-CIDR device auto-approval + +WS device pairing for `role: node` remains manual by default. For private +node networks where the Gateway already trusts the network path, operators can +opt in with explicit CIDRs or exact IPs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24"], + }, + }, + }, +} +``` + +Security boundary: + +- Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset. +- No blanket LAN or private-network auto-approve mode exists. +- Only fresh `role: node` device pairing with no requested scopes is eligible. +- Operator, browser, Control UI, and WebChat clients stay manual. +- Role, scope, metadata, and public-key upgrades stay manual. +- Same-host loopback trusted-proxy header paths are not eligible because that + path can be spoofed by local callers. + ## Metadata-upgrade auto-approval When an already paired device reconnects with only non-sensitive metadata diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c85f8b94752..9a8e07813e3 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -111,6 +111,7 @@ Use this as the quick model when triaging risk: | `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" | | Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | | Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | +| `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" | ## Not vulnerabilities by design @@ -133,6 +134,12 @@ a real boundary bypass is demonstrated: approval layer for `system.run`, when the real execution boundary is still the gateway's global node command policy plus the node's own exec approvals. +- Reports that treat configured `gateway.nodes.pairing.autoApproveCidrs` as a + vulnerability by itself. This setting is disabled by default, requires + explicit CIDR/IP entries, only applies to first-time `role: node` pairing with + no requested scopes, and does not auto-approve operator/browser/Control UI, + WebChat, role upgrades, scope upgrades, metadata changes, public-key changes, + or same-host loopback trusted-proxy header paths. - "Missing per-user authorization" findings that treat `sessionKey` as an auth token. @@ -353,6 +360,12 @@ gateway: When `trustedProxies` is configured, the Gateway uses `X-Forwarded-For` to determine the client IP. `X-Real-IP` is ignored by default unless `gateway.allowRealIpFallback: true` is explicitly set. +Trusted proxy headers do not make node device pairing automatically trusted. +`gateway.nodes.pairing.autoApproveCidrs` is a separate, disabled-by-default +operator policy. Even when enabled, loopback-source trusted-proxy header paths +are excluded from node auto-approval because local callers can forge those +headers. + Good reverse proxy behavior (overwrite incoming forwarding headers): ```nginx diff --git a/docs/platforms/android.md b/docs/platforms/android.md index da5123b1367..0ed03f8ba33 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -117,6 +117,25 @@ openclaw devices reject Pairing details: [Pairing](/channels/pairing). +Optional: if the Android node always connects from a tightly controlled subnet, +you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24"], + }, + }, + }, +} +``` + +This is disabled by default. It applies only to fresh `role: node` pairing with +no requested scopes. Operator/browser pairing and any role, scope, metadata, or +public-key change still require manual approval. + ### 5) Verify the node is connected - Via nodes status: diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 4be97ddccbc..0346e5a9466 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -44,6 +44,25 @@ If the app retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created. Run `openclaw devices list` again before approval. +Optional: if the iOS node always connects from a tightly controlled subnet, you +can opt in to first-time node auto-approval with explicit CIDRs or exact IPs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24"], + }, + }, + }, +} +``` + +This is disabled by default. It applies only to fresh `role: node` pairing with +no requested scopes. Operator/browser pairing and any role, scope, metadata, or +public-key change still require manual approval. + 4. Verify connection: ```bash diff --git a/src/config/config.gateway-node-pairing-auto-approve.test.ts b/src/config/config.gateway-node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..87f98bce78f --- /dev/null +++ b/src/config/config.gateway-node-pairing-auto-approve.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("gateway node pairing auto-approve config", () => { + it("keeps CIDR auto-approval disabled when unset", () => { + const result = validateConfigObject({ + gateway: { + nodes: {}, + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.gateway?.nodes?.pairing?.autoApproveCidrs).toBeUndefined(); + } + }); + + it.each([ + { name: "IPv4 CIDR", value: ["192.168.1.0/24"] }, + { name: "IPv6 CIDR", value: ["fd00:1234:5678::/64"] }, + { name: "exact IP", value: ["192.168.1.42"] }, + { name: "empty array", value: [] }, + ])("accepts $name entries", ({ value }) => { + const result = validateConfigObject({ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: value, + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects non-array autoApproveCidrs shape", () => { + const result = validateConfigObject({ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: "192.168.1.0/24", + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect( + result.issues.some((issue) => issue.path === "gateway.nodes.pairing.autoApproveCidrs"), + ).toBe(true); + } + }); + + it("rejects non-string autoApproveCidrs entries", () => { + const result = validateConfigObject({ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24", 1234], + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect( + result.issues.some((issue) => + issue.path.startsWith("gateway.nodes.pairing.autoApproveCidrs"), + ), + ).toBe(true); + } + }); +}); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 5c40b3fc3dd..9649d1f9ff2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -22040,6 +22040,24 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, additionalProperties: false, }, + pairing: { + type: "object", + properties: { + autoApproveCidrs: { + type: "array", + items: { + type: "string", + }, + title: "Gateway Node Pairing Auto-Approve CIDRs", + description: + "Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.", + }, + }, + additionalProperties: false, + title: "Gateway Node Pairing", + description: + "Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.", + }, allowCommands: { type: "array", items: { @@ -24880,6 +24898,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Pin browser routing to a specific node id or name (optional).", tags: ["network"], }, + "gateway.nodes.pairing": { + label: "Gateway Node Pairing", + help: "Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.", + tags: ["network"], + }, + "gateway.nodes.pairing.autoApproveCidrs": { + label: "Gateway Node Pairing Auto-Approve CIDRs", + help: "Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.", + tags: ["security", "access", "network", "advanced"], + }, "gateway.nodes.allowCommands": { label: "Gateway Node Allowlist (Extra Commands)", help: "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 0b4ecdd18d0..d632ab41bc4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -468,6 +468,10 @@ export const FIELD_HELP: Record = { "gateway.nodes.browser.mode": 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", + "gateway.nodes.pairing": + "Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.", + "gateway.nodes.pairing.autoApproveCidrs": + "Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.", "gateway.nodes.allowCommands": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "gateway.nodes.denyCommands": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c16dcedc354..694e2dffa01 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -321,6 +321,8 @@ export const FIELD_LABELS: Record = { "gateway.reload.deferralTimeoutMs": "Restart Deferral Timeout (ms)", "gateway.nodes.browser.mode": "Gateway Node Browser Mode", "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.pairing": "Gateway Node Pairing", + "gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.denyCommands": "Gateway Node Denylist", "gateway.webchat.chatHistoryMaxChars": "WebChat History Max Chars", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 2d93df16bc1..314bc8ee7c3 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -53,6 +53,7 @@ const TAG_OVERRIDES: Record = { ], "gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"], "gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"], + "gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"], "tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"], }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index bfcca78b2bb..f767d1edb5e 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -363,6 +363,15 @@ export type GatewayPushConfig = { apns?: GatewayPushApnsConfig; }; +export type GatewayNodePairingConfig = { + /** + * Opt-in CIDR/IP allowlist for auto-approving first-time node-role pairing. + * Only applies to fresh node pairing requests with no requested scopes. + * Default: unset/disabled. + */ + autoApproveCidrs?: string[]; +}; + export type GatewayNodesConfig = { /** Browser routing policy for node-hosted browser proxies. */ browser?: { @@ -371,6 +380,8 @@ export type GatewayNodesConfig = { /** Pin to a specific node id/name (optional). */ node?: string; }; + /** Pairing policy for node-role gateway clients. */ + pairing?: GatewayNodePairingConfig; /** Additional node.invoke commands to allow on the gateway. */ allowCommands?: string[]; /** Commands to deny even if they appear in the defaults or node claims. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d183d02cff3..2cb1d00fd17 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -895,6 +895,12 @@ export const OpenClawSchema = z }) .strict() .optional(), + pairing: z + .object({ + autoApproveCidrs: z.array(z.string()).optional(), + }) + .strict() + .optional(), allowCommands: z.array(z.string()).optional(), denyCommands: z.array(z.string()).optional(), }) diff --git a/src/gateway/node-pairing-auto-approve.test.ts b/src/gateway/node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..9e8353b336e --- /dev/null +++ b/src/gateway/node-pairing-auto-approve.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + resolveNodePairingClientIpSource, + shouldAutoApproveNodePairingFromTrustedCidrs, + type NodePairingAutoApproveReason, +} from "./node-pairing-auto-approve.js"; + +const BASE_PARAMS = { + existingPairedDevice: false, + role: "node", + reason: "not-paired" as NodePairingAutoApproveReason, + scopes: [], + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reportedClientIpSource: "direct" as const, + reportedClientIp: "192.168.1.42", + autoApproveCidrs: ["192.168.1.0/24"], +}; + +describe("resolveNodePairingClientIpSource", () => { + it.each([ + { + name: "direct address", + params: { + reportedClientIp: "192.168.1.42", + hasProxyHeaders: false, + remoteIsTrustedProxy: false, + remoteIsLoopback: false, + }, + expected: "direct", + }, + { + name: "trusted proxy", + params: { + reportedClientIp: "192.168.1.42", + hasProxyHeaders: true, + remoteIsTrustedProxy: true, + remoteIsLoopback: false, + }, + expected: "trusted-proxy", + }, + { + name: "loopback trusted proxy", + params: { + reportedClientIp: "192.168.1.42", + hasProxyHeaders: true, + remoteIsTrustedProxy: true, + remoteIsLoopback: true, + }, + expected: "loopback-trusted-proxy", + }, + { + name: "no reported client IP", + params: { + reportedClientIp: undefined, + hasProxyHeaders: true, + remoteIsTrustedProxy: true, + remoteIsLoopback: false, + }, + expected: "none", + }, + ] as const)("$name", ({ params, expected }) => { + expect(resolveNodePairingClientIpSource(params)).toBe(expected); + }); +}); + +describe("shouldAutoApproveNodePairingFromTrustedCidrs", () => { + it("is disabled by default when no CIDRs are configured", () => { + expect( + shouldAutoApproveNodePairingFromTrustedCidrs({ + ...BASE_PARAMS, + autoApproveCidrs: undefined, + }), + ).toBe(false); + }); + + it("accepts first-time node pairing from a matching direct IPv4 CIDR", () => { + expect(shouldAutoApproveNodePairingFromTrustedCidrs(BASE_PARAMS)).toBe(true); + }); + + it("accepts first-time node pairing from an exact IP entry", () => { + expect( + shouldAutoApproveNodePairingFromTrustedCidrs({ + ...BASE_PARAMS, + autoApproveCidrs: ["192.168.1.42"], + }), + ).toBe(true); + }); + + it("accepts first-time node pairing from a matching IPv6 CIDR via non-loopback trusted proxy", () => { + expect( + shouldAutoApproveNodePairingFromTrustedCidrs({ + ...BASE_PARAMS, + reportedClientIpSource: "trusted-proxy", + reportedClientIp: "fd00:1234:5678::9", + autoApproveCidrs: ["fd00:1234:5678::/64"], + }), + ).toBe(true); + }); + + it.each([ + { + name: "existing paired device", + patch: { existingPairedDevice: true }, + }, + { + name: "operator role", + patch: { role: "operator" }, + }, + { + name: "non-matching CIDR", + patch: { reportedClientIp: "192.168.2.42" }, + }, + { + name: "requested scopes", + patch: { scopes: ["operator.read"] }, + }, + { + name: "browser origin", + patch: { hasBrowserOriginHeader: true }, + }, + { + name: "Control UI client", + patch: { isControlUi: true }, + }, + { + name: "WebChat client", + patch: { isWebchat: true }, + }, + { + name: "loopback trusted proxy", + patch: { reportedClientIpSource: "loopback-trusted-proxy" as const }, + }, + { + name: "missing reported client IP", + patch: { reportedClientIpSource: "none" as const, reportedClientIp: undefined }, + }, + { + name: "invalid CIDR config", + patch: { autoApproveCidrs: ["invalid/24"] }, + }, + ])("rejects $name", ({ patch }) => { + expect(shouldAutoApproveNodePairingFromTrustedCidrs({ ...BASE_PARAMS, ...patch })).toBe(false); + }); + + it.each(["role-upgrade", "scope-upgrade", "metadata-upgrade"] as const)( + "rejects %s requests", + (reason) => { + expect( + shouldAutoApproveNodePairingFromTrustedCidrs({ + ...BASE_PARAMS, + reason, + }), + ).toBe(false); + }, + ); +}); diff --git a/src/gateway/node-pairing-auto-approve.ts b/src/gateway/node-pairing-auto-approve.ts new file mode 100644 index 00000000000..6f3fa1f0ede --- /dev/null +++ b/src/gateway/node-pairing-auto-approve.ts @@ -0,0 +1,75 @@ +import { isTrustedProxyAddress } from "./net.js"; + +export type NodePairingAutoApproveReason = + | "not-paired" + | "role-upgrade" + | "scope-upgrade" + | "metadata-upgrade"; + +export type NodePairingAutoApproveClientIpSource = + | "direct" + | "trusted-proxy" + | "loopback-trusted-proxy" + | "none"; + +export function resolveNodePairingClientIpSource(params: { + reportedClientIp?: string; + hasProxyHeaders: boolean; + remoteIsTrustedProxy: boolean; + remoteIsLoopback: boolean; +}): NodePairingAutoApproveClientIpSource { + if (!params.reportedClientIp) { + return "none"; + } + if (!params.hasProxyHeaders || !params.remoteIsTrustedProxy) { + return "direct"; + } + return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy"; +} + +export function shouldAutoApproveNodePairingFromTrustedCidrs(params: { + existingPairedDevice: boolean; + role: string; + reason: NodePairingAutoApproveReason; + scopes: readonly string[]; + hasBrowserOriginHeader: boolean; + isControlUi: boolean; + isWebchat: boolean; + reportedClientIpSource: NodePairingAutoApproveClientIpSource; + reportedClientIp?: string; + autoApproveCidrs?: readonly string[]; +}): boolean { + if (params.existingPairedDevice) { + return false; + } + if (params.role !== "node") { + return false; + } + if (params.reason !== "not-paired") { + return false; + } + if (params.scopes.length > 0) { + return false; + } + if (params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) { + return false; + } + if ( + params.reportedClientIpSource === "none" || + params.reportedClientIpSource === "loopback-trusted-proxy" + ) { + return false; + } + if (!params.reportedClientIp) { + return false; + } + + const autoApproveCidrs = params.autoApproveCidrs + ?.map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (!autoApproveCidrs || autoApproveCidrs.length === 0) { + return false; + } + + return isTrustedProxyAddress(params.reportedClientIp, autoApproveCidrs); +} diff --git a/src/gateway/server.node-pairing-auto-approve.test.ts b/src/gateway/server.node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..22c11248af8 --- /dev/null +++ b/src/gateway/server.node-pairing-auto-approve.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { writeConfigFile } from "../config/config.js"; +import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { loadDeviceIdentity } from "./device-authz.test-helpers.js"; +import { pickPrimaryLanIPv4 } from "./net.js"; +import { + connectReq, + installGatewayTestHooks, + startServer, + trackConnectChallengeNonce, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const TOKEN = "secret"; +const NODE_CLIENT = { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, +}; + +async function openLanGatewayWs(params: { host: string; port: number }): Promise { + const ws = new WebSocket(`ws://${params.host}:${params.port}`); + trackConnectChallengeNonce(ws); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + ws.once("open", onOpen); + ws.once("error", onError); + }); + return ws; +} + +describe("gateway trusted CIDR node pairing auto-approve", () => { + test("stays disabled by default for a direct non-loopback node", async () => { + const lanIp = pickPrimaryLanIPv4(); + if (!lanIp) { + return; + } + const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false }); + let ws: WebSocket | undefined; + try { + const loaded = loadDeviceIdentity("trusted-cidr-default-off"); + ws = await openLanGatewayWs({ host: lanIp, port: started.port }); + const res = await connectReq(ws, { + token: TOKEN, + role: "node", + scopes: [], + client: NODE_CLIENT, + deviceIdentityPath: loaded.identityPath, + }); + + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === loaded.identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.silent).toBe(false); + expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull(); + } finally { + ws?.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("auto-approves first-time node pairing from a matching direct non-loopback CIDR", async () => { + const lanIp = pickPrimaryLanIPv4(); + if (!lanIp) { + return; + } + await writeConfigFile({ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: [`${lanIp}/32`], + }, + }, + }, + }); + const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false }); + let ws: WebSocket | undefined; + try { + const loaded = loadDeviceIdentity("trusted-cidr-direct-lan-auto-approve"); + ws = await openLanGatewayWs({ host: lanIp, port: started.port }); + const res = await connectReq(ws, { + token: TOKEN, + role: "node", + scopes: [], + client: NODE_CLIENT, + deviceIdentityPath: loaded.identityPath, + }); + + expect(res.ok).toBe(true); + expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === loaded.identity.deviceId, + ); + expect(pending).toHaveLength(0); + const paired = await getPairedDevice(loaded.identity.deviceId); + expect(paired?.role).toBe("node"); + expect(paired?.approvedScopes ?? []).toEqual([]); + } finally { + ws?.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a9733b6b624..11b8e10ab28 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -67,6 +67,10 @@ import { resolveClientIp, } from "../../net.js"; import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js"; +import { + resolveNodePairingClientIpSource, + shouldAutoApproveNodePairingFromTrustedCidrs, +} from "../../node-pairing-auto-approve.js"; import { checkBrowserOrigin } from "../../origin-check.js"; import { buildPairingConnectCloseReason, @@ -279,6 +283,12 @@ export function attachGatewayWsMessageHandler(params: { : clientIp && !isLoopbackAddress(clientIp) ? clientIp : undefined; + const reportedClientIpSource = resolveNodePairingClientIpSource({ + reportedClientIp, + hasProxyHeaders, + remoteIsTrustedProxy, + remoteIsLoopback: isLoopbackAddress(remoteAddr), + }); if (hasUntrustedProxyHeaders) { logWsControl.warn( @@ -923,6 +933,20 @@ export function attachGatewayWsMessageHandler(params: { isWebchat, reason, }); + const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs( + { + existingPairedDevice: Boolean(existingPairedDevice), + role, + reason, + scopes, + hasBrowserOriginHeader, + isControlUi, + isWebchat, + reportedClientIpSource, + reportedClientIp, + autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs, + }, + ); const allowSilentBootstrapPairing = authMethod === "bootstrap-token" && reason === "not-paired" && @@ -944,7 +968,9 @@ export function attachGatewayWsMessageHandler(params: { silent: reason === "scope-upgrade" ? false - : allowSilentLocalPairing || allowSilentBootstrapPairing, + : allowSilentLocalPairing || + allowSilentBootstrapPairing || + allowSilentTrustedCidrsNodePairing, }); const context = buildRequestContext(); let approved: Awaited> | undefined; From 537a8e25ed5dbfff1ea3802435b3a7a019c4f2c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:45:54 +0100 Subject: [PATCH 33/93] fix(signal): classify filename-only voice notes --- CHANGELOG.md | 1 + docs/channels/signal.md | 1 + extensions/signal/src/monitor.ts | 13 ++++++-- .../event-handler.inbound-context.test.ts | 31 +++++++++++++++++++ src/media/mime.test.ts | 5 +++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9caed7a24cd..2d3de6017c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. diff --git a/docs/channels/signal.md b/docs/channels/signal.md index eedd78154a5..39346f43e25 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -208,6 +208,7 @@ Groups: - Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). - Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Attachments supported (base64 fetched from `signal-cli`). +- Voice-note attachments use the `signal-cli` filename as a MIME fallback when `contentType` is missing, so audio transcription can still classify AAC voice memos. - Default media cap: `channels.signal.mediaMaxMb` (default 8). - Use `channels.signal.ignoreAttachments` to skip downloading media. - Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 3ef8b0797f1..8a3badcedc6 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -7,7 +7,11 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/config-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; -import { estimateBase64DecodedBytes, saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { + detectMime, + estimateBase64DecodedBytes, + saveMediaBuffer, +} from "openclaw/plugin-sdk/media-runtime"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { deliverTextOrMediaReply, @@ -294,11 +298,16 @@ async function fetchAttachment(params: { ); } const buffer = Buffer.from(result.data, "base64"); + const originalFilename = normalizeOptionalString(attachment.filename ?? undefined); + const contentType = + normalizeOptionalString(attachment.contentType ?? undefined) ?? + (await detectMime({ buffer, filePath: originalFilename })); const saved = await saveMediaBuffer( buffer, - attachment.contentType ?? undefined, + contentType, "inbound", params.maxBytes, + originalFilename, ); return { path: saved.path, contentType: saved.contentType }; } diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index 8e77a0620d6..6df339b5a76 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -295,6 +295,37 @@ describe("signal createSignalEventHandler inbound context", () => { expect(capture.ctx?.MediaTypes).toEqual(["image/jpeg", "application/octet-stream"]); }); + it("threads resolved audio contentType for Signal voice attachments", async () => { + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + cfg: { + messages: { inbound: { debounceMs: 0 } }, + channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } }, + }, + ignoreAttachments: false, + fetchAttachment: async ({ attachment }) => ({ + path: `/tmp/${String(attachment.id)}.aac`, + contentType: "audio/aac", + }), + historyLimit: 0, + }), + ); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + message: "", + attachments: [{ id: "voice1", contentType: undefined, filename: "voice.aac" }], + }, + }), + ); + + expect(capture.ctx).toBeTruthy(); + expect(capture.ctx?.MediaPath).toBe("/tmp/voice1.aac"); + expect(capture.ctx?.MediaType).toBe("audio/aac"); + expect(capture.ctx?.MediaTypes).toEqual(["audio/aac"]); + }); + it("drops own UUID inbound messages when only accountUuid is configured", async () => { const ownUuid = "123e4567-e89b-12d3-a456-426614174000"; const handler = createSignalEventHandler( diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index 68da516c23d..8f1fa91a63f 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -110,6 +110,11 @@ describe("mime detection", () => { const mime = await detectMime({ filePath: "/tmp/style.css" }); expect(mime).toBe("text/css"); }); + + it("detects AAC from a bare filename when buffer sniffing is inconclusive", async () => { + const mime = await detectMime({ buffer: Buffer.alloc(16), filePath: "voice.aac" }); + expect(mime).toBe("audio/aac"); + }); }); describe("extensionForMime", () => { From 2cd2732ab6695a4b3220ce41166b74aca7097c5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:46:04 +0100 Subject: [PATCH 34/93] docs: document trusted CIDR node auto-approval --- docs/cli/devices.md | 8 ++++++++ docs/cli/node.md | 19 +++++++++++++++++++ docs/cli/nodes.md | 3 +++ docs/gateway/configuration-examples.md | 22 ++++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 7a509cef493..03c90af1748 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -66,6 +66,12 @@ request. Review the `Requested` vs `Approved` columns in `openclaw devices list` or use `openclaw devices approve --latest` to preview the exact upgrade before approving it. +If the Gateway is explicitly configured with +`gateway.nodes.pairing.autoApproveCidrs`, first-time `role: node` requests from +matching client IPs can be approved before they appear in this list. That policy +is disabled by default and never applies to operator/browser clients or upgrade +requests. + ``` openclaw devices approve openclaw devices approve @@ -127,6 +133,8 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er - Token rotation returns a new token (sensitive). Treat it like a secret. - These commands require `operator.pairing` (or `operator.admin`) scope. +- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for + fresh node device pairing only; it does not change CLI approval authority. - Token rotation stays inside the approved pairing role set and approved scope baseline for that device. A stray cached token entry does not grant a new rotate target. diff --git a/docs/cli/node.md b/docs/cli/node.md index b15b8dfb131..b0cf80e2d96 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -123,6 +123,25 @@ openclaw devices list openclaw devices approve ``` +On tightly controlled node networks, the Gateway operator can explicitly opt in +to auto-approving first-time node pairing from trusted CIDRs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24"], + }, + }, + }, +} +``` + +This is disabled by default. It only applies to fresh `role: node` pairing with +no requested scopes. Operator/browser clients, Control UI, WebChat, and role, +scope, metadata, or public-key upgrades still require manual approval. + If the node retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created. Run `openclaw devices list` again before approval. diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index df23c6ffc2e..6c46ef19cdf 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -42,6 +42,9 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`). Approval note: - `openclaw nodes pending` only needs pairing scope. +- `gateway.nodes.pairing.autoApproveCidrs` can skip the pending step only for + explicitly trusted, first-time `role: node` device pairing. It is off by + default and does not approve upgrades. - `openclaw nodes approve ` inherits extra scope requirements from the pending request: - commandless request: pairing only diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 4899b3fe521..9d31945e0ab 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -501,6 +501,28 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. } ``` +### Trusted node network auto-approval + +Keep device pairing manual unless you control the network path. For a dedicated +lab or tailnet subnet, you can opt in to first-time node device auto-approval +with exact CIDRs or IPs: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"], + }, + }, + }, +} +``` + +This remains off when unset. It only applies to fresh `role: node` pairing with +no requested scopes. Operator/browser clients and role, scope, metadata, or +public-key upgrades still require manual approval. + ### Secure DM mode (shared inbox / multi-user DMs) If more than one person can DM your bot (multiple entries in `allowFrom`, pairing approvals for multiple people, or `dmPolicy: "open"`), enable **secure DM mode** so DMs from different senders don’t share one context by default: From cfb551c7091d409f38dd447e9d649acd41803aee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:47:34 +0100 Subject: [PATCH 35/93] test(openrouter): cover DeepSeek live cache hits --- docs/reference/prompt-caching.md | 5 ++ extensions/openrouter/openrouter.live.test.ts | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 794a3b8e9c7..eb15d11bcb4 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -136,6 +136,11 @@ model refs, `contextPruning.mode: "cache-ttl"` is allowed because OpenRouter handles provider-side prompt caching automatically. OpenClaw does not inject Anthropic `cache_control` markers into those requests. +DeepSeek cache construction is best-effort and can take a few seconds. An +immediate follow-up may still show `cached_tokens: 0`; verify with a repeated +same-prefix request after a short delay and use `usage.prompt_tokens_details.cached_tokens` +as the cache-hit signal. + If you repoint the model at an arbitrary OpenAI-compatible proxy URL, OpenClaw stops injecting those OpenRouter-specific Anthropic cache markers. diff --git a/extensions/openrouter/openrouter.live.test.ts b/extensions/openrouter/openrouter.live.test.ts index f3f92bdb1a3..1bafd08c37b 100644 --- a/extensions/openrouter/openrouter.live.test.ts +++ b/extensions/openrouter/openrouter.live.test.ts @@ -10,8 +10,12 @@ import plugin from "./index.js"; const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? ""; const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano"; +const LIVE_CACHE_MODEL_ID = + process.env.OPENCLAW_LIVE_OPENROUTER_CACHE_MODEL?.trim() || "deepseek/deepseek-v3.2"; const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; +const describeCacheLive = + liveEnabled && process.env.OPENCLAW_LIVE_CACHE_TEST === "1" ? describe : describe.skip; const ModelRegistryCtor = ModelRegistry as unknown as { new (authStorage: AuthStorage, modelsJsonPath?: string): ModelRegistry; }; @@ -23,6 +27,28 @@ const registerOpenRouterPlugin = async () => name: "OpenRouter Provider", }); +function buildStableCachePrefix(): string { + return Array.from( + { length: 700 }, + (_, index) => + `Stable OpenRouter cache probe sentence ${ + index % 20 + }: this prefix must stay byte-identical across repeated requests.`, + ).join("\n"); +} + +async function completeOpenRouterChat(params: { + client: OpenAI; + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + model: string; +}) { + return params.client.chat.completions.create({ + model: params.model, + messages: params.messages, + max_tokens: 8, + }); +} + describeLive("openrouter plugin live", () => { it("registers an OpenRouter provider that can complete a live request", async () => { const { providers } = await registerOpenRouterPlugin(); @@ -57,3 +83,38 @@ describeLive("openrouter plugin live", () => { expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/); }, 30_000); }); + +describeCacheLive("openrouter plugin live cache", () => { + it("observes automatic cache reads for DeepSeek model refs after cache construction", async () => { + const { providers } = await registerOpenRouterPlugin(); + const provider = requireRegisteredProvider(providers, "openrouter"); + const resolved = provider.resolveDynamicModel?.({ + provider: "openrouter", + modelId: LIVE_CACHE_MODEL_ID, + modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()), + }); + if (!resolved) { + throw new Error(`openrouter provider did not resolve ${LIVE_CACHE_MODEL_ID}`); + } + + const client = new OpenAI({ + apiKey: OPENROUTER_API_KEY, + baseURL: resolved.baseUrl, + }); + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: "system", + content: `You are testing prompt caching.\n${buildStableCachePrefix()}`, + }, + { role: "user", content: "Reply with exactly OK." }, + ]; + + await completeOpenRouterChat({ client, model: resolved.id, messages }); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const cached = await completeOpenRouterChat({ client, model: resolved.id, messages }); + + const cachedTokens = cached.usage?.prompt_tokens_details?.cached_tokens ?? 0; + expect(cached.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/); + expect(cachedTokens).toBeGreaterThan(1024); + }, 60_000); +}); From 47a4124dc327577a4d8821c05ecb557375f9b925 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:54:12 -0700 Subject: [PATCH 36/93] fix(daemon): tolerate loaded launchctl bootstrap (#71413) --- CHANGELOG.md | 1 + src/infra/restart.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ src/infra/restart.ts | 10 +++++-- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3de6017c4..943dc80d273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. +- macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. - WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index 0c05b20f002..9455b365084 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureFullEnv } from "../test-utils/env.js"; const spawnSyncMock = vi.hoisted(() => vi.fn()); const resolveLsofCommandSyncMock = vi.hoisted(() => vi.fn()); @@ -25,12 +26,16 @@ vi.mock("../config/paths.js", async () => { let __testing: typeof import("./restart-stale-pids.js").__testing; let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; +let triggerOpenClawRestart: typeof import("./restart.js").triggerOpenClawRestart; let currentTimeMs = 0; +const envSnapshot = captureFullEnv(); +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); beforeAll(async () => { ({ __testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = await import("./restart-stale-pids.js")); + ({ triggerOpenClawRestart } = await import("./restart.js")); }); beforeEach(() => { @@ -48,11 +53,25 @@ beforeEach(() => { }); afterEach(() => { + envSnapshot.restore(); __testing.setSleepSyncOverride(null); __testing.setDateNowOverride(null); + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } vi.restoreAllMocks(); }); +function setPlatform(platform: NodeJS.Platform): void { + if (!originalPlatformDescriptor) { + return; + } + Object.defineProperty(process, "platform", { + ...originalPlatformDescriptor, + value: platform, + }); +} + describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => { it("parses lsof output and filters non-openclaw/current processes", () => { const gatewayPidA = process.pid + 1000; @@ -164,3 +183,41 @@ describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", ( expect(killSpy).not.toHaveBeenCalled(); }); }); + +describe("triggerOpenClawRestart", () => { + it("continues when launchctl bootstrap reports the service is already loaded", () => { + setPlatform("darwin"); + delete process.env.VITEST; + delete process.env.NODE_ENV; + process.env.HOME = "/Users/test"; + process.env.OPENCLAW_PROFILE = "default"; + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === "/usr/sbin/lsof") { + return { error: undefined, status: 1, stdout: "" }; + } + if (command === "launchctl" && args[0] === "kickstart" && args[1] === "-k") { + return { error: undefined, status: 113, stderr: "service not loaded" }; + } + if (command === "launchctl" && args[0] === "bootstrap") { + return { error: undefined, status: 37, stderr: "Operation already in progress" }; + } + if (command === "launchctl" && args[0] === "kickstart") { + return { error: undefined, status: 0, stdout: "" }; + } + return { error: undefined, status: 1, stdout: "" }; + }); + + const result = triggerOpenClawRestart(); + + expect(result).toEqual({ + ok: true, + method: "launchctl", + tried: [ + `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`, + `launchctl bootstrap gui/${uid} /Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist`, + `launchctl kickstart gui/${uid}/ai.openclaw.gateway`, + ], + }); + }); +}); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 34e3e969ce5..fa80eb211e0 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -20,6 +20,7 @@ const DEFAULT_DEFERRAL_POLL_MS = 500; // Configurable via gateway.reload.deferralTimeoutMs. const DEFAULT_DEFERRAL_MAX_WAIT_MS = 300_000; const RESTART_COOLDOWN_MS = 30_000; +const LAUNCHCTL_ALREADY_LOADED_EXIT_CODE = 37; const restartLog = createSubsystemLogger("restart"); @@ -427,7 +428,12 @@ export function triggerOpenClawRestart(): RestartAttempt { encoding: "utf8", timeout: SPAWN_TIMEOUT_MS, }); - if (boot.error || (boot.status !== 0 && boot.status !== null)) { + if ( + boot.error || + (boot.status !== 0 && + boot.status !== LAUNCHCTL_ALREADY_LOADED_EXIT_CODE && + boot.status !== null) + ) { return { ok: false, method: "launchctl", @@ -435,7 +441,7 @@ export function triggerOpenClawRestart(): RestartAttempt { tried, }; } - const retryArgs = ["kickstart", "-k", target]; + const retryArgs = ["kickstart", target]; tried.push(`launchctl ${retryArgs.join(" ")}`); const retry = spawnSync("launchctl", retryArgs, { encoding: "utf8", From 18ffa81564f957ebe04a4a7136aa31d2543b0542 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:55:42 +0100 Subject: [PATCH 37/93] fix(agents): suppress delivered messaging fallback --- CHANGELOG.md | 1 + .../run.incomplete-turn.test.ts | 27 +++++++++++++++++++ .../pi-embedded-runner/run/incomplete-turn.ts | 18 ++++++++----- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 943dc80d273..244db768b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. +- Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already delivered through the messaging tool on clean non-error terminal turns. (#70623) Thanks @chinar-amrutkar. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. 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 7cbf577b7c3..f308e388780 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -822,6 +822,33 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); + it("suppresses the incomplete-turn warning when a messaging tool delivered before end_turn", () => { + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "google", + model: "gemini-2.5-pro", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_messaging_end_turn", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toBeNull(); + }); + it("still surfaces the incomplete-turn warning after a messaging tool when the provider signalled an error", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 0e8dba07f82..1050a5bfa1c 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -191,13 +191,17 @@ export function resolveIncompleteTurnPayloadText(params: { const stopReason = params.attempt.lastAssistant?.stopReason; // If the assistant already delivered user-visible content via a messaging - // tool during this turn and ended cleanly (stopReason=stop), do not surface - // an incomplete-turn warning. The user has received the reply; a follow-up - // "couldn't generate a response" bubble is a false positive. Provider-side - // failures (stopReason=error, toolUse interruption) still fall through to - // the normal incomplete-turn paths below; tool-error cases are already - // handled by the lastToolError early return above. - if (params.attempt.didSendViaMessagingTool && stopReason === "stop") { + // tool during this turn and did not end in a hard error/interrupted tool-use + // state, do not surface an incomplete-turn warning. The user has received the + // reply; a follow-up "couldn't generate a response" bubble is a false positive. + // Provider-side failures and interrupted tool-use still fall through to the + // normal incomplete-turn paths below; tool-error cases are already handled by + // the lastToolError early return above. + if ( + params.attempt.didSendViaMessagingTool && + stopReason !== "error" && + stopReason !== "toolUse" + ) { return null; } const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({ From cc0992564bc565791810e6a560d12b430a8544ee Mon Sep 17 00:00:00 2001 From: xDarkicex <0509479@my.scccd.edu> Date: Sat, 11 Apr 2026 12:32:07 -0700 Subject: [PATCH 38/93] fix: add contextInjection never mode (#65006) (thanks @xDarkicex) --- CHANGELOG.md | 1 + .../compact.hooks.harness.ts | 1 + src/agents/pi-embedded-runner/compact.ts | 30 +++++++++++-------- .../run/attempt.context-engine-helpers.ts | 26 ++++++++-------- ....spawn-workspace.context-injection.test.ts | 18 ++++++++++- src/config/schema.base.generated.ts | 4 +++ src/config/types.agent-defaults.ts | 2 +- src/config/zod-schema.agent-defaults.test.ts | 7 ++++- src/config/zod-schema.agent-defaults.ts | 4 ++- 9 files changed, 65 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244db768b1c..0af3f8ac908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default. Thanks @lsdsjy. - CLI/Gateway: make `gateway status` start faster by skipping plugin loading on the read-only status path. (#71364) Thanks @andyylin. - Plugins/compatibility: add a central plugin compatibility registry and docs for SDK/config/setup/runtime deprecation records, including dated migration metadata for legacy harness naming and other plugin-facing aliases. Thanks @vincentkoc. +- Agents/bootstrap: add `agents.defaults.contextInjection: "never"` to disable workspace bootstrap file injection for agents that fully own their prompt lifecycle. (#65006) Thanks @xDarkicex. ### Fixes diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 986784225ab..9b63537f17a 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -339,6 +339,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../bootstrap-files.js", () => ({ makeBootstrapWarn: vi.fn(() => () => {}), + resolveContextInjectionMode: vi.fn(() => "always"), resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), })); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 703a72a7dfe..169b6e1e204 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -37,7 +37,11 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; -import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; +import { + makeBootstrapWarn, + resolveBootstrapContextForRun, + resolveContextInjectionMode, +} from "../bootstrap-files.js"; import { listChannelSupportedActions, resolveChannelMessageToolCapabilities, @@ -471,17 +475,19 @@ export async function compactEmbeddedPiSessionDirect( const sessionLabel = params.sessionKey ?? params.sessionId; const resolvedMessageProvider = params.messageChannel ?? params.messageProvider; - const { contextFiles } = await resolveBootstrapContextForRun({ - workspaceDir: effectiveWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ - sessionLabel, - workspaceDir: effectiveWorkspace, - warn: (message) => log.warn(message), - }), - }); + const contextInjectionMode = resolveContextInjectionMode(params.config); + const { contextFiles } = contextInjectionMode === "never" + ? { contextFiles: [] } + : await resolveBootstrapContextForRun({ + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: makeBootstrapWarn({ + sessionLabel, + warn: (message) => log.warn(message), + }), + }); // Apply contextTokens cap to model so pi-coding-agent's auto-compaction // threshold uses the effective limit, not the native context window. const runtimeModelWithContext = runtimeModel as ProviderRuntimeModel; diff --git a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts index c8270fef280..362bc6d1094 100644 --- a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts @@ -13,23 +13,23 @@ export { export type AttemptContextEngine = ContextEngine; -export type AttemptBootstrapContext = { - bootstrapFiles: unknown[]; - contextFiles: unknown[]; +export type AttemptBootstrapContext = { + bootstrapFiles: TBootstrapFile[]; + contextFiles: TContextFile[]; }; -export async function resolveAttemptBootstrapContext< - TContext extends AttemptBootstrapContext, ->(params: { - contextInjectionMode: "always" | "continuation-skip"; +export async function resolveAttemptBootstrapContext(params: { + contextInjectionMode: "always" | "continuation-skip" | "never"; bootstrapContextMode?: string; bootstrapContextRunKind?: string; bootstrapMode?: BootstrapMode; sessionFile: string; hasCompletedBootstrapTurn: (sessionFile: string) => Promise; - resolveBootstrapContextForRun: () => Promise; + resolveBootstrapContextForRun: () => Promise< + AttemptBootstrapContext + >; }): Promise< - TContext & { + AttemptBootstrapContext & { isContinuationTurn: boolean; shouldRecordCompletedBootstrapTurn: boolean; } @@ -39,14 +39,16 @@ export async function resolveAttemptBootstrapContext< params.contextInjectionMode === "continuation-skip" && params.bootstrapContextRunKind !== "heartbeat" && (await params.hasCompletedBootstrapTurn(params.sessionFile)); + const shouldSkipBootstrapInjection = + params.contextInjectionMode === "never" || isContinuationTurn; const shouldRecordCompletedBootstrapTurn = - !isContinuationTurn && + !shouldSkipBootstrapInjection && params.bootstrapContextMode !== "lightweight" && params.bootstrapContextRunKind !== "heartbeat" && params.bootstrapMode === "full"; - const context = isContinuationTurn - ? ({ bootstrapFiles: [], contextFiles: [] } as unknown as TContext) + const context = shouldSkipBootstrapInjection + ? { bootstrapFiles: [], contextFiles: [] } : await params.resolveBootstrapContextForRun(); return { diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts index b58112e25ba..751c9e033d5 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -12,7 +12,7 @@ import { import { resetEmbeddedAttemptHarness } from "./attempt.spawn-workspace.test-support.js"; async function resolveBootstrapContext(params: { - contextInjectionMode?: "always" | "continuation-skip"; + contextInjectionMode?: "always" | "continuation-skip" | "never"; bootstrapContextMode?: string; bootstrapContextRunKind?: string; bootstrapMode?: "full" | "limited" | "none"; @@ -77,6 +77,22 @@ describe("embedded attempt context injection", () => { expect(resolver).toHaveBeenCalledTimes(1); }); + it("disables bootstrap injection without marking the turn as a continuation", async () => { + const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = + await resolveBootstrapContext({ + contextInjectionMode: "never", + bootstrapMode: "full", + completed: true, + }); + + expect(result.isContinuationTurn).toBe(false); + expect(result.shouldRecordCompletedBootstrapTurn).toBe(false); + expect(result.bootstrapFiles).toEqual([]); + expect(result.contextFiles).toEqual([]); + expect(hasCompletedBootstrapTurn).not.toHaveBeenCalled(); + expect(resolveBootstrapContextForRun).not.toHaveBeenCalled(); + }); + it("does not let a stale completed marker suppress pending workspace bootstrap", async () => { const resolver = vi.fn(async () => ({ bootstrapFiles: [{ name: "BOOTSTRAP.md" }], diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 9649d1f9ff2..5e66ea99c2c 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3447,6 +3447,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", const: "continuation-skip", }, + { + type: "string", + const: "never", + }, ], title: "Context Injection", description: diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 96337d50c9f..aaa9cfd8542 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -15,7 +15,7 @@ import type { } from "./types.base.js"; import type { MemorySearchConfig } from "./types.tools.js"; -export type AgentContextInjection = "always" | "continuation-skip"; +export type AgentContextInjection = "always" | "continuation-skip" | "never"; export type EmbeddedPiExecutionContract = "default" | "strict-agentic"; export type Gpt5PromptOverlayConfig = { diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 069c495cb4b..7e4d2dc3dab 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -52,8 +52,13 @@ describe("agent defaults schema", () => { expect(result.contextInjection).toBe("continuation-skip"); }); + it("accepts contextInjection: never", () => { + const result = AgentDefaultsSchema.parse({ contextInjection: "never" })!; + expect(result.contextInjection).toBe("never"); + }); + it("rejects invalid contextInjection values", () => { - expect(() => AgentDefaultsSchema.parse({ contextInjection: "never" })).toThrow(); + expect(() => AgentDefaultsSchema.parse({ contextInjection: "unknown" })).toThrow(); }); it("accepts embeddedPi.executionContract", () => { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 6eb243e991b..faaeea571b3 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -83,7 +83,9 @@ export const AgentDefaultsSchema = z .strict() .optional(), skipBootstrap: z.boolean().optional(), - contextInjection: z.union([z.literal("always"), z.literal("continuation-skip")]).optional(), + contextInjection: z + .union([z.literal("always"), z.literal("continuation-skip"), z.literal("never")]) + .optional(), bootstrapMaxChars: z.number().int().positive().optional(), bootstrapTotalMaxChars: z.number().int().positive().optional(), experimental: z From f5868ad1f846c9329f99b0c21b5c35f06ff80ece Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:59:21 -0700 Subject: [PATCH 39/93] fix(daemon): refresh launchd plist before restart bootstrap (#71421) --- CHANGELOG.md | 1 + src/daemon/launchd.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ src/daemon/launchd.ts | 35 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af3f8ac908..4698a75f343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x. +- macOS Gateway: rewrite stale LaunchAgent plists before restart fallback bootstrap, matching install repair behavior when `gateway restart` has to re-register launchd. Thanks @maybegeeker. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. - WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 6cb97c266a0..da70f09e2ad 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -39,6 +39,7 @@ const state = vi.hoisted(() => ({ dirModes: new Map(), files: new Map(), fileModes: new Map(), + fileWrites: [] as Array<{ path: string; data: string }>, })); const launchdRestartHandoffState = vi.hoisted(() => ({ isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false), @@ -242,6 +243,7 @@ vi.mock("node:fs/promises", async () => { writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); + state.fileWrites.push({ path: key, data }); state.dirs.add(key.split("/").slice(0, -1).join("/")); state.fileModes.set(key, opts?.mode ?? 0o666); }), @@ -274,6 +276,7 @@ beforeEach(() => { state.dirModes.clear(); state.files.clear(); state.fileModes.clear(); + state.fileWrites.length = 0; cleanStaleGatewayProcessesSync.mockReset(); cleanStaleGatewayProcessesSync.mockReturnValue([]); launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset(); @@ -472,6 +475,48 @@ describe("launchd install", () => { expect(plist).toContain(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); + it("rewrites the plist before bootstrap during restart fallback", async () => { + const env = createDefaultLaunchdEnv(); + const plistPath = resolveLaunchAgentPlistPath(env); + state.serviceLoaded = false; + state.kickstartError = "Could not find service"; + state.kickstartCode = 113; + state.kickstartFailuresRemaining = 1; + state.files.set( + plistPath, + [ + '', + '', + " ", + " Label", + " ai.openclaw.gateway", + " ProgramArguments", + " ", + " node", + " gateway.js", + " ", + " ", + "", + ].join("\n"), + ); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + const plist = state.files.get(plistPath) ?? ""; + expect(plist).toContain("StandardOutPath"); + expect(plist).toContain("StandardErrorPath"); + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("node"); + const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath); + const bootstrapIndex = state.launchctlCalls.findIndex((call) => call[0] === "bootstrap"); + expect(rewriteIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(rewriteIndex).toBeLessThan(bootstrapIndex); + }); + it("tightens writable bits on launch agent dirs and plist", async () => { const env = createDefaultLaunchdEnv(); state.dirs.add(env.HOME!); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 2d87a584aff..a79eb3da4b8 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -601,6 +601,40 @@ export async function installLaunchAgent( return { plistPath }; } +async function rewriteLaunchAgentPlistForRestart({ + env, + label, + plistPath, +}: { + env: GatewayServiceEnv; + label: string; + plistPath: string; +}): Promise { + const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath); + if (!existing?.programArguments.length) { + return; + } + + const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + await ensureSecureDirectory(logDir); + + const serviceDescription = resolveGatewayServiceDescription({ + env, + environment: existing.environment, + }); + const plist = buildLaunchAgentPlist({ + label, + comment: serviceDescription, + programArguments: existing.programArguments, + workingDirectory: existing.workingDirectory, + stdoutPath, + stderrPath, + environment: existing.environment, + }); + await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); + await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined); +} + async function ensureLaunchAgentLoadedAfterFailure(params: { domain: string; serviceTarget: string; @@ -669,6 +703,7 @@ export async function restartLaunchAgent({ } // If the service was previously booted out, re-register the plist and retry. + await rewriteLaunchAgentPlistForRestart({ env: serviceEnv, label, plistPath }); await bootstrapLaunchAgentOrThrow({ domain, serviceTarget, From 4a68fa3962da464eae31c30adb811ad7b54d2cdc Mon Sep 17 00:00:00 2001 From: Valentinws <97539256+Valentinws@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:02:10 +0200 Subject: [PATCH 40/93] fix(ui): keep tmp-dir resolver browser-import safe Defers the Node fs.constants lookup until tmp-dir resolution actually runs, adds browser-shim import regression coverage, and records the fix in the changelog.\n\nLocal verification:\n- pnpm test src/infra/tmp-openclaw-dir.browser-import.test.ts src/infra/tmp-openclaw-dir.test.ts src/logging/logger.browser-import.test.ts\n- pnpm test src/infra/run-node.test.ts -t "serializes runtime postbuild restaging|forwards wrapper SIGTERM"\n- pnpm build\n\nCo-authored-by: Valentinws --- CHANGELOG.md | 1 + .../tmp-openclaw-dir.browser-import.test.ts | 67 +++++++++++++++++++ src/infra/tmp-openclaw-dir.ts | 3 +- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/infra/tmp-openclaw-dir.browser-import.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4698a75f343..a503f40407c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already delivered through the messaging tool on clean non-error terminal turns. (#70623) Thanks @chinar-amrutkar. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. +- Control UI/browser: defer temp-dir access-mode constants until Node-only temp-dir resolution runs, preventing browser bundles from crashing when `node:fs` constants are stubbed. (#48930) Thanks @Valentinws. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x. diff --git a/src/infra/tmp-openclaw-dir.browser-import.test.ts b/src/infra/tmp-openclaw-dir.browser-import.test.ts new file mode 100644 index 00000000000..1b437d3a7c3 --- /dev/null +++ b/src/infra/tmp-openclaw-dir.browser-import.test.ts @@ -0,0 +1,67 @@ +import { Buffer } from "node:buffer"; +import crypto from "node:crypto"; +import { build, type Plugin } from "esbuild"; +import { describe, expect, it } from "vitest"; + +describe("tmp-openclaw-dir browser-safe import", () => { + it("loads when a browser fs shim omits constants", async () => { + const resultKey = `__openclawTmpDirBrowserImport_${crypto.randomUUID().replaceAll("-", "_")}`; + const nodeShimPlugin: Plugin = { + name: "node-browser-shims", + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^node:(fs|os|path)$/ }, (args) => ({ + path: args.path, + namespace: "node-browser-shim", + })); + pluginBuild.onLoad({ filter: /^node:fs$/, namespace: "node-browser-shim" }, () => ({ + contents: "export default { constants: undefined };", + loader: "js", + })); + pluginBuild.onLoad({ filter: /^node:os$/, namespace: "node-browser-shim" }, () => ({ + contents: 'export const tmpdir = () => "/tmp";', + loader: "js", + })); + pluginBuild.onLoad({ filter: /^node:path$/, namespace: "node-browser-shim" }, () => ({ + contents: "export default { join: (...parts) => parts.join('/') };", + loader: "js", + })); + }, + }; + + const bundled = await build({ + bundle: true, + format: "esm", + platform: "browser", + plugins: [nodeShimPlugin], + stdin: { + contents: ` + import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./src/infra/tmp-openclaw-dir.ts"; + globalThis.${resultKey} = { + posixTmpDir: POSIX_OPENCLAW_TMP_DIR, + resolverType: typeof resolvePreferredOpenClawTmpDir, + }; + `, + loader: "ts", + resolveDir: process.cwd(), + sourcefile: "tmp-openclaw-dir-browser-entry.ts", + }, + write: false, + }); + + const bundledSource = bundled.outputFiles[0]?.text; + expect(bundledSource).toBeTruthy(); + + await import( + `data:text/javascript;base64,${Buffer.from(bundledSource ?? "").toString("base64")}` + ); + + try { + expect((globalThis as Record)[resultKey]).toEqual({ + posixTmpDir: "/tmp/openclaw", + resolverType: "function", + }); + } finally { + delete (globalThis as Record)[resultKey]; + } + }); +}); diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index cbbd6c4b58d..1c95aea0ae0 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -3,7 +3,6 @@ import { tmpdir as getOsTmpDir } from "node:os"; import path from "node:path"; export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; -const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK; type ResolvePreferredOpenClawTmpDirOptions = { accessSync?: (path: string, mode?: number) => void; @@ -34,6 +33,8 @@ function isNodeErrorWithCode(err: unknown, code: string): err is MaybeNodeError export function resolvePreferredOpenClawTmpDir( options: ResolvePreferredOpenClawTmpDirOptions = {}, ): string { + // Evaluated here (not at module load) so this file is safe to import in browser bundles. + const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK; const accessSync = options.accessSync ?? fs.accessSync; const chmodSync = options.chmodSync ?? fs.chmodSync; const lstatSync = options.lstatSync ?? fs.lstatSync; From 996ec2dd76b910243575de9cea07f6be6c733cf6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:06:41 +0100 Subject: [PATCH 41/93] fix(agents): key fallback on committed delivery --- CHANGELOG.md | 2 +- ...pi-agent.auth-profile-rotation.e2e.test.ts | 8 ++- .../run.incomplete-turn.test.ts | 56 ++++++++++++++++++- .../run.overflow-compaction.fixture.ts | 8 ++- src/agents/pi-embedded-runner/run/attempt.ts | 2 + .../pi-embedded-runner/run/incomplete-turn.ts | 45 ++++++++++----- .../pi-embedded-runner-e2e-fixtures.ts | 8 ++- 7 files changed, 104 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a503f40407c..32dc00a622e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. -- Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already delivered through the messaging tool on clean non-error terminal turns. (#70623) Thanks @chinar-amrutkar. +- Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Control UI/browser: defer temp-dir access-mode constants until Node-only temp-dir resolution runs, preventing browser bundles from crashing when `node:fs` constants are stubbed. (#48930) Thanks @Valentinws. 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 ca438b76769..b242e091993 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 @@ -182,6 +182,8 @@ const buildAssistant = (overrides: Partial): AssistantMessage const makeAttempt = (overrides: Partial): EmbeddedRunAttemptResult => { const toolMetas = overrides.toolMetas ?? []; const didSendViaMessagingTool = overrides.didSendViaMessagingTool ?? false; + const messagingToolSentTexts = overrides.messagingToolSentTexts ?? []; + const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? []; const successfulCronAdds = overrides.successfulCronAdds; return { aborted: false, @@ -202,11 +204,13 @@ const makeAttempt = (overrides: Partial): EmbeddedRunA buildAttemptReplayMetadata({ toolMetas, didSendViaMessagingTool, + messagingToolSentTexts, + messagingToolSentMediaUrls, successfulCronAdds, }), didSendViaMessagingTool, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], + messagingToolSentTexts, + messagingToolSentMediaUrls, messagingToolSentTargets: [], cloudCodeAssistFormatError: false, itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 }, 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 f308e388780..6a4191ecc49 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -302,6 +302,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { makeAttemptResult({ assistantTexts: [], didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered through the message tool."], lastAssistant: { role: "assistant", stopReason: "stop", @@ -801,7 +802,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); - it("suppresses the incomplete-turn warning when a messaging tool delivered and the turn ended cleanly", () => { + it("suppresses the incomplete-turn warning after committed messaging text delivery", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, aborted: false, @@ -809,6 +810,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { attempt: makeAttemptResult({ assistantTexts: [], didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered through the message tool."], lastAssistant: { role: "assistant", stopReason: "stop", @@ -822,7 +824,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); - it("suppresses the incomplete-turn warning when a messaging tool delivered before end_turn", () => { + it("suppresses the incomplete-turn warning after committed messaging delivery before end_turn", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, aborted: false, @@ -830,6 +832,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { attempt: makeAttemptResult({ assistantTexts: [], didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered through the message tool."], lastAssistant: { role: "assistant", stopReason: "end_turn", @@ -849,7 +852,52 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); - it("still surfaces the incomplete-turn warning after a messaging tool when the provider signalled an error", () => { + it("suppresses the incomplete-turn warning after committed media-only messaging delivery", () => { + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: false, + messagingToolSentMediaUrls: ["file:///tmp/render.png"], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toBeNull(); + }); + + it("suppresses the incomplete-turn warning after committed messaging delivery even when the provider errored", () => { + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered before the provider error."], + lastAssistant: { + role: "assistant", + stopReason: "error", + provider: "ollama", + model: "kimi-k2.6:cloud", + errorMessage: "provider failed after delivery", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toBeNull(); + }); + + it("still surfaces the incomplete-turn warning when no messaging delivery was committed", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, aborted: false, @@ -1136,6 +1184,8 @@ describe("resolvePlanningOnlyRetryInstruction single-action loophole", () => { replayMetadata: buildAttemptReplayMetadata({ toolMetas, didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], }), clientToolCall: null, yieldDetected: false, diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts index 79a6ca753d9..dbebd4004f9 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts @@ -31,6 +31,8 @@ export function makeAttemptResult( ): EmbeddedRunAttemptResult { const toolMetas = overrides.toolMetas ?? []; const didSendViaMessagingTool = overrides.didSendViaMessagingTool ?? false; + const messagingToolSentTexts = overrides.messagingToolSentTexts ?? []; + const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? []; const successfulCronAdds = overrides.successfulCronAdds; return { aborted: false, @@ -50,6 +52,8 @@ export function makeAttemptResult( buildAttemptReplayMetadata({ toolMetas, didSendViaMessagingTool, + messagingToolSentTexts, + messagingToolSentMediaUrls, successfulCronAdds, }), itemLifecycle: { @@ -58,8 +62,8 @@ export function makeAttemptResult( activeCount: 0, }, didSendViaMessagingTool, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], + messagingToolSentTexts, + messagingToolSentMediaUrls, messagingToolSentTargets: [], cloudCodeAssistFormatError: false, ...overrides, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index c2bc4162b86..6acbf5749e4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2969,6 +2969,8 @@ export async function runEmbeddedAttempt( const observedReplayMetadata = buildAttemptReplayMetadata({ toolMetas: toolMetasNormalized, didSendViaMessagingTool: didSendViaMessagingTool(), + messagingToolSentTexts: getMessagingToolSentTexts(), + messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), successfulCronAdds: getSuccessfulCronAdds(), }); const replayMetadata = replayMetadataFromState( diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 1050a5bfa1c..0dde8fc53e3 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -13,7 +13,11 @@ import type { EmbeddedRunAttemptResult } from "./types.js"; type ReplayMetadataAttempt = Pick< EmbeddedRunAttemptResult, - "toolMetas" | "didSendViaMessagingTool" | "successfulCronAdds" + | "toolMetas" + | "didSendViaMessagingTool" + | "messagingToolSentTexts" + | "messagingToolSentMediaUrls" + | "successfulCronAdds" >; type IncompleteTurnAttempt = Pick< @@ -24,6 +28,8 @@ type IncompleteTurnAttempt = Pick< | "yieldDetected" | "didSendDeterministicApprovalPrompt" | "didSendViaMessagingTool" + | "messagingToolSentTexts" + | "messagingToolSentMediaUrls" | "lastToolError" | "lastAssistant" | "replayMetadata" @@ -155,12 +161,31 @@ export type PlanningOnlyPlanDetails = { steps: string[]; }; +function hasStringEntry(values: readonly unknown[] | undefined): boolean { + return ( + Array.isArray(values) && + values.some((value) => typeof value === "string" && value.trim().length > 0) + ); +} + +export function hasCommittedUserVisibleToolDelivery( + attempt: Pick, +): boolean { + return ( + hasStringEntry(attempt.messagingToolSentTexts) || + hasStringEntry(attempt.messagingToolSentMediaUrls) + ); +} + export function buildAttemptReplayMetadata( params: ReplayMetadataAttempt, ): EmbeddedRunAttemptResult["replayMetadata"] { const hadMutatingTools = params.toolMetas.some((t) => isLikelyMutatingToolName(t.toolName)); const hadPotentialSideEffects = - hadMutatingTools || params.didSendViaMessagingTool || (params.successfulCronAdds ?? 0) > 0; + hadMutatingTools || + params.didSendViaMessagingTool || + hasCommittedUserVisibleToolDelivery(params) || + (params.successfulCronAdds ?? 0) > 0; return { hadPotentialSideEffects, replaySafe: !hadPotentialSideEffects, @@ -189,21 +214,11 @@ export function resolveIncompleteTurnPayloadText(params: { return null; } - const stopReason = params.attempt.lastAssistant?.stopReason; - // If the assistant already delivered user-visible content via a messaging - // tool during this turn and did not end in a hard error/interrupted tool-use - // state, do not surface an incomplete-turn warning. The user has received the - // reply; a follow-up "couldn't generate a response" bubble is a false positive. - // Provider-side failures and interrupted tool-use still fall through to the - // normal incomplete-turn paths below; tool-error cases are already handled by - // the lastToolError early return above. - if ( - params.attempt.didSendViaMessagingTool && - stopReason !== "error" && - stopReason !== "toolUse" - ) { + if (hasCommittedUserVisibleToolDelivery(params.attempt)) { return null; } + + const stopReason = params.attempt.lastAssistant?.stopReason; const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({ hasAssistantVisibleText: params.payloadCount > 0, lastAssistant: params.attempt.lastAssistant, diff --git a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts b/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts index 53826239e11..4fed384fff1 100644 --- a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts +++ b/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts @@ -99,6 +99,8 @@ export function makeEmbeddedRunnerAttempt( ): EmbeddedRunAttemptResult { const toolMetas = overrides.toolMetas ?? []; const didSendViaMessagingTool = overrides.didSendViaMessagingTool ?? false; + const messagingToolSentTexts = overrides.messagingToolSentTexts ?? []; + const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? []; const successfulCronAdds = overrides.successfulCronAdds; return { aborted: false, @@ -119,11 +121,13 @@ export function makeEmbeddedRunnerAttempt( buildAttemptReplayMetadata({ toolMetas, didSendViaMessagingTool, + messagingToolSentTexts, + messagingToolSentMediaUrls, successfulCronAdds, }), didSendViaMessagingTool, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], + messagingToolSentTexts, + messagingToolSentMediaUrls, messagingToolSentTargets: [], cloudCodeAssistFormatError: false, itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 }, From d6a9165b9e0555272c1e791929e1af7d6a3188a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:07:52 +0100 Subject: [PATCH 42/93] test: stabilize gateway and TTS tests --- src/agents/openclaw-tools.tts-config.test.ts | 117 ++++++++++++++++-- .../server.node-pairing-auto-approve.test.ts | 45 ++++++- 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index 0758ff22f4b..7ba078480ef 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -1,13 +1,116 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { AnyAgentTool } from "./tools/common.js"; -const mocks = vi.hoisted(() => ({ - textToSpeech: vi.fn(async () => ({ - success: true, - audioPath: "/tmp/openclaw/tts-config-test.opus", - provider: "microsoft", - voiceCompatible: true, - })), +const mocks = vi.hoisted(() => { + const stubTool = (name: string) => + ({ + name, + label: name, + displaySummary: name, + description: name, + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }) satisfies AnyAgentTool; + + return { + stubTool, + textToSpeech: vi.fn(async () => ({ + success: true, + audioPath: "/tmp/openclaw/tts-config-test.opus", + provider: "microsoft", + voiceCompatible: true, + })), + }; +}); + +vi.mock("./openclaw-plugin-tools.js", () => ({ + resolveOpenClawPluginToolsForOptions: () => [], +})); + +vi.mock("./openclaw-tools.nodes-workspace-guard.js", () => ({ + applyNodesToolWorkspaceGuard: (tool: AnyAgentTool) => tool, +})); + +vi.mock("./tools/agents-list-tool.js", () => ({ + createAgentsListTool: () => mocks.stubTool("agents_list"), +})); + +vi.mock("./tools/canvas-tool.js", () => ({ + createCanvasTool: () => mocks.stubTool("canvas"), +})); + +vi.mock("./tools/cron-tool.js", () => ({ + createCronTool: () => mocks.stubTool("cron"), +})); + +vi.mock("./tools/gateway-tool.js", () => ({ + createGatewayTool: () => mocks.stubTool("gateway"), +})); + +vi.mock("./tools/image-generate-tool.js", () => ({ + createImageGenerateTool: () => mocks.stubTool("image_generate"), +})); + +vi.mock("./tools/image-tool.js", () => ({ + createImageTool: () => mocks.stubTool("image"), +})); + +vi.mock("./tools/message-tool.js", () => ({ + createMessageTool: () => mocks.stubTool("message"), +})); + +vi.mock("./tools/music-generate-tool.js", () => ({ + createMusicGenerateTool: () => mocks.stubTool("music_generate"), +})); + +vi.mock("./tools/nodes-tool.js", () => ({ + createNodesTool: () => mocks.stubTool("nodes"), +})); + +vi.mock("./tools/pdf-tool.js", () => ({ + createPdfTool: () => mocks.stubTool("pdf"), +})); + +vi.mock("./tools/session-status-tool.js", () => ({ + createSessionStatusTool: () => mocks.stubTool("session_status"), +})); + +vi.mock("./tools/sessions-history-tool.js", () => ({ + createSessionsHistoryTool: () => mocks.stubTool("sessions_history"), +})); + +vi.mock("./tools/sessions-list-tool.js", () => ({ + createSessionsListTool: () => mocks.stubTool("sessions_list"), +})); + +vi.mock("./tools/sessions-send-tool.js", () => ({ + createSessionsSendTool: () => mocks.stubTool("sessions_send"), +})); + +vi.mock("./tools/sessions-spawn-tool.js", () => ({ + createSessionsSpawnTool: () => mocks.stubTool("sessions_spawn"), +})); + +vi.mock("./tools/sessions-yield-tool.js", () => ({ + createSessionsYieldTool: () => mocks.stubTool("sessions_yield"), +})); + +vi.mock("./tools/subagents-tool.js", () => ({ + createSubagentsTool: () => mocks.stubTool("subagents"), +})); + +vi.mock("./tools/update-plan-tool.js", () => ({ + createUpdatePlanTool: () => mocks.stubTool("update_plan"), +})); + +vi.mock("./tools/video-generate-tool.js", () => ({ + createVideoGenerateTool: () => mocks.stubTool("video_generate"), +})); + +vi.mock("./tools/web-tools.js", () => ({ + createWebFetchTool: () => mocks.stubTool("web_fetch"), + createWebSearchTool: () => mocks.stubTool("web_search"), })); vi.mock("../tts/tts.js", () => ({ diff --git a/src/gateway/server.node-pairing-auto-approve.test.ts b/src/gateway/server.node-pairing-auto-approve.test.ts index 22c11248af8..2732eb1442c 100644 --- a/src/gateway/server.node-pairing-auto-approve.test.ts +++ b/src/gateway/server.node-pairing-auto-approve.test.ts @@ -1,3 +1,4 @@ +import net from "node:net"; import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { writeConfigFile } from "../config/config.js"; @@ -23,7 +24,9 @@ const NODE_CLIENT = { }; async function openLanGatewayWs(params: { host: string; port: number }): Promise { - const ws = new WebSocket(`ws://${params.host}:${params.port}`); + const ws = new WebSocket(`ws://${params.host}:${params.port}`, { + localAddress: params.host, + }); trackConnectChallengeNonce(ws); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); @@ -46,10 +49,46 @@ async function openLanGatewayWs(params: { host: string; port: number }): Promise return ws; } +async function canUseLanSelfConnect(host: string): Promise { + return await new Promise((resolve) => { + let settled = false; + let client: net.Socket | undefined; + const server = net.createServer((socket) => { + socket.on("error", () => {}); + socket.end("ok"); + }); + const done = (ok: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + client?.destroy(); + server.close(() => resolve(ok)); + }; + const timer = setTimeout(() => done(false), 1_000); + server.once("error", () => done(false)); + server.listen(0, "0.0.0.0", () => { + const address = server.address(); + if (!address || typeof address === "string") { + done(false); + return; + } + let sawData = false; + client = net.connect({ host, port: address.port, localAddress: host }); + client.on("data", () => { + sawData = true; + }); + client.once("error", () => done(false)); + client.once("close", () => done(sawData)); + }); + }); +} + describe("gateway trusted CIDR node pairing auto-approve", () => { test("stays disabled by default for a direct non-loopback node", async () => { const lanIp = pickPrimaryLanIPv4(); - if (!lanIp) { + if (!lanIp || !(await canUseLanSelfConnect(lanIp))) { return; } const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false }); @@ -82,7 +121,7 @@ describe("gateway trusted CIDR node pairing auto-approve", () => { test("auto-approves first-time node pairing from a matching direct non-loopback CIDR", async () => { const lanIp = pickPrimaryLanIPv4(); - if (!lanIp) { + if (!lanIp || !(await canUseLanSelfConnect(lanIp))) { return; } await writeConfigFile({ From 0ff7aa5c3df7b1ba290aebc514c5d56fe6cb3cdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:43:22 +0100 Subject: [PATCH 43/93] fix(browser): retry stale cached playwright attach once (cherry picked from commit ec252ebd7b45d54e431e0d7599532e5a0c1a9b73) # Conflicts: # extensions/browser/src/browser/pw-session.ts --- .../browser/pw-session.connections.test.ts | 57 +++++++- ...ge-for-targetid.extension-fallback.test.ts | 130 +++++++++++++++++- extensions/browser/src/browser/pw-session.ts | 56 +++++++- 3 files changed, 238 insertions(+), 5 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.connections.test.ts b/extensions/browser/src/browser/pw-session.connections.test.ts index abb6946d610..f8771b983de 100644 --- a/extensions/browser/src/browser/pw-session.connections.test.ts +++ b/extensions/browser/src/browser/pw-session.connections.test.ts @@ -1,7 +1,11 @@ import { chromium } from "playwright-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as chromeModule from "./chrome.js"; -import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js"; +import { + closePlaywrightBrowserConnection, + getPageForTargetId, + listPagesViaPlaywright, +} from "./pw-session.js"; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); @@ -42,6 +46,24 @@ function makeBrowser(targetId: string, url: string): BrowserMockBundle { return { browser, browserClose }; } +function makeEmptyBrowser(): BrowserMockBundle { + const browserClose = vi.fn(async () => {}); + const context = { + pages: () => [], + on: vi.fn(), + newCDPSession: vi.fn(), + } as unknown as import("playwright-core").BrowserContext; + + const browser = { + contexts: () => [context], + on: vi.fn(), + off: vi.fn(), + close: browserClose, + } as unknown as import("playwright-core").Browser; + + return { browser, browserClose }; +} + afterEach(async () => { connectOverCdpSpy.mockReset(); getChromeWebSocketUrlSpy.mockReset(); @@ -116,4 +138,37 @@ describe("pw-session connection scoping", () => { expect(browserA.browserClose).toHaveBeenCalledTimes(1); expect(browserB.browserClose).not.toHaveBeenCalled(); }); + + it("evicts only the stale cdpUrl when getPageForTargetId retries a cached connection", async () => { + const staleA = makeEmptyBrowser(); + const refreshedA = makeBrowser("A", "https://a.example/recovered"); + const browserB = makeBrowser("B", "https://b.example"); + let callsForA = 0; + + connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => { + const endpointText = String(args[0]); + if (endpointText === "http://127.0.0.1:9222") { + callsForA += 1; + return callsForA === 1 ? staleA.browser : refreshedA.browser; + } + if (endpointText === "http://127.0.0.1:9333") { + return browserB.browser; + } + throw new Error(`unexpected endpoint: ${endpointText}`); + }) as never); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" }); + + const recoveredA = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" }); + const stillCachedB = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" }); + + expect(recoveredA.url()).toBe("https://a.example/recovered"); + expect(stillCachedB.url()).toBe("https://b.example"); + expect(staleA.browserClose).toHaveBeenCalledTimes(1); + expect(refreshedA.browserClose).not.toHaveBeenCalled(); + expect(browserB.browserClose).not.toHaveBeenCalled(); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(3); + }); }); diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index 36c5dd24e37..c9d41c06fc7 100644 --- a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -1,14 +1,69 @@ import { chromium } from "playwright-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as chromeModule from "./chrome.js"; -import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js"; +import { + closePlaywrightBrowserConnection, + getPageForTargetId, + listPagesViaPlaywright, +} from "./pw-session.js"; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +type MockPageSpec = { + targetId?: string; + url?: string; + title?: string; +}; + +type BrowserMockBundle = { + browser: import("playwright-core").Browser; + browserClose: ReturnType; + pages: import("playwright-core").Page[]; +}; + +function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle { + let context: import("playwright-core").BrowserContext; + const browserClose = vi.fn(async () => {}); + const targetIdByPage = new Map(); + + const pageObjects = pages.map((spec, index) => { + const page = { + on: vi.fn(), + context: () => context, + title: vi.fn(async () => spec.title ?? spec.targetId ?? `page-${index + 1}`), + url: vi.fn(() => spec.url ?? `https://page-${index + 1}.example`), + } as unknown as import("playwright-core").Page; + targetIdByPage.set(page, spec.targetId); + return page; + }); + + context = { + pages: () => pageObjects, + on: vi.fn(), + newCDPSession: vi.fn(async (page: import("playwright-core").Page) => ({ + send: vi.fn(async (method: string) => + method === "Target.getTargetInfo" + ? { targetInfo: { targetId: targetIdByPage.get(page) } } + : {}, + ), + detach: vi.fn(async () => {}), + })), + } as unknown as import("playwright-core").BrowserContext; + + const browser = { + contexts: () => [context], + on: vi.fn(), + off: vi.fn(), + close: browserClose, + } as unknown as import("playwright-core").Browser; + + return { browser, browserClose, pages: pageObjects }; +} + afterEach(async () => { - connectOverCdpSpy.mockClear(); - getChromeWebSocketUrlSpy.mockClear(); + connectOverCdpSpy.mockReset(); + getChromeWebSocketUrlSpy.mockReset(); await closePlaywrightBrowserConnection().catch(() => {}); }); @@ -119,4 +174,73 @@ describe("pw-session getPageForTargetId", () => { fetchSpy.mockRestore(); } }); + + it("evicts a stale cached page-less browser once and succeeds on a fresh reconnect", async () => { + const stale = makeBrowser([]); + const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]); + + connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + + const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" }); + + expect(resolved).toBe(fresh.pages[0]); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(2); + expect(stale.browserClose).toHaveBeenCalledTimes(1); + }); + + it("evicts a stale cached tab-selection miss once and succeeds on a fresh reconnect", async () => { + const stale = makeBrowser([ + { targetId: "TARGET_A", url: "https://alpha.example" }, + { targetId: "TARGET_C", url: "https://charlie.example" }, + ]); + const fresh = makeBrowser([ + { targetId: "TARGET_A", url: "https://alpha.example" }, + { targetId: "TARGET_B", url: "https://beta.example" }, + ]); + + connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" }); + + const resolved = await getPageForTargetId({ + cdpUrl: "http://127.0.0.1:9333", + targetId: "TARGET_B", + }); + + expect(resolved).toBe(fresh.pages[1]); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(2); + expect(stale.browserClose).toHaveBeenCalledTimes(1); + }); + + it("fails after a single reconnect when the refreshed browser is still page-less", async () => { + const stale = makeBrowser([]); + const stillBroken = makeBrowser([]); + + connectOverCdpSpy + .mockResolvedValueOnce(stale.browser) + .mockResolvedValueOnce(stillBroken.browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" }); + + await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow( + "No pages available in the connected browser.", + ); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(2); + expect(stale.browserClose).toHaveBeenCalledTimes(1); + }); + + it("does not add an extra top-level retry for non-recoverable connect failures", async () => { + connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded")); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow( + "connectOverCDP exploded", + ); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(3); + }); }); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 6a240581473..6a81cfd04f9 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -123,6 +123,27 @@ function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); } +function hasCachedPlaywrightBrowserConnection(cdpUrl: string): boolean { + return cachedByCdpUrl.has(normalizeCdpUrl(cdpUrl)); +} + +function isRecoverableStalePageSelectionError(err: unknown, reusedCachedBrowser: boolean): boolean { + if (!reusedCachedBrowser) { + return false; + } + if ( + err instanceof Error && + err.message.includes("No pages available in the connected browser.") + ) { + return true; + } + if (err instanceof BrowserTabNotFoundError) { + return true; + } + const message = err instanceof Error ? err.message : formatErrorMessage(err); + return message.toLowerCase().includes("tab not found"); +} + function findNetworkRequestById(state: PageState, id: string): BrowserNetworkRequest | undefined { for (let i = state.requests.length - 1; i >= 0; i -= 1) { const candidate = state.requests[i]; @@ -625,7 +646,7 @@ async function resolvePageByTargetIdOrThrow(opts: { return page; } -export async function getPageForTargetId(opts: { +async function getPageForTargetIdOnce(opts: { cdpUrl: string; targetId?: string; ssrfPolicy?: SsrFPolicy; @@ -671,6 +692,23 @@ export async function getPageForTargetId(opts: { throw new BrowserTabNotFoundError(); } +export async function getPageForTargetId(opts: { + cdpUrl: string; + targetId?: string; + ssrfPolicy?: SsrFPolicy; +}): Promise { + const reusedCachedBrowser = hasCachedPlaywrightBrowserConnection(opts.cdpUrl); + try { + return await getPageForTargetIdOnce(opts); + } catch (err) { + if (!isRecoverableStalePageSelectionError(err, reusedCachedBrowser)) { + throw err; + } + await closePlaywrightBrowserConnection({ cdpUrl: opts.cdpUrl }); + return await getPageForTargetIdOnce(opts); + } +} + function isTopLevelNavigationRequest(page: Page, request: Request): boolean { let sameMainFrame = false; try { @@ -848,6 +886,22 @@ export async function gotoPageWithNavigationGuard( } } +export async function getPageForTargetId(opts: { + cdpUrl: string; + targetId?: string; +}): Promise { + const reusedCachedBrowser = hasCachedPlaywrightBrowserConnection(opts.cdpUrl); + try { + return await getPageForTargetIdOnce(opts); + } catch (err) { + if (!isRecoverableStalePageSelectionError(err, reusedCachedBrowser)) { + throw err; + } + await closePlaywrightBrowserConnection({ cdpUrl: opts.cdpUrl }); + return await getPageForTargetIdOnce(opts); + } +} + export function refLocator(page: Page, ref: string) { const normalized = ref.startsWith("@") ? ref.slice(1) From 8e18b3cc20d6c8a5c761315b94303885dd7d2773 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:44:24 +0100 Subject: [PATCH 44/93] fix(browser): report attach-only profile transport truthfully # Conflicts: # extensions/browser/src/browser/routes/basic.ts --- .../routes/basic.existing-session.test.ts | 27 +++++++++++++++++-- .../browser/src/browser/routes/basic.ts | 12 +++++---- .../browser/server-context.availability.ts | 12 ++++++++- .../server-context.existing-session.test.ts | 18 +++++++++++++ .../browser/src/browser/server-context.ts | 27 ++++++++++++------- .../src/browser/server-context.types.ts | 1 + 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/extensions/browser/src/browser/routes/basic.existing-session.test.ts b/extensions/browser/src/browser/routes/basic.existing-session.test.ts index 631463e3273..dd5285dfc7a 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -8,7 +8,11 @@ vi.mock("../chrome-mcp.js", () => ({ const { BrowserProfileUnavailableError } = await import("../errors.js"); const { registerBrowserBasicRoutes } = await import("./basic.js"); -function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise }) { +function createExistingSessionProfileState(params?: { + isHttpReachable?: () => Promise; + isTransportAvailable?: () => Promise; + isReachable?: () => Promise; +}) { return { resolved: { enabled: true, @@ -31,7 +35,8 @@ function createExistingSessionProfileState(params?: { isHttpReachable?: () => Pr attachOnly: true, }, isHttpReachable: params?.isHttpReachable ?? (async () => true), - isReachable: async () => true, + isTransportAvailable: params?.isTransportAvailable ?? (async () => true), + isReachable: params?.isReachable ?? (async () => true), }) as never, }; } @@ -86,4 +91,22 @@ describe("basic browser routes", () => { pid: 4321, }); }); + + it("treats attach-only profiles as running when transport is available even if page reachability is false", async () => { + const response = await callBasicRouteWithState({ + state: createExistingSessionProfileState({ + isTransportAvailable: async () => true, + isReachable: async () => false, + }), + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + profile: "chrome-live", + driver: "existing-session", + transport: "chrome-mcp", + running: true, + cdpReady: true, + }); + }); }); diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index 8a60899f47c..aa42235e7e1 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -61,13 +61,15 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) throw new BrowserError(profileCtx.error, profileCtx.status); } - const [cdpHttp, cdpReady] = await Promise.all([ - profileCtx.isHttpReachable(300), - profileCtx.isReachable(600), - ]); + const capabilities = getBrowserProfileCapabilities(profileCtx.profile); + const [cdpHttp, cdpReady] = capabilities.usesChromeMcp + ? await (async () => { + const ready = await profileCtx.isTransportAvailable(600); + return [ready, ready] as const; + })() + : await Promise.all([profileCtx.isHttpReachable(300), profileCtx.isTransportAvailable(600)]); const profileState = current.profiles.get(profileCtx.profile.name); - const capabilities = getBrowserProfileCapabilities(profileCtx.profile); let detectedBrowser: string | null = null; let detectedExecutablePath: string | null = null; let detectError: string | null = null; diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 27bc09f17fd..3bbc9743eb0 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -46,6 +46,7 @@ type AvailabilityDeps = { type AvailabilityOps = { isHttpReachable: (timeoutMs?: number) => Promise; + isTransportAvailable: (timeoutMs?: number) => Promise; isReachable: (timeoutMs?: number) => Promise; ensureBrowserAvailable: () => Promise; stopRunningBrowser: () => Promise<{ stopped: boolean }>; @@ -87,9 +88,17 @@ export function createProfileAvailability({ ); }; + const isTransportAvailable = async (timeoutMs?: number) => { + if (capabilities.usesChromeMcp) { + await ensureChromeMcpAvailable(profile.name, profile.userDataDir); + return true; + } + return await isReachable(timeoutMs); + }; + const isHttpReachable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { - return await isReachable(timeoutMs); + return await isTransportAvailable(timeoutMs); } const { httpTimeoutMs } = resolveTimeouts(timeoutMs); return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy()); @@ -341,6 +350,7 @@ export function createProfileAvailability({ return { isHttpReachable, + isTransportAvailable, isReachable, ensureBrowserAvailable, stopRunningBrowser, diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 7c412261fdc..48b2c0a5b17 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -85,6 +85,24 @@ afterEach(() => { }); describe("browser server-context existing-session profile", () => { + it("reports attach-only profiles as running when the MCP session is available but no page is selected", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); + const state = makeState(); + const ctx = createBrowserRouteContext({ getState: () => state }); + + vi.mocked(chromeMcp.ensureChromeMcpAvailable).mockResolvedValueOnce(); + vi.mocked(chromeMcp.listChromeMcpTabs).mockRejectedValueOnce(new Error("No page selected")); + + await expect(ctx.listProfiles()).resolves.toEqual([ + expect.objectContaining({ + name: "chrome-live", + transport: "chrome-mcp", + running: true, + tabCount: 0, + }), + ]); + }); + it("routes tab operations through the Chrome MCP backend", async () => { fs.mkdirSync("/tmp/brave-profile", { recursive: true }); const state = makeState(); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index 166b0442e96..a8193c67b21 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -79,14 +79,19 @@ function createProfileContext( getProfileState, }); - const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } = - createProfileAvailability({ - opts, - profile, - state, - getProfileState, - setProfileRunning, - }); + const { + ensureBrowserAvailable, + isHttpReachable, + isTransportAvailable, + isReachable, + stopRunningBrowser, + } = createProfileAvailability({ + opts, + profile, + state, + getProfileState, + setProfileRunning, + }); const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ profile, @@ -110,6 +115,7 @@ function createProfileContext( ensureBrowserAvailable, ensureTabAvailable, isHttpReachable, + isTransportAvailable, isReachable, listTabs, openTab, @@ -173,9 +179,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon if (capabilities.usesChromeMcp) { try { - running = await profileCtx.isReachable(300); + running = await profileCtx.isTransportAvailable(300); if (running) { - const tabs = await profileCtx.listTabs(); + const tabs = await profileCtx.listTabs().catch(() => [] as BrowserTab[]); tabCount = tabs.filter((t) => t.type === "page").length; } } catch { @@ -251,6 +257,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(), ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId), isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs), + isTransportAvailable: (timeoutMs) => getDefaultContext().isTransportAvailable(timeoutMs), isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs), listTabs: () => getDefaultContext().listTabs(), openTab: (url, opts) => getDefaultContext().openTab(url, opts), diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index 88a4ee4bcc4..8be63c52426 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -36,6 +36,7 @@ type BrowserProfileActions = { ensureBrowserAvailable: () => Promise; ensureTabAvailable: (targetId?: string) => Promise; isHttpReachable: (timeoutMs?: number) => Promise; + isTransportAvailable: (timeoutMs?: number) => Promise; isReachable: (timeoutMs?: number) => Promise; listTabs: () => Promise; openTab: (url: string, opts?: { label?: string }) => Promise; From 3c31facfa2b0cea603a3abf575a3cc9ec2b0e4f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:46:40 +0100 Subject: [PATCH 45/93] Fix sticky Chrome MCP status probes # Conflicts: # extensions/browser/src/browser/chrome-mcp.ts # extensions/browser/src/browser/server-context.ts --- .../browser/src/browser/chrome-mcp.test.ts | 67 +++++++ extensions/browser/src/browser/chrome-mcp.ts | 185 ++++++++++++++++-- .../browser/server-context.availability.ts | 5 +- .../server-context.existing-session.test.ts | 59 +++++- .../browser/src/browser/server-context.ts | 5 +- 5 files changed, 294 insertions(+), 27 deletions(-) diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index b4414cc7b00..0a9996a2661 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clickChromeMcpElement, buildChromeMcpArgs, + ensureChromeMcpAvailable, evaluateChromeMcpScript, listChromeMcpTabs, navigateChromeMcpPage, @@ -186,6 +187,72 @@ describe("chrome MCP page parsing", () => { expect(result).toBe(123); }); + it("does not cache an ephemeral availability probe before the next real attach", async () => { + let factoryCalls = 0; + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + session.client.close = closeMock as typeof session.client.close; + closeMocks.push(closeMock); + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await ensureChromeMcpAvailable("chrome-live", undefined, { ephemeral: true }); + + expect(factoryCalls).toBe(1); + expect(closeMocks[0]).toHaveBeenCalledTimes(1); + + const tabs = await listChromeMcpTabs("chrome-live"); + + expect(factoryCalls).toBe(2); + expect(closeMocks[1]).not.toHaveBeenCalled(); + expect(tabs).toHaveLength(2); + }); + + it("does not poison the next real attach after an ephemeral no-page probe", async () => { + let factoryCalls = 0; + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + session.client.close = closeMock as typeof session.client.close; + closeMocks.push(closeMock); + if (factoryCalls === 1) { + const callTool = vi.fn(async ({ name }: ToolCall) => { + if (name === "list_pages") { + return { + content: [{ type: "text", text: "No page selected" }], + isError: true, + }; + } + throw new Error(`unexpected tool ${name}`); + }); + session.client.callTool = callTool as typeof session.client.callTool; + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await expect( + listChromeMcpTabs("chrome-live", undefined, { + ephemeral: true, + }), + ).rejects.toThrow(/No page selected/); + + expect(factoryCalls).toBe(1); + expect(closeMocks[0]).toHaveBeenCalledTimes(1); + + const tabs = await listChromeMcpTabs("chrome-live"); + + expect(factoryCalls).toBe(2); + expect(closeMocks[1]).not.toHaveBeenCalled(); + expect(tabs).toHaveLength(2); + }); + it("surfaces MCP tool errors instead of JSON parse noise", async () => { const factory: ChromeMcpSessionFactory = async () => { const session = createFakeSession(); diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index f053820ce3b..a940bd0fbb8 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -31,6 +31,18 @@ type ChromeMcpSession = { ready: Promise; }; +type ChromeMcpCallOptions = { + ephemeral?: boolean; + timeoutMs?: number; + signal?: AbortSignal; +}; + +type ChromeMcpSessionLease = { + session: ChromeMcpSession; + cacheKey: string; + temporary: boolean; +}; + type ChromeMcpSessionFactory = ( profileName: string, userDataDir?: string, @@ -332,7 +344,42 @@ async function createRealSession( }; } -async function getSession(profileName: string, userDataDir?: string): Promise { +async function waitForChromeMcpReady( + session: ChromeMcpSession, + profileName: string, + timeoutMs?: number, +): Promise { + if (!timeoutMs || timeoutMs <= 0) { + await session.ready; + return; + } + + let timer: ReturnType | undefined; + try { + await Promise.race([ + session.ready, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new BrowserProfileUnavailableError( + `Chrome MCP existing-session attach for profile "${profileName}" timed out after ${timeoutMs}ms.`, + ), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function getSession( + profileName: string, + userDataDir?: string, + timeoutMs?: number, +): Promise { const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); await closeChromeMcpSessionsForProfile(profileName, cacheKey); @@ -364,7 +411,7 @@ async function getSession(profileName: string, userDataDir?: string): Promise { + let session = sessions.get(cacheKey); + if (session && session.transport.pid === null) { + sessions.delete(cacheKey); + session = undefined; + } + if (session) { + try { + await waitForChromeMcpReady(session, profileName, timeoutMs); + return session; + } catch (err) { + const current = sessions.get(cacheKey); + if (current?.transport === session.transport) { + sessions.delete(cacheKey); + } + throw err; + } + } + + const pending = pendingSessions.get(cacheKey); + if (!pending) { + return null; + } + + session = await pending; + try { + await waitForChromeMcpReady(session, profileName, timeoutMs); + return session; + } catch (err) { + const current = sessions.get(cacheKey); + if (current?.transport === session.transport) { + sessions.delete(cacheKey); + } + throw err; + } +} + +async function createEphemeralSession( + profileName: string, + userDataDir?: string, + timeoutMs?: number, +): Promise { + const session = await (sessionFactory ?? createRealSession)(profileName, userDataDir); + try { + await waitForChromeMcpReady(session, profileName, timeoutMs); + return session; + } catch (err) { + await session.client.close().catch(() => {}); + throw err; + } +} + +async function leaseSession( + profileName: string, + userDataDir?: string, + options: ChromeMcpCallOptions = {}, +): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + if (!options.ephemeral) { + return { + session: await getSession(profileName, userDataDir, options.timeoutMs), + cacheKey, + temporary: false, + }; + } + + // Status probes should avoid seeding the shared attach session cache, but they can safely + // reuse a real cached session if one already exists. + const existingSession = await getExistingSession(cacheKey, profileName, options.timeoutMs); + if (existingSession) { + return { + session: existingSession, + cacheKey, + temporary: false, + }; + } + + return { + session: await createEphemeralSession(profileName, userDataDir, options.timeoutMs), + cacheKey, + temporary: true, + }; +} + async function callTool( profileName: string, userDataDir: string | undefined, name: string, args: Record = {}, - opts?: { timeoutMs?: number; signal?: AbortSignal }, + options: ChromeMcpCallOptions = {}, ): Promise { - const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); - const timeoutMs = opts?.timeoutMs; - const signal = opts?.signal; + const timeoutMs = options.timeoutMs; + const signal = options.signal; if (signal?.aborted) { throw signal.reason ?? new Error("aborted"); } for (let attempt = 0; attempt < 2; attempt += 1) { - const session = await getSession(profileName, userDataDir); - const rawCall = session.client.callTool({ + const lease = await leaseSession(profileName, userDataDir, options); + const rawCall = lease.session.client.callTool({ name, arguments: args, }) as Promise; @@ -430,10 +564,12 @@ async function callTool( void rawCall.catch(() => {}); // Transport/connection error, timeout, or abort: tear down session so it reconnects. // Transport-identity check prevents clobbering a replacement session created concurrently. - const cur = sessions.get(cacheKey); - if (cur?.transport === session.transport) { - sessions.delete(cacheKey); - await session.client.close().catch(() => {}); + if (!lease.temporary) { + const cur = sessions.get(lease.cacheKey); + if (cur?.transport === lease.session.transport) { + sessions.delete(lease.cacheKey); + await lease.session.client.close().catch(() => {}); + } } throw err; } finally { @@ -443,6 +579,9 @@ async function callTool( if (signal && abortListener) { signal.removeEventListener("abort", abortListener); } + if (lease.temporary) { + await lease.session.client.close().catch(() => {}); + } } // Tool-level errors (element not found, script error, etc.) don't indicate a // broken connection. A stale selected-page error does poison the Chrome MCP @@ -450,10 +589,12 @@ async function callTool( if (result.isError) { const message = extractToolErrorMessage(result, name); if (shouldReconnectForToolError(name, message)) { - const cur = sessions.get(cacheKey); - if (cur?.transport === session.transport) { - sessions.delete(cacheKey); - await session.client.close().catch(() => {}); + if (!lease.temporary) { + const cur = sessions.get(lease.cacheKey); + if (cur?.transport === lease.session.transport) { + sessions.delete(lease.cacheKey); + await lease.session.client.close().catch(() => {}); + } } if (attempt === 0) { continue; @@ -492,8 +633,12 @@ async function findPageById( export async function ensureChromeMcpAvailable( profileName: string, userDataDir?: string, + options: ChromeMcpCallOptions = {}, ): Promise { - await getSession(profileName, userDataDir); + const lease = await leaseSession(profileName, userDataDir, options); + if (lease.temporary) { + await lease.session.client.close().catch(() => {}); + } } export function getChromeMcpPid(profileName: string): number | null { @@ -519,16 +664,18 @@ export async function stopAllChromeMcpSessions(): Promise { export async function listChromeMcpPages( profileName: string, userDataDir?: string, + options: ChromeMcpCallOptions = {}, ): Promise { - const result = await callTool(profileName, userDataDir, "list_pages"); + const result = await callTool(profileName, userDataDir, "list_pages", {}, options); return extractStructuredPages(result); } export async function listChromeMcpTabs( profileName: string, userDataDir?: string, + options: ChromeMcpCallOptions = {}, ): Promise { - return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); + return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir, options)); } export async function openChromeMcpTab( diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 3bbc9743eb0..2a74f778900 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -90,7 +90,10 @@ export function createProfileAvailability({ const isTransportAvailable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { - await ensureChromeMcpAvailable(profile.name, profile.userDataDir); + await ensureChromeMcpAvailable(profile.name, profile.userDataDir, { + ephemeral: true, + timeoutMs, + }); return true; } return await isReachable(timeoutMs); diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 48b2c0a5b17..fad28383291 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -13,7 +13,7 @@ vi.mock("./chrome-mcp.js", () => ({ openChromeMcpTab: vi.fn(async () => ({ targetId: "8", title: "", - url: "https://openclaw.ai", + url: "about:blank", type: "page", })), closeChromeMcpTab: vi.fn(async () => {}), @@ -101,6 +101,53 @@ describe("browser server-context existing-session profile", () => { tabCount: 0, }), ]); + + expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith( + "chrome-live", + "/tmp/brave-profile", + { ephemeral: true }, + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile", { + ephemeral: true, + }); + }); + + it("keeps the next real attach on the normal sticky session path after an idle status probe", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); + const state = makeState(); + const ctx = createBrowserRouteContext({ getState: () => state }); + const live = ctx.forProfile("chrome-live"); + + vi.mocked(chromeMcp.listChromeMcpTabs).mockRejectedValueOnce(new Error("No page selected")); + + await expect(ctx.listProfiles()).resolves.toEqual([ + expect.objectContaining({ + name: "chrome-live", + running: true, + tabCount: 0, + }), + ]); + + vi.mocked(chromeMcp.listChromeMcpTabs).mockClear(); + + await live.ensureBrowserAvailable(); + const tabs = await live.listTabs(); + + expect(tabs.map((tab) => tab.targetId)).toEqual(["7"]); + expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenLastCalledWith( + "chrome-live", + "/tmp/brave-profile", + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith( + 1, + "chrome-live", + "/tmp/brave-profile", + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith( + 2, + "chrome-live", + "/tmp/brave-profile", + ); }); it("routes tab operations through the Chrome MCP backend", async () => { @@ -118,22 +165,22 @@ describe("browser server-context existing-session profile", () => { ]) .mockResolvedValueOnce([ { targetId: "7", title: "", url: "https://example.com", type: "page" }, - { targetId: "8", title: "", url: "https://openclaw.ai", type: "page" }, + { targetId: "8", title: "", url: "about:blank", type: "page" }, ]) .mockResolvedValueOnce([ { targetId: "7", title: "", url: "https://example.com", type: "page" }, - { targetId: "8", title: "", url: "https://openclaw.ai", type: "page" }, + { targetId: "8", title: "", url: "about:blank", type: "page" }, ]) .mockResolvedValueOnce([ { targetId: "7", title: "", url: "https://example.com", type: "page" }, - { targetId: "8", title: "", url: "https://openclaw.ai", type: "page" }, + { targetId: "8", title: "", url: "about:blank", type: "page" }, ]); await live.ensureBrowserAvailable(); const tabs = await live.listTabs(); expect(tabs.map((tab) => tab.targetId)).toEqual(["7"]); - const opened = await live.openTab("https://openclaw.ai"); + const opened = await live.openTab("about:blank"); expect(opened.targetId).toBe("8"); const selected = await live.ensureTabAvailable(); @@ -149,7 +196,7 @@ describe("browser server-context existing-session profile", () => { expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile"); expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith( "chrome-live", - "https://openclaw.ai", + "about:blank", "/tmp/brave-profile", ); expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith( diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index a8193c67b21..a67d8730969 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -3,6 +3,7 @@ import { resolveCdpReachabilityPolicy, } from "./cdp-reachability-policy.js"; import { usesFastLoopbackCdpProbeClass } from "./cdp-timeouts.js"; +import { listChromeMcpTabs } from "./chrome-mcp.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; @@ -181,7 +182,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon try { running = await profileCtx.isTransportAvailable(300); if (running) { - const tabs = await profileCtx.listTabs().catch(() => [] as BrowserTab[]); + const tabs = await listChromeMcpTabs(profile.name, profile.userDataDir, { + ephemeral: true, + }).catch(() => [] as BrowserTab[]); tabCount = tabs.filter((t) => t.type === "page").length; } } catch { From ad8737af2c1de311819f9ff0715310df4a8a5eb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:48:00 +0100 Subject: [PATCH 46/93] fix(browser): tighten WS3 status probes # Conflicts: # extensions/browser/src/browser/chrome-mcp.test.ts # extensions/browser/src/browser/chrome-mcp.ts # extensions/browser/src/browser/routes/basic.ts --- .../browser/src/browser/chrome-mcp.test.ts | 32 ++++++++++++++++++- extensions/browser/src/browser/chrome-mcp.ts | 31 ++++++++++++++++++ .../routes/basic.existing-session.test.ts | 23 ++++++++++++- .../server-context.existing-session.test.ts | 2 +- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index 0a9996a2661..38a0995189b 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -98,6 +98,7 @@ function createFakeSession(): ChromeMcpSession { describe("chrome MCP page parsing", () => { beforeEach(async () => { await resetChromeMcpSessionsForTest(); + vi.useRealTimers(); }); afterEach(() => { @@ -474,7 +475,6 @@ describe("chrome MCP page parsing", () => { expect(factoryCalls).toBe(2); expect(tabs).toHaveLength(2); }); - it("reconnects and retries list_pages once when Chrome MCP reports a stale selected page", async () => { let factoryCalls = 0; const factory: ChromeMcpSessionFactory = async () => { @@ -613,4 +613,34 @@ describe("chrome MCP page parsing", () => { expect(factoryCalls).toBe(2); expect(tabs).toHaveLength(2); }); + + it("honors timeoutMs for ephemeral availability probes", async () => { + vi.useFakeTimers(); + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => + ({ + client: { + callTool: vi.fn(), + listTools: vi.fn(), + close: closeMock, + connect: vi.fn(), + }, + transport: { + pid: 123, + }, + ready: new Promise(() => {}), + }) as unknown as ChromeMcpSession; + setChromeMcpSessionFactoryForTest(factory); + + const promise = ensureChromeMcpAvailable("chrome-live", undefined, { + ephemeral: true, + timeoutMs: 50, + }); + const expectation = expect(promise).rejects.toThrow(/timed out after 50ms/i); + + await vi.advanceTimersByTimeAsync(50); + + await expectation; + expect(closeMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index a940bd0fbb8..7bf1b9bd612 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -463,6 +463,37 @@ async function getExistingSession( } } +async function waitForChromeMcpReady( + session: ChromeMcpSession, + profileName: string, + timeoutMs?: number, +): Promise { + if (!timeoutMs || timeoutMs <= 0) { + await session.ready; + return; + } + + let timer: ReturnType | undefined; + try { + await Promise.race([ + session.ready, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new BrowserProfileUnavailableError( + `Chrome MCP existing-session attach for profile "${profileName}" timed out after ${timeoutMs}ms.`, + ), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + async function createEphemeralSession( profileName: string, userDataDir?: string, diff --git a/extensions/browser/src/browser/routes/basic.existing-session.test.ts b/extensions/browser/src/browser/routes/basic.existing-session.test.ts index dd5285dfc7a..345b9b49988 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -63,7 +63,7 @@ describe("basic browser routes", () => { it("maps existing-session status failures to JSON browser errors", async () => { const response = await callBasicRouteWithState({ state: createExistingSessionProfileState({ - isHttpReachable: async () => { + isTransportAvailable: async () => { throw new BrowserProfileUnavailableError("attach failed"); }, }), @@ -109,4 +109,25 @@ describe("basic browser routes", () => { cdpReady: true, }); }); + + it("probes Chrome MCP transport only once for status", async () => { + const isHttpReachable = vi.fn(async () => true); + const isTransportAvailable = vi.fn(async () => true); + + const response = await callBasicRouteWithState({ + state: createExistingSessionProfileState({ + isHttpReachable, + isTransportAvailable, + }), + }); + + expect(response.statusCode).toBe(200); + expect(isTransportAvailable).toHaveBeenCalledTimes(1); + expect(isHttpReachable).not.toHaveBeenCalled(); + expect(response.body).toMatchObject({ + cdpHttp: true, + cdpReady: true, + running: true, + }); + }); }); diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index fad28383291..38c582b2db6 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -105,7 +105,7 @@ describe("browser server-context existing-session profile", () => { expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith( "chrome-live", "/tmp/brave-profile", - { ephemeral: true }, + { ephemeral: true, timeoutMs: 300 }, ); expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile", { ephemeral: true, From 998e09ee008bd8647e793edcf780eec409ad51bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:48:22 +0100 Subject: [PATCH 47/93] docs(changelog): note browser existing-session fixes (#57245) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dc00a622e..5947cf0ceb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai - Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries. - Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar. - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. +- Browser/existing-session: keep Chrome MCP status probes transport-only and ephemeral, and retry stale cached Playwright attaches once so idle profile checks no longer poison the next real attach. (#57245) Thanks @josephbergvinson. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. From a7c8a1ba0d10db7341d232eba05c0c55b01d9daa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:59:28 +0100 Subject: [PATCH 48/93] fix(browser): finish existing-session attach port (#57245) --- extensions/browser/src/browser/chrome-mcp.ts | 31 ------------------- extensions/browser/src/browser/pw-session.ts | 16 ---------- .../browser/server-context.availability.ts | 1 + .../server-context.existing-session.test.ts | 13 ++++++-- 4 files changed, 11 insertions(+), 50 deletions(-) diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index 7bf1b9bd612..a940bd0fbb8 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -463,37 +463,6 @@ async function getExistingSession( } } -async function waitForChromeMcpReady( - session: ChromeMcpSession, - profileName: string, - timeoutMs?: number, -): Promise { - if (!timeoutMs || timeoutMs <= 0) { - await session.ready; - return; - } - - let timer: ReturnType | undefined; - try { - await Promise.race([ - session.ready, - new Promise((_, reject) => { - timer = setTimeout(() => { - reject( - new BrowserProfileUnavailableError( - `Chrome MCP existing-session attach for profile "${profileName}" timed out after ${timeoutMs}ms.`, - ), - ); - }, timeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - async function createEphemeralSession( profileName: string, userDataDir?: string, diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 6a81cfd04f9..18e8b442ce6 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -886,22 +886,6 @@ export async function gotoPageWithNavigationGuard( } } -export async function getPageForTargetId(opts: { - cdpUrl: string; - targetId?: string; -}): Promise { - const reusedCachedBrowser = hasCachedPlaywrightBrowserConnection(opts.cdpUrl); - try { - return await getPageForTargetIdOnce(opts); - } catch (err) { - if (!isRecoverableStalePageSelectionError(err, reusedCachedBrowser)) { - throw err; - } - await closePlaywrightBrowserConnection({ cdpUrl: opts.cdpUrl }); - return await getPageForTargetIdOnce(opts); - } -} - export function refLocator(page: Page, ref: string) { const normalized = ref.startsWith("@") ? ref.slice(1) diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 2a74f778900..480bf048b5a 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -90,6 +90,7 @@ export function createProfileAvailability({ const isTransportAvailable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { + const { ensureChromeMcpAvailable } = await getChromeMcpModule(); await ensureChromeMcpAvailable(profile.name, profile.userDataDir, { ephemeral: true, timeoutMs, diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 38c582b2db6..619ec502909 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "../../test-support/browser-security-runtime.mock.js"; import type { BrowserServerState } from "./server-context.js"; -vi.mock("./chrome-mcp.js", () => ({ +const chromeMcpMock = vi.hoisted(() => ({ closeChromeMcpSession: vi.fn(async () => true), ensureChromeMcpAvailable: vi.fn(async () => {}), focusChromeMcpTab: vi.fn(async () => {}), @@ -20,8 +20,14 @@ vi.mock("./chrome-mcp.js", () => ({ getChromeMcpPid: vi.fn(() => 4321), })); +vi.mock("./chrome-mcp.js", () => chromeMcpMock); + +vi.mock("./chrome-mcp.runtime.js", () => ({ + getChromeMcpModule: vi.fn(async () => chromeMcpMock), +})); + const { createBrowserRouteContext } = await import("./server-context.js"); -const chromeMcp = await import("./chrome-mcp.js"); +const chromeMcp = chromeMcpMock; function makeState(): BrowserServerState { return { @@ -93,7 +99,8 @@ describe("browser server-context existing-session profile", () => { vi.mocked(chromeMcp.ensureChromeMcpAvailable).mockResolvedValueOnce(); vi.mocked(chromeMcp.listChromeMcpTabs).mockRejectedValueOnce(new Error("No page selected")); - await expect(ctx.listProfiles()).resolves.toEqual([ + const profiles = await ctx.listProfiles(); + expect(profiles).toEqual([ expect.objectContaining({ name: "chrome-live", transport: "chrome-mcp", From d957401c7e0d2a039db5eb080c07f94b1d88dc38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:04:28 +0100 Subject: [PATCH 49/93] test(daemon): type launchd kickstart code fake --- src/daemon/launchd.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index da70f09e2ad..6dcde5a955c 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -25,6 +25,7 @@ const state = vi.hoisted(() => ({ bootstrapError: "", bootstrapCode: 1, kickstartError: "", + kickstartCode: 1, kickstartFailuresRemaining: 0, disableError: "", disableCode: 1, @@ -178,7 +179,7 @@ vi.mock("./exec-file.js", () => ({ if (call[0] === "kickstart") { if (state.kickstartError && state.kickstartFailuresRemaining > 0) { state.kickstartFailuresRemaining -= 1; - return { stdout: "", stderr: state.kickstartError, code: 1 }; + return { stdout: "", stderr: state.kickstartError, code: state.kickstartCode }; } state.serviceLoaded = true; state.serviceRunning = true; @@ -262,6 +263,7 @@ beforeEach(() => { state.bootstrapError = ""; state.bootstrapCode = 1; state.kickstartError = ""; + state.kickstartCode = 1; state.kickstartFailuresRemaining = 0; state.disableError = ""; state.disableCode = 1; From 22aa402b640a7a4e2d1e303d594241026077ae40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:08:09 +0100 Subject: [PATCH 50/93] test(daemon): mock launchd plist reads --- src/daemon/launchd.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 6dcde5a955c..0f7dc85b5f1 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -241,6 +241,14 @@ vi.mock("node:fs/promises", async () => { unlink: vi.fn(async (p: string) => { state.files.delete(p); }), + readFile: vi.fn(async (p: string) => { + const key = p; + const value = state.files.get(key); + if (value !== undefined) { + return value; + } + throw new Error(`ENOENT: no such file or directory, open '${key}'`); + }), writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); From 85cab8b5162434340db149b7586fade3a88f0a44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:09:42 +0100 Subject: [PATCH 51/93] test: fix launchd restart mock state --- src/daemon/launchd.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 0f7dc85b5f1..0ba79559850 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -238,6 +238,14 @@ vi.mock("node:fs/promises", async () => { } throw new Error(`ENOENT: no such file or directory, chmod '${key}'`); }), + readFile: vi.fn(async (p: string) => { + const key = p; + const data = state.files.get(key); + if (data !== undefined) { + return data; + } + throw new Error(`ENOENT: no such file or directory, open '${key}'`); + }), unlink: vi.fn(async (p: string) => { state.files.delete(p); }), From 07cf1dd65c937c75cf48a1645ea580404f5056b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:10:32 +0100 Subject: [PATCH 52/93] test: remove duplicate launchd read mock --- src/daemon/launchd.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 0ba79559850..18db288d93c 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -249,14 +249,6 @@ vi.mock("node:fs/promises", async () => { unlink: vi.fn(async (p: string) => { state.files.delete(p); }), - readFile: vi.fn(async (p: string) => { - const key = p; - const value = state.files.get(key); - if (value !== undefined) { - return value; - } - throw new Error(`ENOENT: no such file or directory, open '${key}'`); - }), writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); From 2a96ea4d726a18c57e185925afdffd113cd5e283 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:11:26 +0100 Subject: [PATCH 53/93] test(agents): cover committed delivery fallback --- .../run.incomplete-turn.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) 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 6a4191ecc49..88546d6a4c3 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -17,6 +17,7 @@ import { DEFAULT_REASONING_ONLY_RETRY_LIMIT, EMPTY_RESPONSE_RETRY_INSTRUCTION, extractPlanningOnlyPlanDetails, + hasCommittedUserVisibleToolDelivery, isLikelyExecutionAckPrompt, PLANNING_ONLY_RETRY_INSTRUCTION, REASONING_ONLY_RETRY_INSTRUCTION, @@ -919,6 +920,73 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toContain("verify before retrying"); }); + it("does not treat empty committed messaging arrays as user-visible delivery", () => { + expect( + hasCommittedUserVisibleToolDelivery({ + messagingToolSentTexts: [" "], + messagingToolSentMediaUrls: [], + }), + ).toBe(false); + }); + + it("treats committed messaging media as user-visible delivery", () => { + expect( + hasCommittedUserVisibleToolDelivery({ + messagingToolSentTexts: [], + messagingToolSentMediaUrls: ["file:///tmp/render.png"], + }), + ).toBe(true); + }); + + it("treats committed messaging text as replay-invalid side effect metadata", () => { + expect( + buildAttemptReplayMetadata({ + toolMetas: [], + didSendViaMessagingTool: false, + messagingToolSentTexts: ["Delivered through the message tool."], + messagingToolSentMediaUrls: [], + }), + ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); + }); + + it("treats committed messaging media as replay-invalid side effect metadata", () => { + expect( + buildAttemptReplayMetadata({ + toolMetas: [], + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: ["file:///tmp/render.png"], + }), + ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); + }); + + it("leaves committed delivery plus tool errors to the tool-error payload path", () => { + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered through the message tool."], + lastToolError: { + toolName: "message", + meta: "send", + error: "delivery failed for second target", + }, + lastAssistant: { + role: "assistant", + stopReason: "error", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toBeNull(); + }); + it("does not retry reasoning-only GPT turns after side effects", () => { const retryInstruction = resolveReasoningOnlyRetryInstruction({ provider: "openai", From 33d5ebbff7bceb782b314de14ebd9a213ba31869 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:11:29 +0100 Subject: [PATCH 54/93] test(daemon): read launchd fixture files --- src/daemon/launchd.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 18db288d93c..84f931adc9a 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -249,6 +249,14 @@ vi.mock("node:fs/promises", async () => { unlink: vi.fn(async (p: string) => { state.files.delete(p); }), + readFile: vi.fn(async (p: string) => { + const key = p; + const data = state.files.get(key); + if (data !== undefined) { + return data; + } + throw new Error(`ENOENT: no such file or directory, read '${key}'`); + }), writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); @@ -490,7 +498,6 @@ describe("launchd install", () => { const plistPath = resolveLaunchAgentPlistPath(env); state.serviceLoaded = false; state.kickstartError = "Could not find service"; - state.kickstartCode = 113; state.kickstartFailuresRemaining = 1; state.files.set( plistPath, From b8b270d5b89b43e3833b67c526f2f0eed12f753e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 25 Apr 2026 08:12:10 +0200 Subject: [PATCH 55/93] fix(daemon): add Nix Home Manager PATH support Add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux. Includes ~/.nix-profile/bin fallback when NIX_PROFILES is absent, honors NIX_PROFILES right-to-left precedence when present, and covers the service PATH resolver with focused unit tests. Closes #44402. --- src/daemon/service-env.test.ts | 96 ++++++++++++++++++++++++++++++++++ src/daemon/service-env.ts | 23 ++++++++ 2 files changed, 119 insertions(+) diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 9edb2d84d03..79216dadcf0 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -172,6 +172,102 @@ describe("getMinimalServicePathParts - Linux user directories", () => { }); }); +describe("getMinimalServicePathParts - Nix Home Manager", () => { + it("falls back to default Nix profile when NIX_PROFILES is absent on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + }); + + expect(result).toContain("/home/testuser/.nix-profile/bin"); + }); + + it("falls back to default Nix profile when NIX_PROFILES is absent on macOS", () => { + const result = getMinimalServicePathParts({ + platform: "darwin", + home: "/Users/testuser", + }); + + expect(result).toContain("/Users/testuser/.nix-profile/bin"); + }); + + it("places rightmost NIX_PROFILES entry before leftmost on Linux", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + NIX_PROFILES: "/nix/var/nix/profiles/default /home/testuser/.nix-profile", + }, + }); + + const userIdx = result.indexOf("/home/testuser/.nix-profile/bin"); + const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); + expect(userIdx).toBeGreaterThan(-1); + expect(defaultIdx).toBeGreaterThan(-1); + expect(userIdx).toBeLessThan(defaultIdx); + }); + + it("places rightmost NIX_PROFILES entry before leftmost on macOS", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "darwin", + env: { + HOME: "/Users/testuser", + NIX_PROFILES: "/nix/var/nix/profiles/default /Users/testuser/.nix-profile", + }, + }); + + const userIdx = result.indexOf("/Users/testuser/.nix-profile/bin"); + const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); + expect(userIdx).toBeGreaterThan(-1); + expect(defaultIdx).toBeGreaterThan(-1); + expect(userIdx).toBeLessThan(defaultIdx); + }); + + it("includes single Nix profile from NIX_PROFILES on Linux", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + NIX_PROFILES: "/nix/var/nix/profiles/per-user/testuser/profile", + }, + }); + + expect(result).toContain("/nix/var/nix/profiles/per-user/testuser/profile/bin"); + }); + + it("includes single Nix profile from NIX_PROFILES on macOS", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "darwin", + env: { + HOME: "/Users/testuser", + NIX_PROFILES: "/nix/var/nix/profiles/per-user/testuser/profile", + }, + }); + + expect(result).toContain("/nix/var/nix/profiles/per-user/testuser/profile/bin"); + }); + + it("preserves Nix precedence across three profiles", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + NIX_PROFILES: + "/nix/var/nix/profiles/default /nix/var/nix/profiles/per-user/testuser/custom /home/testuser/.nix-profile", + }, + }); + + const userIdx = result.indexOf("/home/testuser/.nix-profile/bin"); + const customIdx = result.indexOf("/nix/var/nix/profiles/per-user/testuser/custom/bin"); + const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); + expect(userIdx).toBeGreaterThan(-1); + expect(customIdx).toBeGreaterThan(-1); + expect(defaultIdx).toBeGreaterThan(-1); + expect(userIdx).toBeLessThan(customIdx); + expect(customIdx).toBeLessThan(defaultIdx); + }); +}); + describe("buildMinimalServicePath", () => { const splitPath = (value: string, platform: NodeJS.Platform) => value.split(platform === "win32" ? path.win32.delimiter : path.posix.delimiter); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 0c238df5a6c..3460af66865 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -106,6 +106,23 @@ function addCommonEnvConfiguredBinDirs( addNonEmptyDir(dirs, appendSubdir(env?.ASDF_DATA_DIR, "shims")); } +// Nix shell precedence: rightmost profile in NIX_PROFILES = highest priority. +// When NIX_PROFILES is absent, fall back to the default single-user profile. +function addNixProfileBinDirs( + dirs: string[], + home: string, + env: Record | undefined, +): void { + const nixProfiles = env?.NIX_PROFILES?.trim(); + if (nixProfiles) { + for (const profile of nixProfiles.split(/\s+/).toReversed()) { + addNonEmptyDir(dirs, appendSubdir(profile, "bin")); + } + } else { + dirs.push(`${home}/.nix-profile/bin`); + } +} + function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { if (platform === "darwin") { return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]; @@ -148,6 +165,9 @@ export function resolveDarwinUserBinDirs( // Common user bin directories addCommonUserBinDirs(dirs, home); + // Nix Home Manager (cross-platform) + addNixProfileBinDirs(dirs, home, env); + // Node version managers - macOS specific paths // nvm: no stable default path, depends on user's shell configuration // fnm: macOS default is ~/Library/Application Support/fnm, not ~/.fnm @@ -182,6 +202,9 @@ export function resolveLinuxUserBinDirs( // Common user bin directories addCommonUserBinDirs(dirs, home); + // Nix Home Manager (cross-platform) + addNixProfileBinDirs(dirs, home, env); + // Node version managers dirs.push(`${home}/.nvm/current/bin`); // nvm with current symlink dirs.push(`${home}/.fnm/current/bin`); // fnm From 017252e4f8e76e6e95904bf8f798097de8dd36f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:12:09 +0100 Subject: [PATCH 56/93] test(daemon): remove duplicate launchd read mock --- src/daemon/launchd.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 84f931adc9a..3cacdeee259 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -249,14 +249,6 @@ vi.mock("node:fs/promises", async () => { unlink: vi.fn(async (p: string) => { state.files.delete(p); }), - readFile: vi.fn(async (p: string) => { - const key = p; - const data = state.files.get(key); - if (data !== undefined) { - return data; - } - throw new Error(`ENOENT: no such file or directory, read '${key}'`); - }), writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); From bd60df3e53af85eb2ec87cec2918e4a22edf15bc Mon Sep 17 00:00:00 2001 From: Mark Goldenstein Date: Fri, 24 Apr 2026 20:56:34 -0700 Subject: [PATCH 57/93] fix: silence cron exec completion noise --- src/agents/bash-process-registry.ts | 6 ++ src/agents/bash-tools.exec-host-node.test.ts | 31 ++++++ src/agents/bash-tools.exec-host-node.ts | 4 +- src/agents/bash-tools.exec-runtime.test.ts | 97 +++++++++++++++++++ src/agents/bash-tools.exec-runtime.ts | 5 +- src/agents/bash-tools.exec.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 5 +- src/cron/isolated-agent/run-executor.ts | 9 ++ .../run.message-tool-policy.test.ts | 56 +++++++++++ src/cron/isolated-agent/run.ts | 1 + 10 files changed, 212 insertions(+), 3 deletions(-) diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 486ced8280d..851f10e309d 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -1,4 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { TerminationReason } from "../process/supervisor/types.js"; import type { DeliveryContext } from "../utils/delivery-context.js"; import { createSessionSlug as createSessionSlugId } from "./session-slug.js"; @@ -51,6 +52,7 @@ export interface ProcessSession { tail: string; exitCode?: number | null; exitSignal?: NodeJS.Signals | number | null; + exitReason?: TerminationReason; exited: boolean; truncated: boolean; backgrounded: boolean; @@ -68,6 +70,7 @@ export interface FinishedSession { status: ProcessStatus; exitCode?: number | null; exitSignal?: NodeJS.Signals | number | null; + exitReason?: TerminationReason; aggregated: string; tail: string; truncated: boolean; @@ -150,10 +153,12 @@ export function markExited( exitCode: number | null, exitSignal: NodeJS.Signals | number | null, status: ProcessStatus, + exitReason?: TerminationReason, ) { session.exited = true; session.exitCode = exitCode; session.exitSignal = exitSignal; + session.exitReason = exitReason; session.tail = tail(session.aggregated, 2000); moveToFinished(session, status); } @@ -209,6 +214,7 @@ function moveToFinished(session: ProcessSession, status: ProcessStatus) { status, exitCode: session.exitCode, exitSignal: session.exitSignal, + exitReason: session.exitReason, aggregated: session.aggregated, tail: session.tail, truncated: session.truncated, diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 4451a49c6da..83030c15436 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -148,6 +148,7 @@ let executeNodeHostCommand: typeof import("./bash-tools.exec-host-node.js").exec type MockNodeInvokeParams = { command?: string; + params?: Record; }; describe("executeNodeHostCommand", () => { @@ -276,6 +277,36 @@ describe("executeNodeHostCommand", () => { ); }); + it("suppresses node completion events when notifyOnExit is disabled", async () => { + requiresExecApprovalMock.mockReturnValue(false); + + await executeNodeHostCommand({ + command: "bun ./script.ts", + workdir: "/tmp/work", + env: {}, + security: "full", + ask: "off", + defaultTimeoutSec: 30, + approvalRunningNoticeMs: 0, + warnings: [], + agentId: "requested-agent", + sessionKey: "requested-session", + notifyOnExit: false, + }); + + expect(callGatewayToolMock).toHaveBeenNthCalledWith( + 2, + "node.invoke", + expect.anything(), + expect.objectContaining({ + command: "system.run", + params: expect.objectContaining({ + suppressNotifyOnExit: true, + }), + }), + ); + }); + it("denies timed-out inline-eval requests instead of invoking the node", async () => { detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT); resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null); diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 73e8922a8aa..27d7fd092c4 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -53,6 +53,7 @@ export type ExecuteNodeHostCommandParams = { approvalRunningNoticeMs: number; warnings: string[]; notifySessionKey?: string; + notifyOnExit?: boolean; trustedSafeBinDirs?: ReadonlySet; }; @@ -228,7 +229,8 @@ export async function executeNodeHostCommand( ? "allow-once" : (approvalDecision ?? undefined), runId: runId ?? undefined, - suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined, + suppressNotifyOnExit: + suppressNotifyOnExit === true || params.notifyOnExit === false ? true : undefined, }, idempotencyKey: crypto.randomUUID(), }) satisfies Record; diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index 54ff1ee05bb..b2c96c3ae7b 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -2,6 +2,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const requestHeartbeatNowMock = vi.hoisted(() => vi.fn()); const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); +const supervisorMock = vi.hoisted(() => ({ + spawn: vi.fn(), +})); vi.mock("../infra/heartbeat-wake.js", () => ({ requestHeartbeatNow: requestHeartbeatNowMock, @@ -11,22 +14,38 @@ vi.mock("../infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventMock, })); +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: supervisorMock.spawn, + }), +})); + +let markBackgrounded: typeof import("./bash-process-registry.js").markBackgrounded; let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome; let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode; let emitExecSystemEvent: typeof import("./bash-tools.exec-runtime.js").emitExecSystemEvent; let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").formatExecFailureReason; let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget; +let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess; beforeAll(async () => { + ({ markBackgrounded } = await import("./bash-process-registry.js")); ({ buildExecExitOutcome, detectCursorKeyMode, emitExecSystemEvent, formatExecFailureReason, resolveExecTarget, + runExecProcess, } = await import("./bash-tools.exec-runtime.js")); }); +beforeEach(() => { + requestHeartbeatNowMock.mockClear(); + enqueueSystemEventMock.mockClear(); + supervisorMock.spawn.mockReset(); +}); + describe("detectCursorKeyMode", () => { it("returns null when no toggle found", () => { expect(detectCursorKeyMode("hello world")).toBe(null); @@ -295,6 +314,84 @@ describe("resolveExecTarget", () => { }); }); +describe("exec notifyOnExit suppression", () => { + async function runBackgroundedExit(params: { + reason: "manual-cancel" | "overall-timeout"; + stdout?: string; + }) { + supervisorMock.spawn.mockImplementationOnce( + async (input: { onStdout?: (chunk: string) => void }) => { + if (params.stdout) { + input.onStdout?.(params.stdout); + } + return { + runId: "run-1", + startedAtMs: Date.now(), + pid: 123, + wait: async () => { + await new Promise((resolve) => setImmediate(resolve)); + return { + reason: params.reason, + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 10, + stdout: "", + stderr: "", + timedOut: params.reason === "overall-timeout", + noOutputTimedOut: false, + }; + }, + cancel: vi.fn(), + }; + }, + ); + + const run = await runExecProcess({ + command: "sleep 999", + workdir: "/tmp", + env: {}, + usePty: false, + warnings: [], + maxOutput: 1000, + pendingMaxOutput: 1000, + notifyOnExit: true, + notifyOnExitEmptySuccess: false, + sessionKey: "agent:main:main", + timeoutSec: null, + }); + markBackgrounded(run.session); + return await run.promise; + } + + it("keeps manual-cancelled no-output background execs silent", async () => { + const outcome = await runBackgroundedExit({ reason: "manual-cancel" }); + + expect(outcome.status).toBe("failed"); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); + + it("notifies for manual-cancelled background execs with output", async () => { + await runBackgroundedExit({ reason: "manual-cancel", stdout: "partial output\n" }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + expect.stringContaining("partial output"), + expect.objectContaining({ sessionKey: "agent:main:main" }), + ); + expect(requestHeartbeatNowMock).toHaveBeenCalled(); + }); + + it("still notifies for no-output background exec timeouts", async () => { + await runBackgroundedExit({ reason: "overall-timeout" }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + expect.stringContaining("Exec failed"), + expect.objectContaining({ sessionKey: "agent:main:main" }), + ); + expect(requestHeartbeatNowMock).toHaveBeenCalled(); + }); +}); + describe("emitExecSystemEvent", () => { beforeEach(() => { requestHeartbeatNowMock.mockClear(); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 81554ceb17b..bc9529a454a 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -291,6 +291,9 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile const output = compactNotifyOutput( tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); + if (status === "failed" && session.exitReason === "manual-cancel" && !output) { + return; + } if (status === "completed" && !output && session.notifyOnExitEmptySuccess !== true) { return; } @@ -783,7 +786,7 @@ export async function runExecProcess(opts: { timeoutSec: opts.timeoutSec, }); - markExited(session, exit.exitCode, exit.exitSignal, outcome.status); + markExited(session, exit.exitCode, exit.exitSignal, outcome.status, exit.reason); maybeNotifyOnExit(session, outcome.status); if (!session.child && session.stdin) { session.stdin.destroyed = true; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 888cecef362..00dd918b403 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1628,6 +1628,7 @@ export function createExecTool( approvalRunningNoticeMs, warnings, notifySessionKey, + notifyOnExit, trustedSafeBinDirs, }); } diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 49e4fda940e..cfc13931a91 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -109,7 +109,10 @@ export type RunEmbeddedPiAgentParams = { bootstrapPromptWarningSignaturesSeen?: string[]; /** Last shown bootstrap truncation warning signature for this session. */ bootstrapPromptWarningSignature?: string; - execOverrides?: Pick; + execOverrides?: Pick< + ExecToolDefaults, + "host" | "security" | "ask" | "node" | "notifyOnExit" | "notifyOnExitEmptySuccess" + >; bashElevated?: ExecElevatedDefaults; timeoutMs: number; runId: string; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 932dbce77a1..0148d7e966c 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -69,6 +69,7 @@ export function createCronPromptExecutor(params: { thinkLevel: ThinkLevel | undefined; timeoutMs: number; messageChannel: string | undefined; + deliveryRequested: boolean; resolvedDelivery: { accountId?: string; to?: string; @@ -196,6 +197,12 @@ export function createCronPromptExecutor(params: { bootstrapContextMode: params.agentPayload?.lightContext ? "lightweight" : undefined, bootstrapContextRunKind: "cron", toolsAllow: params.agentPayload?.toolsAllow, + execOverrides: params.deliveryRequested + ? undefined + : { + notifyOnExit: false, + notifyOnExitEmptySuccess: false, + }, runId: params.cronSession.sessionEntry.sessionId, requireExplicitMessageTarget: params.toolPolicy.requireExplicitMessageTarget, disableMessageTool: params.toolPolicy.disableMessageTool, @@ -263,6 +270,7 @@ export async function executeCronRun(params: { isAborted: () => boolean; thinkLevel: ThinkLevel | undefined; timeoutMs: number; + deliveryRequested: boolean; runStartedAt?: number; }): Promise { const resolvedVerboseLevel: VerboseLevel = @@ -286,6 +294,7 @@ export async function executeCronRun(params: { thinkLevel: params.thinkLevel, timeoutMs: params.timeoutMs, messageChannel: params.resolvedDelivery.channel, + deliveryRequested: params.deliveryRequested, resolvedDelivery: params.resolvedDelivery, toolPolicy: params.toolPolicy, skillsSnapshot: params.skillsSnapshot, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 85c9304d8ff..08ad99b169b 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -241,6 +241,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { thinkLevel: undefined, timeoutMs: 60_000, messageChannel: "messagechat", + deliveryRequested: false, toolPolicy: { requireExplicitMessageTarget: false, disableMessageTool: false, @@ -270,6 +271,48 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); }); + it('suppresses automatic exec completion notifications when delivery.mode is "none"', async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: false, + mode: "none", + channel: "topicchat", + to: "room#42", + threadId: 42, + }); + resolveDeliveryTargetMock.mockResolvedValue({ + ok: true, + channel: "topicchat", + to: "room#42", + threadId: 42, + accountId: undefined, + error: undefined, + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeMessageToolPolicyJob({ + mode: "none", + channel: "topicchat", + to: "room#42", + threadId: 42, + }), + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ + disableMessageTool: false, + forceMessageTool: true, + messageChannel: "topicchat", + messageTo: "room#42", + messageThreadId: 42, + execOverrides: { + notifyOnExit: false, + notifyOnExitEmptySuccess: false, + }, + }); + }); + it("preserves explicit delivery targets for agent-initiated messaging when delivery.mode is none", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue({ @@ -414,6 +457,19 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); }); + it("keeps automatic exec completion notifications when announce delivery is active", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeAnnounceMessageToolJob(), + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.execOverrides).toBeUndefined(); + }); + it("disables the message tool when webhook delivery is active", async () => { await expectMessageToolDisabledForPlan({ requested: false, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4cb619d33ce..467d5ea472e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -977,6 +977,7 @@ export async function runCronIsolatedAgentTurn(params: { isAborted, thinkLevel: prepared.context.thinkLevel, timeoutMs: prepared.context.timeoutMs, + deliveryRequested: prepared.context.deliveryRequested, }); if (isAborted()) { return prepared.context.withRunSession({ status: "error", error: abortReason() }); From 36eae5a2c7501e6a349e987e1d02671696b6abad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:09:01 +0100 Subject: [PATCH 58/93] fix: tighten silent cron exec notifications --- CHANGELOG.md | 1 + src/cron/isolated-agent/run-executor.ts | 14 ++++++------ .../run.message-tool-policy.test.ts | 22 ++++++++++++++++++- src/cron/isolated-agent/run.ts | 4 +++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5947cf0ceb4..84b70a1fe0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar. - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Browser/existing-session: keep Chrome MCP status probes transport-only and ephemeral, and retry stale cached Playwright attaches once so idle profile checks no longer poison the next real attach. (#57245) Thanks @josephbergvinson. +- Cron/exec: suppress automatic background exec completion wakes only for silent cron jobs with `delivery.mode="none"` while keeping webhook and announce runs observable. (#71391) Thanks @goldmar. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 0148d7e966c..385301a1adf 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -69,7 +69,7 @@ export function createCronPromptExecutor(params: { thinkLevel: ThinkLevel | undefined; timeoutMs: number; messageChannel: string | undefined; - deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; resolvedDelivery: { accountId?: string; to?: string; @@ -197,12 +197,12 @@ export function createCronPromptExecutor(params: { bootstrapContextMode: params.agentPayload?.lightContext ? "lightweight" : undefined, bootstrapContextRunKind: "cron", toolsAllow: params.agentPayload?.toolsAllow, - execOverrides: params.deliveryRequested - ? undefined - : { + execOverrides: params.suppressExecNotifyOnExit + ? { notifyOnExit: false, notifyOnExitEmptySuccess: false, - }, + } + : undefined, runId: params.cronSession.sessionEntry.sessionId, requireExplicitMessageTarget: params.toolPolicy.requireExplicitMessageTarget, disableMessageTool: params.toolPolicy.disableMessageTool, @@ -270,7 +270,7 @@ export async function executeCronRun(params: { isAborted: () => boolean; thinkLevel: ThinkLevel | undefined; timeoutMs: number; - deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; runStartedAt?: number; }): Promise { const resolvedVerboseLevel: VerboseLevel = @@ -294,7 +294,7 @@ export async function executeCronRun(params: { thinkLevel: params.thinkLevel, timeoutMs: params.timeoutMs, messageChannel: params.resolvedDelivery.channel, - deliveryRequested: params.deliveryRequested, + suppressExecNotifyOnExit: params.suppressExecNotifyOnExit, resolvedDelivery: params.resolvedDelivery, toolPolicy: params.toolPolicy, skillsSnapshot: params.skillsSnapshot, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 08ad99b169b..4fbe5cc31a9 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -241,7 +241,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { thinkLevel: undefined, timeoutMs: 60_000, messageChannel: "messagechat", - deliveryRequested: false, + suppressExecNotifyOnExit: true, toolPolicy: { requireExplicitMessageTarget: false, disableMessageTool: false, @@ -470,6 +470,26 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.execOverrides).toBeUndefined(); }); + it("keeps automatic exec completion notifications when webhook delivery is active", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: false, + mode: "webhook", + to: "https://example.invalid/cron", + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeMessageToolPolicyJob({ + mode: "webhook", + to: "https://example.invalid/cron", + }), + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.execOverrides).toBeUndefined(); + }); + it("disables the message tool when webhook delivery is active", async () => { await expectMessageToolDisabledForPlan({ requested: false, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 467d5ea472e..9b6bdce576b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -408,6 +408,7 @@ type PreparedCronRunContext = { deliveryPlan: CronDeliveryPlan; resolvedDelivery: ResolvedCronDeliveryTarget; deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; toolPolicy: ReturnType; skillsSnapshot: SkillSnapshot; liveSelection: CronLiveSelection; @@ -696,6 +697,7 @@ async function prepareCronRunContext(params: { deliveryPlan, resolvedDelivery, deliveryRequested, + suppressExecNotifyOnExit: deliveryPlan.mode === "none", toolPolicy, skillsSnapshot, liveSelection, @@ -977,7 +979,7 @@ export async function runCronIsolatedAgentTurn(params: { isAborted, thinkLevel: prepared.context.thinkLevel, timeoutMs: prepared.context.timeoutMs, - deliveryRequested: prepared.context.deliveryRequested, + suppressExecNotifyOnExit: prepared.context.suppressExecNotifyOnExit, }); if (isAborted()) { return prepared.context.withRunSession({ status: "error", error: abortReason() }); From 2aa313cd905cf2e8af9ec93a931579c80920588a Mon Sep 17 00:00:00 2001 From: ToToKr Date: Sat, 25 Apr 2026 15:18:10 +0900 Subject: [PATCH 59/93] fix(feishu): prevent duplicate message after streaming card close (#67791) (#68491) * fix(feishu): prevent duplicate message after streaming card close (#67791) When onIdle closed the streaming card before the final delivery arrived, the streamed text was not tracked in deliveredFinalTexts. The subsequent final payload bypassed the streaming?.isActive() guard (already closed) and fell through to the non-streaming path, sending the same content as a redundant text/card message. Track raw streamText in deliveredFinalTexts when closeStreaming finalizes the card so the duplicate-final check catches it. * test(feishu): cover idle streaming final dedupe --------- Co-authored-by: Vincent Koc --- .../feishu/src/reply-dispatcher.test.ts | 21 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index a4a58d7759d..190bc007a4a 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -364,6 +364,27 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); + + it("skips final text already closed by idle streaming", async () => { + const { result, options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), + }); + + await options.onReplyStart?.(); + result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" }); + await options.onIdle?.(); + await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" }); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", { + note: "Agent: agent", + }); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + }); + it("suppresses duplicate final text while still sending media", async () => { const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "plain final" }, { kind: "final" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index fc2e18eb07a..b4fa4f909d9 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -311,6 +311,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); await streaming.close(text, { note: finalNote }); + // Track the raw streamed text so the duplicate-final check in deliver() + // can skip the redundant text delivery that arrives after onIdle closes + // the streaming card. + if (streamText) { + deliveredFinalTexts.add(streamText); + } } streaming = null; streamingStartPromise = null; From b59ba1dc8ea587e05534554a92c8fce287b53e95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:18:25 -0700 Subject: [PATCH 60/93] docs: cover new contextInjection 'never' mode and Nix daemon PATH support Two recent code changes lacked or had only partial doc coverage: - contextInjection 'never' (#65006, xDarkicex): the new mode is now documented under agents.defaults.contextInjection, alongside the existing 'continuation-skip' mode, with guidance on when to use it (custom context engines, native runtimes that own their prompt). - Nix Home Manager daemon PATH (#44402, jerome.benoit): document the service PATH auto-discovery (NIX_PROFILES right-to-left precedence and ~/.nix-profile/bin fallback) under the Nix install page. Also sentence-case three Title-Cased headings on the Nix page ('What You Get', 'Quick Start', 'Nix Mode Runtime Behavior') and drop a duplicate body H1 that restated the frontmatter title. --- docs/gateway/config-agents.md | 1 + docs/install/nix.md | 22 ++++++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 9224859790d..a9b98244602 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -72,6 +72,7 @@ Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md` Controls when workspace bootstrap files are injected into the system prompt. Default: `"always"`. - `"continuation-skip"`: safe continuation turns (after a completed assistant response) skip workspace bootstrap re-injection, reducing prompt size. Heartbeat runs and post-compaction retries still rebuild context. +- `"never"`: disable workspace bootstrap and context-file injection on every turn. Use this only for agents that fully own their prompt lifecycle (custom context engines, native runtimes that build their own context, or specialized bootstrap-free workflows). Heartbeat and compaction-recovery turns also skip injection. ```json5 { diff --git a/docs/install/nix.md b/docs/install/nix.md index 371cee007a2..df7efe88ff4 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -7,22 +7,20 @@ read_when: title: "Nix" --- -# Nix Installation - -Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** -- a batteries-included Home Manager module. +Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** — a batteries-included Home Manager module. The [nix-openclaw](https://github.com/openclaw/nix-openclaw) repo is the source of truth for Nix installation. This page is a quick overview. -## What You Get +## What you get - Gateway + macOS app + tools (whisper, spotify, cameras) -- all pinned - Launchd service that survives reboots - Plugin system with declarative config - Instant rollback: `home-manager switch --rollback` -## Quick Start +## Quick start @@ -50,7 +48,7 @@ The [nix-openclaw](https://github.com/openclaw/nix-openclaw) repo is the source See the [nix-openclaw README](https://github.com/openclaw/nix-openclaw) for full module options and examples. -## Nix Mode Runtime Behavior +## Nix-mode runtime behavior When `OPENCLAW_NIX_MODE=1` is set (automatic with nix-openclaw), OpenClaw enters a deterministic mode that disables auto-install flows. @@ -82,6 +80,18 @@ OpenClaw reads JSON5 config from `OPENCLAW_CONFIG_PATH` and stores mutable data | `OPENCLAW_STATE_DIR` | `~/.openclaw` | | `OPENCLAW_CONFIG_PATH` | `$OPENCLAW_STATE_DIR/openclaw.json` | +### Service PATH discovery + +The launchd/systemd gateway service auto-discovers Nix-profile binaries so +plugins and tools that shell out to `nix`-installed executables work without +manual PATH setup: + +- When `NIX_PROFILES` is set, every entry is added to the service PATH in + right-to-left precedence (matches Nix shell precedence — rightmost wins). +- When `NIX_PROFILES` is unset, `~/.nix-profile/bin` is added as a fallback. + +This applies to both macOS launchd and Linux systemd service environments. + ## Related - [nix-openclaw](https://github.com/openclaw/nix-openclaw) -- full setup guide From d7fae7a5e72eb4d36f3f957edf7c1ba38fbd523c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:21:40 -0700 Subject: [PATCH 61/93] docs(changelog): note Feishu streaming dedupe fix (#71431) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b70a1fe0f..444dad8495c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI. - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. From 982230f460b5449fc4ec28fe18a999977c6c7d91 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:22:53 -0500 Subject: [PATCH 62/93] Refine tool access controls (#71405) * feat(ui): refine tool access controls * fix(ui): tighten tool access scanning * fix(ui): keep tool access toggles visible (#71405) * test(daemon): cover launchd restart fallback plist reads (#71405) * test(daemon): drop duplicate launchd read mock (#71405) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 13 + ui/src/styles/components.css | 547 +++++++++++++++++- ui/src/styles/layout.css | 2 + ui/src/ui/app-render.helpers.browser.test.ts | 69 +++ ui/src/ui/app-render.helpers.ts | 37 +- ...agents-panels-tools-skills.browser.test.ts | 217 ++++++- ui/src/ui/views/agents-panels-tools-skills.ts | 545 ++++++++++++----- 7 files changed, 1263 insertions(+), 167 deletions(-) create mode 100644 ui/src/ui/app-render.helpers.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 444dad8495c..58a4c921196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +- Control UI: refine the agent Tool Access panel with compact live-tool chips, + collapsible tool groups, direct per-tool toggles, and clearer runtime/source + provenance. (#71405) Thanks @BunsDev. + +### Fixes + +- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. +- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt + ## 2026.4.24 (Unreleased) ### Breaking diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 68e762cf62c..5422887a59a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -753,6 +753,96 @@ stroke-linejoin: round; } +.chat-controls { + display: inline-flex; + align-items: center; + gap: 8px; + position: relative; + overflow: visible; +} + +.chat-controls__separator { + color: var(--muted); + font-size: 12px; + line-height: 1; + user-select: none; +} + +.chat-controls .btn--icon[data-tooltip] { + position: relative; + overflow: visible; +} + +.chat-controls .btn--icon[data-tooltip]::before, +.chat-controls .btn--icon[data-tooltip]::after { + position: absolute; + left: 50%; + pointer-events: none; + opacity: 0; + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); + z-index: 40; +} + +.chat-controls .btn--icon[data-tooltip]::before { + content: ""; + top: calc(100% + 4px); + border-width: 6px; + border-style: solid; + border-color: transparent transparent color-mix(in srgb, var(--card) 94%, black 6%) transparent; + transform: translate(-50%, -3px); +} + +.chat-controls .btn--icon[data-tooltip]::after { + content: attr(data-tooltip); + top: calc(100% + 10px); + min-width: max-content; + max-width: min(260px, 60vw); + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 94%, black 6%); + box-shadow: + 0 10px 28px rgba(0, 0, 0, 0.24), + 0 0 0 1px rgba(255, 255, 255, 0.04); + color: var(--text); + font-size: 11px; + font-weight: 500; + line-height: 1.35; + text-align: center; + white-space: normal; + transform: translate(-50%, -4px); +} + +@media (hover: hover) { + .chat-controls .btn--icon[data-tooltip]:hover::before, + .chat-controls .btn--icon[data-tooltip]:hover::after { + opacity: 1; + } + + .chat-controls .btn--icon[data-tooltip]:hover::before { + transform: translate(-50%, 0); + } + + .chat-controls .btn--icon[data-tooltip]:hover::after { + transform: translate(-50%, 0); + } +} + +.chat-controls .btn--icon[data-tooltip]:focus-visible::before, +.chat-controls .btn--icon[data-tooltip]:focus-visible::after { + opacity: 1; +} + +.chat-controls .btn--icon[data-tooltip]:focus-visible::before { + transform: translate(-50%, 0); +} + +.chat-controls .btn--icon[data-tooltip]:focus-visible::after { + transform: translate(-50%, 0); +} + .btn--ghost { border-color: transparent; background: transparent; @@ -3691,62 +3781,483 @@ td.data-table-key-col { } } -.agent-tools-meta { +.agent-tools-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.agent-tools-header__intro { + min-width: 0; +} + +.agent-tools-header__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.agent-tools-overview { + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1.75fr) minmax(280px, 0.9fr); + align-items: start; + margin-top: 16px; +} + +.agent-tools-overview__primary { display: grid; gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.agent-tools-pane { + display: grid; + gap: 10px; + min-width: 0; +} + +.agent-tools-facts { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; +} + +.agent-tools-fact { + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); } .agent-tools-buttons { display: flex; gap: 8px; flex-wrap: wrap; - margin-top: 8px; } .agent-tools-grid { display: grid; gap: 16px; + margin-top: 20px; } -.agent-tools-section { +.agent-tools-runtime { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.agent-tools-runtime-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + padding: 7px 10px; border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 10px; - background: var(--bg-elevated); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--card) 86%, transparent); + color: inherit; + text-decoration: none; + transition: + background-color var(--duration-fast) var(--ease-in-out), + border-color var(--duration-fast) var(--ease-in-out), + color var(--duration-fast) var(--ease-in-out); + touch-action: manipulation; } -.agent-tools-header { +.agent-tools-runtime-chip:hover { + background: color-mix(in srgb, var(--card) 92%, transparent); + border-color: color-mix(in srgb, var(--border-strong) 58%, var(--border)); +} + +.agent-tools-runtime-chip:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.agent-tools-runtime-chip--more { + color: var(--muted); + cursor: default; + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); +} + +.agent-tools-runtime-chip--more:hover { + background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); + border-color: var(--border); +} + +.agent-tools-runtime-chip__meta { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.agent-tools-group { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + overflow: hidden; +} + +.agent-tools-group summary::-webkit-details-marker, +.agent-tool-summary::-webkit-details-marker { + display: none; +} + +.agent-tools-group summary::marker, +.agent-tool-summary::marker { + content: ""; +} + +.agent-tools-group__summary { + display: flex; + align-items: flex-start; + gap: 12px; + justify-content: space-between; + padding: 12px 14px; + cursor: pointer; + list-style: none; + transition: + background-color var(--duration-fast) var(--ease-in-out), + color var(--duration-fast) var(--ease-in-out); + touch-action: manipulation; +} + +.agent-tools-group__summary::before { + content: "▸"; + color: var(--muted); + font-size: 11px; + line-height: 20px; + transition: transform var(--duration-fast) var(--ease-in-out); +} + +.agent-tools-group[open] .agent-tools-group__summary::before { + transform: rotate(90deg); +} + +.agent-tools-group__summary:hover { + background: color-mix(in srgb, var(--bg-elevated) 96%, var(--text) 4%); +} + +.agent-tools-group__summary:hover::before { + color: color-mix(in srgb, var(--text) 78%, var(--muted)); +} + +.agent-tools-group__summary:focus-visible { + outline: none; + box-shadow: inset var(--focus-ring); +} + +.agent-tools-group__title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; font-weight: 600; - margin-bottom: 10px; +} + +.agent-tools-group__summary-main { + display: grid; + gap: 6px; + min-width: 0; + flex: 1; +} + +.agent-tools-group__preview { + display: flex; + gap: 6px; + flex-wrap: wrap; + min-width: 0; + color: var(--muted); + font-size: 11px; +} + +.agent-tools-group__preview > span { + min-width: 0; + max-width: min(180px, 100%); + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--card) 70%, transparent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-tools-group__counts { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.agent-tools-group__counts > span { + white-space: nowrap; } .agent-tools-list { display: grid; - gap: 8px 12px; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + padding: 0 12px 12px; } -.agent-tool-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 6px 8px; +.agent-tools-list--stacked { + grid-template-columns: 1fr; +} + +.agent-tool-card { + position: relative; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--card); + overflow: hidden; + scroll-margin-top: 16px; +} + +.agent-tool-card[open] { + border-color: color-mix(in srgb, var(--accent) 22%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); +} + +.agent-tool-summary { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(220px, 0.9fr) auto; + gap: 12px 16px; + align-items: center; + min-width: 0; + padding: 12px 92px 12px 12px; + cursor: pointer; + list-style: none; + transition: + background-color var(--duration-fast) var(--ease-in-out), + color var(--duration-fast) var(--ease-in-out); + touch-action: manipulation; +} + +.agent-tool-summary::after { + content: "▸"; + position: absolute; + top: 18px; + right: 64px; + color: var(--muted); + font-size: 11px; + transition: transform var(--duration-fast) var(--ease-in-out); +} + +.agent-tool-card[open] .agent-tool-summary::after { + transform: rotate(90deg); +} + +.agent-tool-summary:hover { + background: color-mix(in srgb, var(--card) 97%, var(--text) 3%); +} + +.agent-tool-summary:hover::after { + color: color-mix(in srgb, var(--text) 78%, var(--muted)); +} + +.agent-tool-summary:focus-visible { + outline: none; + box-shadow: inset var(--focus-ring); +} + +.agent-tool-summary__main { + min-width: 0; +} + +.agent-tool-summary__title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.agent-tool-summary__badges { + min-width: 0; +} + +.agent-tool-summary__badges .agent-tool-badges { + margin-top: 0; + justify-content: flex-end; +} + +.agent-tool-summary__facts { + display: grid; + gap: 8px 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + min-width: 0; +} + +.agent-tool-summary__fact { + min-width: 0; +} + +.agent-tool-summary__fact dd { + margin: 2px 0 0; + font-size: 12px; +} + +.agent-tool-toggle { + position: absolute; + top: 12px; + right: 12px; + margin: 0; + z-index: 1; +} + +.agent-tool-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 8px; } .agent-tool-title { font-weight: 600; font-size: 13px; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .agent-tool-sub { color: var(--muted); font-size: 11px; - margin-top: 2px; + margin-top: 3px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-tool-details { + padding: 0 12px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); +} + +.agent-tool-card:not([open]) .agent-tool-details { + display: none; +} + +.agent-tool-details-strip { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + align-items: flex-start; + padding-top: 10px; +} + +.agent-tool-detail { + min-width: 0; +} + +.agent-tool-detail--inline { + max-width: min(100%, 260px); +} + +.agent-tool-detail .label { + margin-bottom: 4px; +} + +.agent-tool-card[open] .agent-tool-sub { + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +.agent-tool-jump { + color: var(--accent); + text-decoration: none; + align-self: end; + margin-left: auto; +} + +.agent-tool-jump:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.agent-tool-jump:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + .agent-tools-runtime-chip, + .agent-tools-group__summary, + .agent-tool-summary, + .agent-tools-group__summary::before, + .agent-tool-summary::after { + transition: none; + } +} + +@media (max-width: 1180px) { + .agent-tools-overview { + grid-template-columns: 1fr; + } + + .agent-tools-facts { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } + + .agent-tool-summary { + grid-template-columns: minmax(0, 1fr) auto; + } + + .agent-tool-summary__facts { + grid-column: 1 / -1; + } + + .agent-tool-summary__badges .agent-tool-badges { + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .agent-tools-group__summary { + flex-wrap: wrap; + align-items: flex-start; + } + + .agent-tools-group__summary::before { + line-height: 18px; + } + + .agent-tools-group__counts { + justify-content: flex-start; + width: 100%; + padding-left: 24px; + } + + .agent-tool-summary { + grid-template-columns: 1fr; + padding-right: 92px; + } + + .agent-tool-summary__facts { + grid-template-columns: 1fr; + } + + .agent-tool-jump { + margin-left: 0; + } } .agent-skills-groups { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 87e71a80364..08774c6d6e2 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1024,6 +1024,7 @@ justify-content: space-between; gap: 16px; padding-bottom: 0; + overflow: visible; } .content--chat .content-header > div:first-child { @@ -1032,6 +1033,7 @@ .content--chat .page-meta { justify-content: flex-start; + overflow: visible; } .content--chat .chat-controls { diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts new file mode 100644 index 00000000000..71450e7b795 --- /dev/null +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -0,0 +1,69 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { t } from "../i18n/index.ts"; +import { renderChatControls } from "./app-render.helpers.ts"; +import type { AppViewState } from "./app-view-state.ts"; + +function createState(overrides: Partial = {}) { + return { + connected: true, + chatLoading: false, + onboarding: false, + sessionKey: "main", + sessionsHideCron: true, + sessionsResult: { + ts: 0, + path: "", + count: 0, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [], + }, + settings: { + gatewayUrl: "", + token: "", + locale: "en", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "dark", + splitRatio: 0.6, + navWidth: 280, + navCollapsed: false, + navGroupsCollapsed: {}, + borderRadius: 50, + chatFocusMode: false, + chatShowThinking: false, + chatShowToolCalls: true, + }, + applySettings: () => undefined, + ...overrides, + } as unknown as AppViewState; +} + +describe("chat header controls (browser)", () => { + it("renders explicit hover tooltip metadata for the top-right action buttons", async () => { + const container = document.createElement("div"); + render(renderChatControls(createState()), container); + await Promise.resolve(); + + const buttons = Array.from( + container.querySelectorAll(".chat-controls .btn--icon[data-tooltip]"), + ); + + expect(buttons).toHaveLength(5); + + const labels = buttons.map((button) => button.getAttribute("data-tooltip")); + expect(labels).toEqual([ + t("chat.refreshTitle"), + t("chat.thinkingToggle"), + t("chat.toolCallsToggle"), + t("chat.focusToggle"), + t("chat.showCronSessions"), + ]); + + for (const button of buttons) { + expect(button.getAttribute("title")).toBe(button.getAttribute("data-tooltip")); + expect(button.getAttribute("aria-label")).toBe(button.getAttribute("data-tooltip")); + } + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 529c92b0ad7..f0b4128705c 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -184,6 +184,19 @@ export function renderChatControls(state: AppViewState) { const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; const focusActive = state.onboarding ? true : state.settings.chatFocusMode; + const refreshLabel = t("chat.refreshTitle"); + const thinkingLabel = disableThinkingToggle + ? t("chat.onboardingDisabled") + : t("chat.thinkingToggle"); + const toolCallsLabel = disableThinkingToggle + ? t("chat.onboardingDisabled") + : t("chat.toolCallsToggle"); + const focusLabel = disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle"); + const cronLabel = hideCron + ? hiddenCronCount > 0 + ? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) }) + : t("chat.showCronSessions") + : t("chat.hideCronSessions"); const toolCallsIcon = html` ${refreshIcon} @@ -274,7 +289,9 @@ export function renderChatControls(state: AppViewState) { }); }} aria-pressed=${showThinking} - title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.thinkingToggle")} + title=${thinkingLabel} + aria-label=${thinkingLabel} + data-tooltip=${thinkingLabel} > ${icons.brain} @@ -291,7 +308,9 @@ export function renderChatControls(state: AppViewState) { }); }} aria-pressed=${showToolCalls} - title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.toolCallsToggle")} + title=${toolCallsLabel} + aria-label=${toolCallsLabel} + data-tooltip=${toolCallsLabel} > ${toolCallsIcon} @@ -308,7 +327,9 @@ export function renderChatControls(state: AppViewState) { }); }} aria-pressed=${focusActive} - title=${disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle")} + title=${focusLabel} + aria-label=${focusLabel} + data-tooltip=${focusLabel} > ${focusIcon} @@ -318,11 +339,9 @@ export function renderChatControls(state: AppViewState) { state.sessionsHideCron = !hideCron; }} aria-pressed=${hideCron} - title=${hideCron - ? hiddenCronCount > 0 - ? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) }) - : t("chat.showCronSessions") - : t("chat.hideCronSessions")} + title=${cronLabel} + aria-label=${cronLabel} + data-tooltip=${cronLabel} > ${renderCronFilterIcon(hiddenCronCount)} 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 70d36983b33..8a64ae7833e 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 @@ -105,12 +105,13 @@ describe("agents tools panel (browser)", () => { await Promise.resolve(); const text = container.textContent ?? ""; - expect(text).toContain("core"); - expect(text).toContain("plugin:voice-call"); - expect(text).toContain("optional"); + expect(text).toContain("Built-In"); + expect(text).toContain("Plugin: voice-call"); + expect(text).toContain("Optional"); expect(text).toContain("Available Right Now"); expect(text).toContain("Message Actions"); expect(text).toContain("Channel: guildchat"); + expect(container.querySelector(".agent-tool-card[open]")).toBeNull(); }); it("shows fallback warning when runtime catalog fails", async () => { @@ -128,4 +129,214 @@ describe("agents tools panel (browser)", () => { expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); }); + + it("closes expanded tool rows when the parent group collapses", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [{ id: "full", label: "Full" }], + groups: [ + { + id: "files", + label: "Files", + source: "core", + tools: [ + { + id: "read", + label: "read", + description: "Read file contents", + source: "core", + defaultProfiles: ["full"], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const group = container.querySelector(".agent-tools-group"); + const tool = container.querySelector(".agent-tool-card"); + + expect(group).not.toBeNull(); + expect(tool).not.toBeNull(); + + if (!group || !tool) { + return; + } + + group.open = true; + tool.open = true; + + group.open = false; + group.dispatchEvent(new Event("toggle")); + + expect(tool.open).toBe(false); + }); + + it("keeps the access toggle inside the collapsed tool summary", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [{ id: "full", label: "Full" }], + groups: [ + { + id: "files", + label: "Files", + source: "core", + tools: [ + { + id: "read", + label: "read", + description: "Read file contents", + source: "core", + defaultProfiles: ["full"], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const tool = container.querySelector(".agent-tool-card"); + const summary = container.querySelector(".agent-tool-summary"); + const toggle = container.querySelector(".agent-tool-toggle input"); + + expect(tool?.open).toBe(false); + expect(toggle?.closest(".agent-tool-summary")).toBe(summary); + }); + + it("uses section-level plugin provenance for tool details", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [{ id: "full", label: "Full" }], + groups: [ + { + id: "plugin:voice-call", + label: "voice-call", + source: "plugin", + pluginId: "voice-call", + tools: [ + { + id: "voice_call", + label: "voice_call", + description: "Voice call tool", + source: undefined as never, + defaultProfiles: ["full"], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const tool = container.querySelector(".agent-tool-card"); + tool!.open = true; + + const sourceDetail = Array.from( + container.querySelectorAll(".agent-tool-detail"), + ).find((detail) => detail.textContent?.includes("Source")); + + expect(sourceDetail?.textContent).toContain("Plugin: voice-call"); + }); + + it("opens the collapsed group and tool row from a live tool chip", async () => { + const container = document.createElement("div"); + document.body.append(container); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [{ id: "full", label: "Full" }], + groups: [ + { + id: "files", + label: "Files", + source: "core", + tools: [ + { + id: "read", + label: "read", + description: "Read file contents", + source: "core", + defaultProfiles: ["full"], + }, + ], + }, + ], + }, + toolsEffectiveResult: { + agentId: "main", + profile: "full", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "read", + label: "read", + description: "Read file contents", + rawDescription: "Read file contents", + source: "core", + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const group = container.querySelector(".agent-tools-group"); + const tool = container.querySelector(".agent-tool-card"); + const chip = container.querySelector( + '.agent-tools-runtime-chip[href="#agent-tool-read"]', + ); + + expect(group).not.toBeNull(); + expect(tool).not.toBeNull(); + expect(chip).not.toBeNull(); + + if (!group || !tool || !chip) { + container.remove(); + return; + } + + expect(group.open).toBe(false); + expect(tool.open).toBe(false); + + chip.click(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(group.open).toBe(true); + expect(tool.open).toBe(true); + + container.remove(); + }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 566139953bb..f6404bdb7ae 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -6,6 +6,7 @@ import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult, + ToolsEffectiveEntry, ToolsEffectiveResult, } from "../types.ts"; import { @@ -26,26 +27,152 @@ import { renderSkillStatusChips, } from "./skills-shared.ts"; -function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) { +function renderToolMetaBadges(labels: string[]) { + if (labels.length === 0) { + return nothing; + } + return html` +
+ ${labels.map((label) => html`${label}`)} +
+ `; +} + +function buildCatalogBadgeLabels(section: AgentToolSection, tool: AgentToolEntry): string[] { const source = tool.source ?? section.source; const pluginId = tool.pluginId ?? section.pluginId; const badges: string[] = []; if (source === "plugin" && pluginId) { - badges.push(`plugin:${pluginId}`); + badges.push(`Plugin: ${pluginId}`); } else if (source === "core") { - badges.push("core"); + badges.push("Built-In"); } if (tool.optional) { - badges.push("optional"); + badges.push("Optional"); } - if (badges.length === 0) { - return nothing; + return badges; +} + +function buildRowStatusBadges(params: { + section: AgentToolSection; + tool: AgentToolEntry; + activeEntry: ToolsEffectiveEntry | null; +}) { + const badges = buildCatalogBadgeLabels(params.section, params.tool); + if (params.activeEntry) { + badges.unshift("Live Now"); } - return html` -
- ${badges.map((badge) => html`${badge}`)} -
- `; + return badges; +} + +function formatToolPolicyState(params: { + allowed: boolean; + baseAllowed: boolean; + denied: boolean; +}) { + if (params.denied) { + return "Disabled by agent override."; + } + if (params.allowed && params.baseAllowed) { + return "Enabled by the current profile."; + } + if (params.allowed) { + return "Enabled by agent override."; + } + return "Not included in the current profile."; +} + +function formatToolSourceLabel(section: AgentToolSection, tool: AgentToolEntry) { + const source = tool.source ?? section.source; + const pluginId = tool.pluginId ?? section.pluginId; + if (source === "plugin" && pluginId) { + return `Plugin: ${pluginId}`; + } + return "Built-In"; +} + +function formatToolAccessSummary(params: { + allowed: boolean; + baseAllowed: boolean; + denied: boolean; +}) { + if (params.denied) { + return "Override Off"; + } + if (params.allowed && params.baseAllowed) { + return "Enabled"; + } + if (params.allowed) { + return "Override On"; + } + return "Profile Off"; +} + +function formatToolRuntimeSummary(params: { + activeEntry: ToolsEffectiveEntry | null; + runtimeSessionMatchesSelectedAgent: boolean; +}) { + if (params.activeEntry) { + return "Live Now"; + } + if (params.runtimeSessionMatchesSelectedAgent) { + return "Not Live"; + } + return "Other Agent"; +} + +function toToolAnchorId(toolId: string) { + const safe = normalizeToolName(toolId).replace(/[^a-z0-9_-]+/g, "-"); + return `agent-tool-${safe}`; +} + +function formatCountLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function flattenEffectiveTools(groups: ToolsEffectiveResult["groups"] | null | undefined) { + return (groups ?? []).flatMap((group) => group.tools); +} + +const MAX_RUNTIME_TOOL_CHIPS = 12; + +function handleToolGroupToggle(event: Event) { + const group = event.currentTarget; + if (!(group instanceof HTMLDetailsElement) || group.open) { + return; + } + for (const tool of group.querySelectorAll(".agent-tool-card[open]")) { + tool.open = false; + } +} + +function handleRuntimeToolJump(event: Event, anchorId: string) { + const target = document.getElementById(anchorId); + if (!(target instanceof HTMLDetailsElement)) { + return; + } + + event.preventDefault(); + const parentGroup = target.closest(".agent-tools-group"); + if (parentGroup) { + parentGroup.open = true; + } + target.open = true; + + const nextUrl = new URL(window.location.href); + nextUrl.hash = anchorId; + window.history.replaceState(null, "", nextUrl); + + requestAnimationFrame(() => { + const reducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + target.scrollIntoView?.({ + block: "center", + behavior: reducedMotion ? "auto" : "smooth", + }); + target.querySelector("summary")?.focus(); + }); } function renderEffectiveToolBadge(tool: { @@ -127,6 +254,40 @@ export function renderAgentTools(params: { }; }; const enabledCount = toolIds.filter((toolId) => resolveAllowed(toolId).allowed).length; + const effectiveTools = + params.runtimeSessionMatchesSelectedAgent && !params.toolsEffectiveError + ? flattenEffectiveTools(params.toolsEffectiveResult?.groups) + : []; + const uniqueEffectiveTools = Array.from( + new Map(effectiveTools.map((tool) => [normalizeToolName(tool.id), tool])).values(), + ); + const visibleEffectiveTools = uniqueEffectiveTools.slice(0, MAX_RUNTIME_TOOL_CHIPS); + const hiddenEffectiveToolCount = Math.max( + 0, + uniqueEffectiveTools.length - visibleEffectiveTools.length, + ); + const liveToolCount = uniqueEffectiveTools.length; + const activeToolMap = new Map( + effectiveTools.map((tool) => [normalizeToolName(tool.id), tool] as const), + ); + const activeToolIds = new Set(activeToolMap.keys()); + + const sortSectionTools = (tools: AgentToolEntry[]) => + tools.toSorted((left, right) => { + const leftId = normalizeToolName(left.id); + const rightId = normalizeToolName(right.id); + const leftActive = activeToolIds.has(leftId) ? 1 : 0; + const rightActive = activeToolIds.has(rightId) ? 1 : 0; + if (leftActive !== rightActive) { + return rightActive - leftActive; + } + const leftAllowed = resolveAllowed(left.id).allowed ? 1 : 0; + const rightAllowed = resolveAllowed(right.id).allowed ? 1 : 0; + if (leftAllowed !== rightAllowed) { + return rightAllowed - leftAllowed; + } + return left.label.localeCompare(right.label); + }); const updateTool = (toolId: string, nextEnabled: boolean) => { const nextAllow = new Set( @@ -174,15 +335,15 @@ export function renderAgentTools(params: { return html`
-
-
+
+
Tool Access
Profile + per-tool overrides for this agent. ${enabledCount}/${toolIds.length} enabled.
-
+
@@ -242,150 +403,260 @@ export function renderAgentTools(params: { ` : nothing} -
-
-
Profile
-
${profile}
-
-
-
Source
-
${profileSource}
-
- ${params.configDirty - ? html` -
-
Status
-
unsaved
-
- ` - : nothing} -
- -
-
Available Right Now
-
- What this agent can use in the current chat session. - ${params.runtimeSessionKey || "no session"} -
- ${!params.runtimeSessionMatchesSelectedAgent - ? html` -
- Switch chat to this agent to view its live runtime tools. -
- ` - : params.toolsEffectiveLoading && - !params.toolsEffectiveResult && - !params.toolsEffectiveError - ? html` -
Loading available tools…
- ` - : params.toolsEffectiveError +
+
+
+
Available Right Now
+
+ What this agent can use in the current chat session. + ${params.runtimeSessionKey || "no session"} +
+ ${!params.runtimeSessionMatchesSelectedAgent ? html`
- Could not load available tools for this session. + Switch chat to this agent to view its live runtime tools.
` - : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + : params.toolsEffectiveLoading && + !params.toolsEffectiveResult && + !params.toolsEffectiveError ? html`
- No tools are available for this session right now. + Loading available tools…
` - : html` -
- ${params.toolsEffectiveResult?.groups.map( - (group) => html` -
-
${group.label}
-
- ${group.tools.map((tool) => { - return html` -
-
-
${tool.label}
-
${tool.description}
-
- ${renderEffectiveToolBadge(tool)} -
-
-
- `; - })} -
-
- `, - )} -
- `} -
+ : params.toolsEffectiveError + ? html` +
+ Could not load available tools for this session. +
+ ` + : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + ? html` +
+ No tools are available for this session right now. +
+ ` + : html` +
+ ${visibleEffectiveTools.map((tool) => { + const anchorId = toToolAnchorId(tool.id); + return html` + handleRuntimeToolJump(event, anchorId)} + > + ${tool.label} + ${renderEffectiveToolBadge(tool)} + + `; + })} + ${hiddenEffectiveToolCount > 0 + ? html` + + +${hiddenEffectiveToolCount} more live tools + + ` + : nothing} +
+ `} +
-
-
Quick Presets
-
- ${profileOptions.map( - (option) => html` +
+
Quick Presets
+
+ ${profileOptions.map( + (option) => html` + + `, + )} - `, - )} - +
+
+
+ +
+
+
Profile
+
${profile}
+
+
+
Source
+
${profileSource}
+
+
+
Enabled
+
${enabledCount}/${toolIds.length}
+
+
+
Live
+
${liveToolCount}
+
+
+
Status
+
+ ${params.configSaving ? "saving…" : params.configDirty ? "unsaved" : "saved"} +
+
-
- ${toolSections.map( - (section) => html` -
-
- ${section.label} - ${section.source === "plugin" && section.pluginId - ? html`plugin:${section.pluginId}` - : nothing} -
-
- ${section.tools.map((tool) => { - const { allowed } = resolveAllowed(tool.id); +
+ ${toolSections.map((section) => { + const sortedTools = sortSectionTools(section.tools); + const enabledSectionCount = section.tools.filter( + (tool) => resolveAllowed(tool.id).allowed, + ).length; + const activeSectionCount = section.tools.filter((tool) => + activeToolIds.has(normalizeToolName(tool.id)), + ).length; + const previewTools = sortedTools.slice(0, 4); + const remainingPreviewCount = Math.max(0, sortedTools.length - previewTools.length); + return html` +
+ + + + ${section.label} + ${section.source === "plugin" && section.pluginId + ? html`Plugin: ${section.pluginId}` + : nothing} + + + ${previewTools.map( + (tool) => + html`${tool.label}`, + )} + ${remainingPreviewCount > 0 + ? html`+${remainingPreviewCount} more` + : nothing} + + + + ${formatCountLabel(section.tools.length, "Tool")} + ${formatCountLabel(enabledSectionCount, "Enabled Tool")} + ${activeSectionCount > 0 + ? html`${formatCountLabel(activeSectionCount, "Live Tool")}` + : nothing} + + +
+ ${sortedTools.map((tool) => { + const anchorId = toToolAnchorId(tool.id); + const resolved = resolveAllowed(tool.id); + const activeEntry = activeToolMap.get(normalizeToolName(tool.id)) ?? null; + const defaultProfiles = tool.defaultProfiles ?? []; + const rowBadges = buildRowStatusBadges({ + section, + tool, + activeEntry, + }); + const accessSummary = formatToolAccessSummary(resolved); + const runtimeSummary = formatToolRuntimeSummary({ + activeEntry, + runtimeSessionMatchesSelectedAgent: params.runtimeSessionMatchesSelectedAgent, + }); return html` -
-
-
${tool.label}
-
${tool.description}
- ${renderToolBadges(section, tool)} +
+ +
+
+ ${tool.label} +
+
${tool.description}
+
+
+
+
Access
+
${accessSummary}
+
+
+
Session
+
${runtimeSummary}
+
+
+
+ ${renderToolMetaBadges(rowBadges)} +
+ +
+
+
+
+
Access
+
${formatToolPolicyState(resolved)}
+
+
+
Source
+
${formatToolSourceLabel(section, tool)}
+
+ ${defaultProfiles.length > 0 + ? html` +
+
Default Presets
+
+ ${defaultProfiles.map( + (profileId) => + html`${profileId}`, + )} +
+
+ ` + : nothing} +
+
Current Session
+
+ ${activeEntry + ? `Available now via ${renderEffectiveToolBadge(activeEntry)}.` + : params.runtimeSessionMatchesSelectedAgent + ? "Not available in this chat session right now." + : "Switch chat to this agent to inspect live availability."} +
+
+ Link to This Tool +
- -
+
`; })}
-
- `, - )} + + `; + })}
`; From 209d50b52c67bc8bf8a115a48d564005fd585aa4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:31:18 +0100 Subject: [PATCH 63/93] feat(browser): add coordinate click action Co-authored-by: Daniel Lutts --- CHANGELOG.md | 1 + docs/cli/browser.md | 1 + docs/tools/browser-control.md | 3 +- docs/tools/browser.md | 2 +- extensions/browser/src/browser-tool.schema.ts | 5 ++ extensions/browser/src/browser-tool.ts | 2 + extensions/browser/src/browser/chrome-mcp.ts | 59 +++++++++++++++++++ .../src/browser/client-actions.types.ts | 10 ++++ .../src/browser/pw-tools-core.interactions.ts | 42 +++++++++++++ .../src/browser/routes/agent.act.normalize.ts | 30 ++++++++++ .../src/browser/routes/agent.act.shared.ts | 1 + .../browser/src/browser/routes/agent.act.ts | 20 +++++++ .../routes/agent.existing-session.test.ts | 30 ++++++++++ .../server.agent-contract-core.test.ts | 42 +++++++++++++ .../server.control-server.test-harness.ts | 7 +++ .../register.element.ts | 31 ++++++++++ .../browser/src/cli/browser-cli-examples.ts | 1 + 17 files changed, 285 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a4c921196..f544749c59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar. +- Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz. - Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. - Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 032ed879290..38ab18d4a99 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -164,6 +164,7 @@ Navigate/click/type (ref-based UI automation): ```bash openclaw browser navigate https://example.com openclaw browser click +openclaw browser click-coords 120 340 openclaw browser type "hello" openclaw browser press Enter openclaw browser hover diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index 7cc5878b3b5..31164ec16ae 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -165,6 +165,7 @@ openclaw browser responsebody "**/api" --max-chars 5000 openclaw browser navigate https://example.com openclaw browser resize 1280 720 openclaw browser click 12 --double # or e12 for role refs +openclaw browser click-coords 120 340 # viewport coordinates openclaw browser type 23 "hello" --submit openclaw browser press Enter openclaw browser hover 44 @@ -212,7 +213,7 @@ openclaw browser set device "iPhone 14" Notes: - `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog. -- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12` or role ref `e12`). CSS selectors are intentionally not supported for actions. +- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12` or role ref `e12`). CSS selectors are intentionally not supported for actions. Use `click-coords` when the visible viewport position is the only reliable target. - Download, trace, and upload paths are constrained to OpenClaw temp roots: `/tmp/openclaw{,/downloads,/uploads}` (fallback: `${os.tmpdir()}/openclaw/...`). - `upload` can also set file inputs directly via `--input-ref` or `--element`. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index aa3ad316d1c..50962d93fb4 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -529,7 +529,7 @@ Notes: Compared to the managed `openclaw` profile, existing-session drivers are more constrained: - **Screenshots** — page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots. -- **Actions** — `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value. +- **Actions** — `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value. - **Wait / upload / dialog** — `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides. - **Managed-only features** — batch actions, PDF export, download interception, and `responsebody` still require the managed browser path. diff --git a/extensions/browser/src/browser-tool.schema.ts b/extensions/browser/src/browser-tool.schema.ts index a1c336f04f4..1750da81610 100644 --- a/extensions/browser/src/browser-tool.schema.ts +++ b/extensions/browser/src/browser-tool.schema.ts @@ -3,6 +3,7 @@ import { Type } from "typebox"; const BROWSER_ACT_KINDS = [ "click", + "clickCoords", "type", "press", "hover", @@ -55,6 +56,8 @@ const BrowserActSchema = Type.Object({ doubleClick: Type.Optional(Type.Boolean()), button: Type.Optional(Type.String()), modifiers: Type.Optional(Type.Array(Type.String())), + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), // type text: Type.Optional(Type.String()), submit: Type.Optional(Type.Boolean()), @@ -122,6 +125,8 @@ export const BrowserToolSchema = Type.Object({ doubleClick: Type.Optional(Type.Boolean()), button: Type.Optional(Type.String()), modifiers: Type.Optional(Type.Array(Type.String())), + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), text: Type.Optional(Type.String()), submit: Type.Optional(Type.Boolean()), slowly: Type.Optional(Type.Boolean()), diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index f40cee6dc29..1cb6f4930b5 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -147,6 +147,8 @@ const LEGACY_BROWSER_ACT_REQUEST_KEYS = [ "doubleClick", "button", "modifiers", + "x", + "y", "text", "submit", "slowly", diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index a940bd0fbb8..3853f982eca 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -824,6 +824,65 @@ export async function clickChromeMcpElement(params: { ); } +export async function clickChromeMcpCoords(params: { + profileName: string; + userDataDir?: string; + targetId: string; + x: number; + y: number; + doubleClick?: boolean; + button?: "left" | "right" | "middle"; + delayMs?: number; +}): Promise { + const button = params.button ?? "left"; + const buttonCode = button === "middle" ? 1 : button === "right" ? 2 : 0; + const pressedButtons = button === "middle" ? 4 : button === "right" ? 2 : 1; + const x = JSON.stringify(params.x); + const y = JSON.stringify(params.y); + const delayMs = JSON.stringify(Math.max(0, Math.floor(params.delayMs ?? 0))); + const doubleClick = params.doubleClick ? "true" : "false"; + await evaluateChromeMcpScript({ + profileName: params.profileName, + userDataDir: params.userDataDir, + targetId: params.targetId, + fn: `async () => { + const x = ${x}; + const y = ${y}; + const delayMs = ${delayMs}; + const doubleClick = ${doubleClick}; + const target = document.elementFromPoint(x, y) ?? document.body ?? document.documentElement ?? document; + const base = { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + screenX: window.screenX + x, + screenY: window.screenY + y, + button: ${buttonCode}, + }; + const pressedButtons = ${pressedButtons}; + const dispatch = (type, buttons, detail) => { + target.dispatchEvent(new MouseEvent(type, { ...base, buttons, detail })); + }; + dispatch("mousemove", 0, 0); + dispatch("mousedown", pressedButtons, 1); + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + dispatch("mouseup", 0, 1); + dispatch("click", 0, 1); + if (doubleClick) { + dispatch("mousedown", pressedButtons, 2); + dispatch("mouseup", 0, 2); + dispatch("click", 0, 2); + dispatch("dblclick", 0, 2); + } + return true; + }`, + }); +} + export async function fillChromeMcpElement(params: { profileName: string; userDataDir?: string; diff --git a/extensions/browser/src/browser/client-actions.types.ts b/extensions/browser/src/browser/client-actions.types.ts index 167e9c9469c..b4aa244a28d 100644 --- a/extensions/browser/src/browser/client-actions.types.ts +++ b/extensions/browser/src/browser/client-actions.types.ts @@ -16,6 +16,16 @@ export type BrowserActRequest = delayMs?: number; timeoutMs?: number; } + | { + kind: "clickCoords"; + x: number; + y: number; + targetId?: string; + doubleClick?: boolean; + button?: string; + delayMs?: number; + timeoutMs?: number; + } | { kind: "type"; ref?: string; diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.ts b/extensions/browser/src/browser/pw-tools-core.interactions.ts index ba2af27ad0e..5463889dc96 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.ts @@ -592,6 +592,35 @@ export async function clickViaPlaywright(opts: { } } +export async function clickCoordsViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + x: number; + y: number; + doubleClick?: boolean; + button?: "left" | "right" | "middle"; + delayMs?: number; + timeoutMs?: number; + ssrfPolicy?: SsrFPolicy; +}): Promise { + const page = await getRestoredPageForTarget(opts); + const previousUrl = page.url(); + await assertInteractionNavigationCompletedSafely({ + action: async () => { + await page.mouse.click(opts.x, opts.y, { + button: opts.button, + clickCount: opts.doubleClick ? 2 : 1, + delay: resolveBoundedDelayMs(opts.delayMs, "clickCoords delayMs", ACT_MAX_CLICK_DELAY_MS), + }); + }, + cdpUrl: opts.cdpUrl, + page, + previousUrl, + ssrfPolicy: opts.ssrfPolicy, + targetId: opts.targetId, + }); +} + export async function hoverViaPlaywright(opts: { cdpUrl: string; targetId?: string; @@ -1244,6 +1273,19 @@ async function executeSingleAction( ssrfPolicy, }); break; + case "clickCoords": + await clickCoordsViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + x: action.x, + y: action.y, + doubleClick: action.doubleClick, + button: action.button as "left" | "right" | "middle" | undefined, + delayMs: action.delayMs, + timeoutMs: action.timeoutMs, + ssrfPolicy, + }); + break; case "type": await typeViaPlaywright({ cdpUrl, diff --git a/extensions/browser/src/browser/routes/agent.act.normalize.ts b/extensions/browser/src/browser/routes/agent.act.normalize.ts index 50a16e85b25..7f8d33afd32 100644 --- a/extensions/browser/src/browser/routes/agent.act.normalize.ts +++ b/extensions/browser/src/browser/routes/agent.act.normalize.ts @@ -114,6 +114,36 @@ export function normalizeActRequest( ...(timeoutMs !== undefined ? { timeoutMs } : {}), }; } + case "clickCoords": { + const x = toNumber(body.x); + const y = toNumber(body.y); + if (x === undefined || y === undefined || x < 0 || y < 0) { + throw new Error("clickCoords requires non-negative x and y"); + } + const buttonRaw = toStringOrEmpty(body.button); + const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; + if (buttonRaw && !button) { + throw new Error("clickCoords button must be left|right|middle"); + } + const doubleClick = toBoolean(body.doubleClick); + const delayMs = normalizeActBoundedNonNegativeMs( + toNumber(body.delayMs), + "clickCoords delayMs", + ACT_MAX_CLICK_DELAY_MS, + ); + const timeoutMs = toNumber(body.timeoutMs); + const targetId = toStringOrEmpty(body.targetId) || undefined; + return { + kind, + x, + y, + ...(targetId ? { targetId } : {}), + ...(doubleClick !== undefined ? { doubleClick } : {}), + ...(button ? { button } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } case "type": { const ref = toStringOrEmpty(body.ref) || undefined; const selector = toStringOrEmpty(body.selector) || undefined; diff --git a/extensions/browser/src/browser/routes/agent.act.shared.ts b/extensions/browser/src/browser/routes/agent.act.shared.ts index b22f35e7ef2..bfa04d65588 100644 --- a/extensions/browser/src/browser/routes/agent.act.shared.ts +++ b/extensions/browser/src/browser/routes/agent.act.shared.ts @@ -1,6 +1,7 @@ export const ACT_KINDS = [ "batch", "click", + "clickCoords", "close", "drag", "evaluate", diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index 63356a1afa3..c75dafb0b40 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -1,6 +1,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { clickChromeMcpElement, + clickChromeMcpCoords, closeChromeMcpTab, dragChromeMcpElement, evaluateChromeMcpScript, @@ -279,6 +280,8 @@ function getExistingSessionUnsupportedMessage(action: BrowserActRequest): string return EXISTING_SESSION_LIMITS.act.clickButtonOrModifiers; } return null; + case "clickCoords": + return null; case "type": if (action.selector) { return EXISTING_SESSION_LIMITS.act.typeSelector; @@ -425,6 +428,22 @@ export function registerBrowserAgentActRoutes( guard: existingSessionNavigationGuard, }); return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + case "clickCoords": + await runExistingSessionActionWithNavigationGuard({ + execute: () => + clickChromeMcpCoords({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + x: action.x, + y: action.y, + doubleClick: action.doubleClick ?? false, + button: action.button as "left" | "right" | "middle" | undefined, + delayMs: action.delayMs, + }), + guard: existingSessionNavigationGuard, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); case "type": await runExistingSessionActionWithNavigationGuard({ execute: async () => { @@ -610,6 +629,7 @@ export function registerBrowserAgentActRoutes( result: result.result, }); case "click": + case "clickCoords": case "resize": return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); default: diff --git a/extensions/browser/src/browser/routes/agent.existing-session.test.ts b/extensions/browser/src/browser/routes/agent.existing-session.test.ts index 118ab6857fd..d48a70dfb8c 100644 --- a/extensions/browser/src/browser/routes/agent.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/agent.existing-session.test.ts @@ -8,6 +8,7 @@ import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helper const routeState = existingSessionRouteState; const chromeMcpMocks = vi.hoisted(() => ({ + clickChromeMcpCoords: vi.fn(async () => {}), clickChromeMcpElement: vi.fn(async () => {}), evaluateChromeMcpScript: vi.fn( async (_params: { profileName: string; targetId: string; fn: string }) => true, @@ -30,6 +31,7 @@ const navigationGuardMocks = vi.hoisted(() => ({ })); vi.mock("../chrome-mcp.js", () => ({ + clickChromeMcpCoords: chromeMcpMocks.clickChromeMcpCoords, clickChromeMcpElement: chromeMcpMocks.clickChromeMcpElement, closeChromeMcpTab: vi.fn(async () => {}), dragChromeMcpElement: vi.fn(async () => {}), @@ -108,6 +110,7 @@ describe("existing-session browser routes", () => { beforeEach(() => { routeState.profileCtx.ensureTabAvailable.mockClear(); routeState.profileCtx.listTabs.mockClear(); + chromeMcpMocks.clickChromeMcpCoords.mockClear(); chromeMcpMocks.clickChromeMcpElement.mockClear(); chromeMcpMocks.evaluateChromeMcpScript.mockReset(); chromeMcpMocks.fillChromeMcpElement.mockClear(); @@ -313,4 +316,31 @@ describe("existing-session browser routes", () => { signal: ctrl.signal, }); }); + + it("supports coordinate clicks for existing-session profiles", async () => { + const handler = getActPostHandler(); + const response = createBrowserRouteResponse(); + + await handler?.( + { + params: {}, + query: {}, + body: { kind: "clickCoords", x: 25, y: "32", doubleClick: true, delayMs: 5 }, + }, + response.res, + ); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ ok: true, targetId: "7", url: "https://example.com" }); + expect(chromeMcpMocks.clickChromeMcpCoords).toHaveBeenCalledWith({ + profileName: "chrome-live", + userDataDir: undefined, + targetId: "7", + x: 25, + y: 32, + doubleClick: true, + button: undefined, + delayMs: 5, + }); + }); }); 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 9a504c34362..96f800f2d9f 100644 --- a/extensions/browser/src/browser/server.agent-contract-core.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -82,6 +82,23 @@ describe("browser control server", () => { slowTimeoutMs, ); + it( + "returns ACT_INVALID_REQUEST for malformed coordinate clicks", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "clickCoords", + x: -1, + y: 20, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ACT_INVALID_REQUEST"); + expect(response.body.error).toContain("clickCoords requires non-negative x and y"); + }, + slowTimeoutMs, + ); + it( "returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions", async () => { @@ -297,6 +314,31 @@ describe("browser control server", () => { const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? []; expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined(); + const clickCoords = await postJson<{ ok: boolean; url?: string }>(`${base}/act`, { + kind: "clickCoords", + x: "42.5", + y: 64, + doubleClick: "true", + button: "left", + delayMs: "10", + }); + expect(clickCoords.ok).toBe(true); + expect(clickCoords.url).toBe("https://example.com"); + expect(pwMocks.clickCoordsViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + x: 42.5, + y: 64, + doubleClick: true, + button: "left", + delayMs: 10, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + const type = await postJson<{ ok: boolean }>(`${base}/act`, { kind: "type", ref: "1", diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index be9211eb70c..6dd393e0133 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -147,6 +147,7 @@ const pwMocks = vi.hoisted(() => ({ armDialogViaPlaywright: vi.fn(async () => {}), armFileUploadViaPlaywright: vi.fn(async () => {}), batchViaPlaywright: vi.fn(async (_opts?: unknown) => ({ results: [] })), + clickCoordsViaPlaywright: vi.fn(async (_opts?: unknown) => {}), clickViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePageViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePlaywrightBrowserConnection: vi.fn(async () => {}), @@ -194,6 +195,11 @@ const passThroughActDispatch: Record = { fields: ["ref", "selector", "doubleClick", "button", "modifiers", "delayMs", "timeoutMs"], includeSsrf: true, }, + clickCoords: { + mock: pwMocks.clickCoordsViaPlaywright, + fields: ["x", "y", "doubleClick", "button", "delayMs", "timeoutMs"], + includeSsrf: true, + }, type: { mock: pwMocks.typeViaPlaywright, fields: ["ref", "selector", "text", "submit", "slowly", "timeoutMs"], @@ -301,6 +307,7 @@ export function getPwMocks(): Record { } const chromeMcpMocks = vi.hoisted(() => ({ + clickChromeMcpCoords: vi.fn(async () => {}), clickChromeMcpElement: vi.fn(async () => {}), closeChromeMcpSession: vi.fn(async () => true), closeChromeMcpTab: vi.fn(async () => {}), diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts index a71eabc48a7..810df665322 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.element.ts @@ -75,6 +75,37 @@ export function registerBrowserElementCommands( }); }); + browser + .command("click-coords") + .description("Click viewport coordinates") + .argument("", "Viewport x coordinate") + .argument("", "Viewport y coordinate") + .option("--target-id ", "CDP target id (or unique prefix)") + .option("--double", "Double click", false) + .option("--button ", "Mouse button to use") + .option("--delay-ms ", "Delay between mouse down/up", (v: string) => Number(v)) + .action(async (xRaw: string, yRaw: string, opts, cmd) => { + const x = Number(xRaw); + const y = Number(yRaw); + await runElementAction({ + cmd, + body: { + kind: "clickCoords", + x, + y, + targetId: normalizeOptionalString(opts.targetId), + doubleClick: Boolean(opts.double), + button: normalizeOptionalString(opts.button), + delayMs: Number.isFinite(opts.delayMs) ? opts.delayMs : undefined, + }, + successMessage: (result) => { + const url = (result as { url?: unknown }).url; + const suffix = typeof url === "string" && url ? ` on ${url}` : ""; + return `clicked ${x},${y}${suffix}`; + }, + }); + }); + browser .command("type") .description("Type into an element by ref from snapshot") diff --git a/extensions/browser/src/cli/browser-cli-examples.ts b/extensions/browser/src/cli/browser-cli-examples.ts index 7e6df7cd6db..de621a80f60 100644 --- a/extensions/browser/src/cli/browser-cli-examples.ts +++ b/extensions/browser/src/cli/browser-cli-examples.ts @@ -19,6 +19,7 @@ export const browserActionExamples = [ "openclaw browser navigate https://example.com", "openclaw browser resize 1280 720", "openclaw browser click 12 --double", + "openclaw browser click-coords 120 340", 'openclaw browser type 23 "hello" --submit', "openclaw browser press Enter", "openclaw browser hover 44", From 1752b15a21fb99c7a8004d24705f5109d659fcd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:36:05 +0100 Subject: [PATCH 64/93] feat(google-meet): add artifacts and attendance commands --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 21 ++ extensions/google-meet/index.test.ts | 282 ++++++++++++++++ extensions/google-meet/index.ts | 119 ++++++- extensions/google-meet/src/cli.ts | 172 ++++++++++ extensions/google-meet/src/meet.ts | 470 ++++++++++++++++++++++++++- 6 files changed, 1060 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f544749c59d..3447be3bc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete. - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 727c67606f1..930299d406e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -480,6 +480,27 @@ Run preflight before media work: openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij ``` +List meeting artifacts and attendance after Meet has created conference records: + +```bash +openclaw googlemeet artifacts --meeting https://meet.google.com/abc-defg-hij +openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij +``` + +If you already know the conference record id, address it directly: + +```bash +openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json +openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json +``` + +`artifacts` returns conference record metadata plus participant, recording, +transcript, and smart-note resource metadata when Google exposes it for the +meeting. `attendance` expands participants into participant-session rows with +join/leave timestamps. These commands use the Meet REST API only; transcript or +smart-note document body download is intentionally out of scope because that +requires separate Google Docs/Drive access. + Create a fresh Meet space: ```bash diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 8c2e7ca20c4..d5d97f5d90a 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -9,6 +9,8 @@ import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/c import { buildGoogleMeetPreflightReport, createGoogleMeetSpace, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; @@ -64,6 +66,112 @@ function setup( return setupGoogleMeetPlugin(plugin, config, options); } +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function requestUrl(input: RequestInfo | URL): URL { + if (typeof input === "string") { + return new URL(input); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} + +function stubMeetArtifactsApi() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/v2/conferenceRecords") { + return jsonResponse({ + conferenceRecords: [ + { + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + earliestStartTime: "2026-04-25T10:00:00Z", + latestEndTime: "2026-04-25T10:30:00Z", + signedinUser: { user: "users/alice", displayName: "Alice" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { + return jsonResponse({ + recordings: [ + { + name: "conferenceRecords/rec-1/recordings/r1", + driveDestination: { file: "drive/file-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { + return jsonResponse({ + transcripts: [ + { + name: "conferenceRecords/rec-1/transcripts/t1", + docsDestination: { document: "docs/doc-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { + return jsonResponse({ + smartNotes: [ + { + name: "conferenceRecords/rec-1/smartNotes/sn1", + docsDestination: { document: "docs/doc-2" }, + }, + ], + }); + } + return new Response(`unexpected ${url.pathname}`, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + type TestBridgeProcess = { stdin?: { write(chunk: unknown): unknown } | null; stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; @@ -218,6 +326,8 @@ describe("google-meet plugin", () => { "setup_status", "resolve_space", "preflight", + "artifacts", + "attendance", "recover_current_tab", "leave", "speak", @@ -310,6 +420,82 @@ describe("google-meet plugin", () => { ); }); + it("lists Meet artifact metadata for conference records", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchGoogleMeetArtifacts({ + accessToken: "token", + meeting: "abc-defg-hij", + pageSize: 2, + }), + ).resolves.toMatchObject({ + input: "abc-defg-hij", + space: { name: "spaces/abc-defg-hij" }, + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + conferenceRecord: { name: "conferenceRecords/rec-1" }, + participants: [{ name: "conferenceRecords/rec-1/participants/p1" }], + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + }); + + const listCall = fetchMock.mock.calls.find(([input]) => { + const url = requestUrl(input); + return url.pathname === "/v2/conferenceRecords"; + }); + if (!listCall) { + throw new Error("Expected conferenceRecords.list fetch call"); + } + const listUrl = requestUrl(listCall[0]); + expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); + expect(listUrl.searchParams.get("pageSize")).toBe("2"); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2", + auditContext: "google-meet.conferenceRecords.smartNotes.list", + }), + ); + }); + + it("lists Meet attendance rows with participant sessions", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchGoogleMeetAttendance({ + accessToken: "token", + conferenceRecord: "rec-1", + pageSize: 3, + }), + ).resolves.toMatchObject({ + input: "rec-1", + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + attendance: [ + { + conferenceRecord: "conferenceRecords/rec-1", + participant: "conferenceRecords/rec-1/participants/p1", + displayName: "Alice", + user: "users/alice", + sessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + }, + ], + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://meet.googleapis.com/v2/conferenceRecords/rec-1", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => { expect( buildGoogleMeetPreflightReport({ @@ -454,6 +640,27 @@ describe("google-meet plugin", () => { expect(result.details.ok).toBe(true); }); + it("reports attendance through the tool", async () => { + stubMeetArtifactsApi(); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { attendance?: Array<{ displayName?: string }> } }>; + }; + + const result = await tool.execute("id", { + action: "attendance", + accessToken: "token", + expiresAt: Date.now() + 120_000, + conferenceRecord: "rec-1", + pageSize: 3, + }); + + expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]); + }); + it("fails setup status when the configured Chrome node is not connected", async () => { const { tools } = setup( { @@ -630,6 +837,81 @@ describe("google-meet plugin", () => { } }); + it("CLI artifacts prints JSON output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--json", + ], + { from: "user" }, + ); + expect(JSON.parse(stdout.output())).toMatchObject({ + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + tokenSource: "cached-access-token", + }); + } finally { + stdout.restore(); + } + }); + + it("CLI attendance prints participant sessions by default", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("attendance rows: 1"); + expect(stdout.output()).toContain("participant: Alice"); + expect(stdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + stdout.restore(); + } + }); + it("CLI doctor prints human-readable session health", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 3d9c6495c6e..10a668ae0f5 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -15,7 +15,12 @@ import { createMeetFromParams, shouldJoinCreatedMeet, } from "./src/create.js"; -import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js"; +import { + buildGoogleMeetPreflightReport, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, + fetchGoogleMeetSpace, +} from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; @@ -145,6 +150,8 @@ const GoogleMeetToolSchema = Type.Object({ "setup_status", "resolve_space", "preflight", + "artifacts", + "attendance", "recover_current_tab", "leave", "speak", @@ -175,6 +182,10 @@ const GoogleMeetToolSchema = Type.Object({ sessionId: Type.Optional(Type.String({ description: "Meet session ID" })), message: Type.Optional(Type.String({ description: "Realtime instructions to speak now" })), meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })), + conferenceRecord: Type.Optional( + Type.String({ description: "Meet conferenceRecords/{id} resource name or id" }), + ), + pageSize: Type.Optional(Type.Number({ description: "Meet API page size for list actions" })), accessToken: Type.Optional(Type.String({ description: "Access token override" })), refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), @@ -211,15 +222,33 @@ function resolveMeetingInput(config: GoogleMeetConfig, value: unknown): string { return meeting; } -async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { - const meeting = resolveMeetingInput(config, raw.meeting); - const token = await resolveGoogleMeetAccessToken({ +function resolveOptionalPositiveInteger(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = typeof value === "number" ? value : Number(normalizeOptionalString(value)); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("Expected pageSize to be a positive integer"); + } + return parsed; +} + +async function resolveGoogleMeetTokenFromParams( + config: GoogleMeetConfig, + raw: Record, +) { + return resolveGoogleMeetAccessToken({ clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret, refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken, accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken, expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt, }); +} + +async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { + const meeting = resolveMeetingInput(config, raw.meeting); + const token = await resolveGoogleMeetTokenFromParams(config, raw); const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting, @@ -227,6 +256,24 @@ async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record, +) { + const meeting = normalizeOptionalString(raw.meeting) ?? config.defaults.meeting; + const conferenceRecord = normalizeOptionalString(raw.conferenceRecord); + if (!meeting && !conferenceRecord) { + throw new Error("Meeting input or conferenceRecord required"); + } + const token = await resolveGoogleMeetTokenFromParams(config, raw); + return { + token, + meeting, + conferenceRecord, + pageSize: resolveOptionalPositiveInteger(raw.pageSize), + }; +} + export default definePluginEntry({ id: "google-meet", name: "Google Meet", @@ -337,6 +384,48 @@ export default definePluginEntry({ }, ); + api.registerGatewayMethod( + "googlemeet.artifacts", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const resolved = await resolveArtifactQueryFromParams(config, raw); + respond( + true, + await fetchGoogleMeetArtifacts({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "googlemeet.attendance", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const resolved = await resolveArtifactQueryFromParams(config, raw); + respond( + true, + await fetchGoogleMeetAttendance({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + api.registerGatewayMethod( "googlemeet.leave", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -469,6 +558,28 @@ export default definePluginEntry({ }), ); } + case "artifacts": { + const resolved = await resolveArtifactQueryFromParams(config, raw); + return json( + await fetchGoogleMeetArtifacts({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } + case "attendance": { + const resolved = await resolveArtifactQueryFromParams(config, raw); + return json( + await fetchGoogleMeetAttendance({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } case "leave": { const rt = await ensureRuntime(); const sessionId = normalizeOptionalString(raw.sessionId); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index d015304b753..c2ce43744da 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -5,7 +5,11 @@ import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./co import { buildGoogleMeetPreflightReport, createGoogleMeetSpace, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, fetchGoogleMeetSpace, + type GoogleMeetArtifactsResult, + type GoogleMeetAttendanceResult, } from "./meet.js"; import { buildGoogleMeetAuthUrl, @@ -44,6 +48,11 @@ type ResolveSpaceOptions = { json?: boolean; }; +type MeetArtifactOptions = ResolveSpaceOptions & { + conferenceRecord?: string; + pageSize?: string; +}; + type SetupOptions = { json?: boolean; }; @@ -251,6 +260,38 @@ function resolveCreateTokenOptions( }; } +function resolveArtifactTokenOptions( + config: GoogleMeetConfig, + options: MeetArtifactOptions, +): { + meeting?: string; + conferenceRecord?: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; + pageSize?: number; +} { + const meeting = options.meeting?.trim() || config.defaults.meeting; + const conferenceRecord = options.conferenceRecord?.trim(); + if (!meeting && !conferenceRecord) { + throw new Error( + "Meeting input or conference record is required. Pass --meeting, --conference-record, or configure defaults.meeting.", + ); + } + return { + meeting, + conferenceRecord, + clientId: options.clientId?.trim() || config.oauth.clientId, + clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret, + refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken, + accessToken: options.accessToken?.trim() || config.oauth.accessToken, + expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, + pageSize: parseOptionalNumber(options.pageSize), + }; +} + function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boolean { return Boolean( options.accessToken?.trim() || @@ -260,6 +301,67 @@ function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boole ); } +function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + for (const entry of result.artifacts) { + writeStdoutLine(""); + writeStdoutLine("record: %s", entry.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime)); + writeStdoutLine("participants: %d", entry.participants.length); + writeStdoutLine("recordings: %d", entry.recordings.length); + writeStdoutLine("transcripts: %d", entry.transcripts.length); + writeStdoutLine("smart notes: %d", entry.smartNotes.length); + if (entry.smartNotesError) { + writeStdoutLine("smart notes warning: %s", entry.smartNotesError); + } + for (const recording of entry.recordings) { + writeStdoutLine("- recording: %s", recording.name); + } + for (const transcript of entry.transcripts) { + writeStdoutLine("- transcript: %s", transcript.name); + } + for (const smartNote of entry.smartNotes) { + writeStdoutLine("- smart note: %s", smartNote.name); + } + } +} + +function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + writeStdoutLine("attendance rows: %d", result.attendance.length); + for (const row of result.attendance) { + const identity = row.displayName || row.user || row.participant; + writeStdoutLine(""); + writeStdoutLine("participant: %s", identity); + writeStdoutLine("record: %s", row.conferenceRecord); + writeStdoutLine("resource: %s", row.participant); + writeStdoutLine("first joined: %s", formatOptional(row.earliestStartTime)); + writeStdoutLine("last left: %s", formatOptional(row.latestEndTime)); + writeStdoutLine("sessions: %d", row.sessions.length); + for (const session of row.sessions) { + writeStdoutLine( + "- %s: %s -> %s", + session.name, + formatOptional(session.startTime), + formatOptional(session.endTime), + ); + } + } +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -570,6 +672,76 @@ export function registerGoogleMeetCli(params: { } }); + root + .command("artifacts") + .description("List Meet conference records and available participant/artifact metadata") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--page-size ", "Max resources per Meet API page") + .option("--json", "Print JSON output", false) + .action(async (options: MeetArtifactOptions) => { + const resolved = resolveArtifactTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchGoogleMeetArtifacts({ + accessToken: token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeArtifactsSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("attendance") + .description("List Meet participants and participant sessions") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--page-size ", "Max resources per Meet API page") + .option("--json", "Print JSON output", false) + .action(async (options: MeetArtifactOptions) => { + const resolved = resolveArtifactTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeAttendanceSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("status") .argument("[session-id]", "Meet session ID") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index fac7bc94c9c..acf1ba9af88 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -1,6 +1,7 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -const GOOGLE_MEET_API_BASE_URL = "https://meet.googleapis.com/v2"; +const GOOGLE_MEET_API_ORIGIN = "https://meet.googleapis.com"; +const GOOGLE_MEET_API_BASE_URL = `${GOOGLE_MEET_API_ORIGIN}/v2`; const GOOGLE_MEET_URL_HOST = "meet.google.com"; const GOOGLE_MEET_API_HOST = "meet.googleapis.com"; @@ -28,6 +29,95 @@ export type GoogleMeetCreateSpaceResult = { meetingUri: string; }; +export type GoogleMeetConferenceRecord = { + name: string; + space?: string; + startTime?: string; + endTime?: string; + expireTime?: string; +}; + +export type GoogleMeetParticipant = { + name: string; + earliestStartTime?: string; + latestEndTime?: string; + signedinUser?: { + user?: string; + displayName?: string; + }; + anonymousUser?: { + displayName?: string; + }; + phoneUser?: { + displayName?: string; + }; +}; + +export type GoogleMeetParticipantSession = { + name: string; + startTime?: string; + endTime?: string; +}; + +export type GoogleMeetRecording = { + name: string; + startTime?: string; + endTime?: string; + driveDestination?: Record; +}; + +export type GoogleMeetTranscript = { + name: string; + startTime?: string; + endTime?: string; + docsDestination?: Record; +}; + +export type GoogleMeetSmartNote = { + name: string; + startTime?: string; + endTime?: string; + docsDestination?: Record; +}; + +export type GoogleMeetArtifactsEntry = { + conferenceRecord: GoogleMeetConferenceRecord; + participants: GoogleMeetParticipant[]; + recordings: GoogleMeetRecording[]; + transcripts: GoogleMeetTranscript[]; + smartNotes: GoogleMeetSmartNote[]; + smartNotesError?: string; +}; + +export type GoogleMeetArtifactsResult = { + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; + artifacts: GoogleMeetArtifactsEntry[]; +}; + +export type GoogleMeetAttendanceRow = { + conferenceRecord: string; + participant: string; + displayName?: string; + user?: string; + earliestStartTime?: string; + latestEndTime?: string; + sessions: GoogleMeetParticipantSession[]; +}; + +export type GoogleMeetAttendanceResult = { + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; + attendance: GoogleMeetAttendanceRow[]; +}; + +type GoogleMeetSmartNotesListResult = { + smartNotes: GoogleMeetSmartNote[]; + smartNotesError?: string; +}; + export function normalizeGoogleMeetSpaceName(input: string): string { const trimmed = input.trim(); if (!trimmed) { @@ -61,6 +151,121 @@ function encodeSpaceNameForPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } +function encodeResourceNameForPath(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Google Meet resource name is required"); + } + return trimmed.split("/").map(encodeURIComponent).join("/"); +} + +function normalizeConferenceRecordName(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Conference record is required"); + } + return trimmed.startsWith("conferenceRecords/") ? trimmed : `conferenceRecords/${trimmed}`; +} + +function appendQuery( + url: string, + query?: Record, +): string { + if (!query) { + return url; + } + const parsed = new URL(url); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + parsed.searchParams.set(key, String(value)); + } + } + return parsed.toString(); +} + +function assertResourceArray( + value: unknown, + key: string, + context: string, +): T[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error(`Google Meet ${context} response had non-array ${key}`); + } + const resources = value as T[]; + for (const resource of resources) { + if (!resource.name?.trim()) { + throw new Error(`Google Meet ${context} response included a resource without name`); + } + } + return resources; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function fetchGoogleMeetJson(params: { + accessToken: string; + path: string; + query?: Record; + auditContext: string; + errorPrefix: string; +}): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query), + init: { + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + }, + }, + policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + auditContext: params.auditContext, + }); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`${params.errorPrefix} failed (${response.status}): ${detail}`); + } + return (await response.json()) as T; + } finally { + await release(); + } +} + +async function listGoogleMeetCollection(params: { + accessToken: string; + path: string; + collectionKey: string; + query?: Record; + auditContext: string; + errorPrefix: string; +}): Promise { + const items: T[] = []; + let pageToken: string | undefined; + do { + const payload = await fetchGoogleMeetJson>({ + accessToken: params.accessToken, + path: params.path, + query: { ...params.query, pageToken }, + auditContext: params.auditContext, + errorPrefix: params.errorPrefix, + }); + items.push( + ...assertResourceArray( + payload[params.collectionKey], + params.collectionKey, + params.errorPrefix, + ), + ); + pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined; + } while (pageToken); + return items; +} + export async function fetchGoogleMeetSpace(params: { accessToken: string; meeting: string; @@ -128,6 +333,269 @@ export async function createGoogleMeetSpace(params: { } } +export async function fetchGoogleMeetConferenceRecord(params: { + accessToken: string; + conferenceRecord: string; +}): Promise { + const name = normalizeConferenceRecordName(params.conferenceRecord); + const payload = await fetchGoogleMeetJson({ + accessToken: params.accessToken, + path: encodeResourceNameForPath(name), + auditContext: "google-meet.conferenceRecords.get", + errorPrefix: "Google Meet conferenceRecords.get", + }); + if (!payload.name?.trim()) { + throw new Error("Google Meet conferenceRecords.get response was missing name"); + } + return payload; +} + +export async function listGoogleMeetConferenceRecords(params: { + accessToken: string; + meeting?: string; + pageSize?: number; +}): Promise { + const filter = params.meeting + ? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"` + : undefined; + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: "conferenceRecords", + collectionKey: "conferenceRecords", + query: { + pageSize: params.pageSize, + filter, + }, + auditContext: "google-meet.conferenceRecords.list", + errorPrefix: "Google Meet conferenceRecords.list", + }); +} + +export async function listGoogleMeetParticipants(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/participants`, + collectionKey: "participants", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.participants.list", + errorPrefix: "Google Meet conferenceRecords.participants.list", + }); +} + +export async function listGoogleMeetParticipantSessions(params: { + accessToken: string; + participant: string; + pageSize?: number; +}): Promise { + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(params.participant)}/participantSessions`, + collectionKey: "participantSessions", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.participants.participantSessions.list", + errorPrefix: "Google Meet conferenceRecords.participants.participantSessions.list", + }); +} + +export async function listGoogleMeetRecordings(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/recordings`, + collectionKey: "recordings", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.recordings.list", + errorPrefix: "Google Meet conferenceRecords.recordings.list", + }); +} + +export async function listGoogleMeetTranscripts(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/transcripts`, + collectionKey: "transcripts", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.transcripts.list", + errorPrefix: "Google Meet conferenceRecords.transcripts.list", + }); +} + +export async function listGoogleMeetSmartNotes(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/smartNotes`, + collectionKey: "smartNotes", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.smartNotes.list", + errorPrefix: "Google Meet conferenceRecords.smartNotes.list", + }); +} + +function getParticipantDisplayName(participant: GoogleMeetParticipant): string | undefined { + return ( + participant.signedinUser?.displayName ?? + participant.anonymousUser?.displayName ?? + participant.phoneUser?.displayName + ); +} + +function getParticipantUser(participant: GoogleMeetParticipant): string | undefined { + return participant.signedinUser?.user; +} + +async function resolveConferenceRecordQuery(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise<{ + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; +}> { + if (params.conferenceRecord?.trim()) { + const conferenceRecord = await fetchGoogleMeetConferenceRecord({ + accessToken: params.accessToken, + conferenceRecord: params.conferenceRecord, + }); + return { + input: params.conferenceRecord.trim(), + conferenceRecords: [conferenceRecord], + }; + } + if (!params.meeting?.trim()) { + throw new Error("Meeting input or conference record is required"); + } + const space = await fetchGoogleMeetSpace({ + accessToken: params.accessToken, + meeting: params.meeting, + }); + const conferenceRecords = await listGoogleMeetConferenceRecords({ + accessToken: params.accessToken, + meeting: space.name, + pageSize: params.pageSize, + }); + return { + input: params.meeting, + space, + conferenceRecords, + }; +} + +export async function fetchGoogleMeetArtifacts(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise { + const resolved = await resolveConferenceRecordQuery(params); + const artifacts = await Promise.all( + resolved.conferenceRecords.map(async (conferenceRecord) => { + const [participants, recordings, transcripts, smartNotesResult] = await Promise.all([ + listGoogleMeetParticipants({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetRecordings({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetTranscripts({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetSmartNotes({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }) + .then((smartNotes) => ({ smartNotes })) + .catch((error: unknown) => ({ + smartNotes: [], + smartNotesError: getErrorMessage(error), + })), + ]); + return { + conferenceRecord, + participants, + recordings, + transcripts, + smartNotes: smartNotesResult.smartNotes, + ...(smartNotesResult.smartNotesError + ? { smartNotesError: smartNotesResult.smartNotesError } + : {}), + }; + }), + ); + return { + input: resolved.input, + space: resolved.space, + conferenceRecords: resolved.conferenceRecords, + artifacts, + }; +} + +export async function fetchGoogleMeetAttendance(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise { + const resolved = await resolveConferenceRecordQuery(params); + const nestedRows = await Promise.all( + resolved.conferenceRecords.map(async (conferenceRecord) => { + const participants = await listGoogleMeetParticipants({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }); + return Promise.all( + participants.map(async (participant) => ({ + conferenceRecord: conferenceRecord.name, + participant: participant.name, + displayName: getParticipantDisplayName(participant), + user: getParticipantUser(participant), + earliestStartTime: participant.earliestStartTime, + latestEndTime: participant.latestEndTime, + sessions: await listGoogleMeetParticipantSessions({ + accessToken: params.accessToken, + participant: participant.name, + pageSize: params.pageSize, + }), + })), + ); + }), + ); + return { + input: resolved.input, + space: resolved.space, + conferenceRecords: resolved.conferenceRecords, + attendance: nestedRows.flat(), + }; +} + export function buildGoogleMeetPreflightReport(params: { input: string; space: GoogleMeetSpace; From 6b38714cb9afa59f57cea85bc5b4afa1a53c269f Mon Sep 17 00:00:00 2001 From: Charles Dusek <38732970+cgdusek@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:36:40 -0500 Subject: [PATCH 65/93] fix(agents): guard malformed tool result text blocks Harden context pruning and tool-result character estimation against malformed `{ type: "text" }` blocks created by void/undefined tool handler results. - Require text blocks to carry a string before using `.length` in the tool-result estimator. - Guard context-pruning text/image loops against malformed and null content entries. - Serialize malformed non-string text blocks for pruning size accounting so they cannot bypass trimming as zero-sized. - Add regression coverage for malformed text blocks, null entries, and non-string text payloads. Closes #34979. Maintainer verification: - `pnpm test src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts src/agents/pi-hooks/context-pruning/pruner.test.ts` - `pnpm check:changed` - GitHub checks passed, including the OpenAI / Opus 4.6 parity gate. Based on prior work by #39331 and #34980. Co-authored-by: Charles Dusek Co-authored-by: alvinttang Co-authored-by: coffeexcoin Co-authored-by: Claude Opus 4.6 --- .../tool-result-char-estimator.test.ts | 69 +++++++++ .../tool-result-char-estimator.ts | 7 +- .../pi-hooks/context-pruning/pruner.test.ts | 135 ++++++++++++++++++ src/agents/pi-hooks/context-pruning/pruner.ts | 46 ++++-- 4 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts diff --git a/src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts b/src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts new file mode 100644 index 00000000000..8e00c10b823 --- /dev/null +++ b/src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts @@ -0,0 +1,69 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { + createMessageCharEstimateCache, + estimateMessageCharsCached, + getToolResultText, +} from "./tool-result-char-estimator.js"; + +/** + * Regression tests for malformed tool result content blocks. + * See https://github.com/openclaw/openclaw/issues/34979 + * + * A plugin tool handler returning undefined produces {type: "text"} (no text + * property) in the session JSONL. Without guards, this crashes the char + * estimator with: TypeError: Cannot read properties of undefined (reading 'length') + */ +describe("tool-result-char-estimator", () => { + it("does not crash on toolResult with malformed text block (missing text string)", () => { + const malformed = { + role: "toolResult", + toolName: "sentinel_control", + content: [{ type: "text" }], + isError: false, + timestamp: Date.now(), + } as unknown as AgentMessage; + + const cache = createMessageCharEstimateCache(); + expect(() => estimateMessageCharsCached(malformed, cache)).not.toThrow(); + // Malformed block should be estimated via the unknown-block fallback, not zero + expect(estimateMessageCharsCached(malformed, cache)).toBeGreaterThan(0); + }); + + it("does not crash on toolResult with null content entries", () => { + const malformed = { + role: "toolResult", + toolName: "read", + content: [null, { type: "text", text: "ok" }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + const cache = createMessageCharEstimateCache(); + expect(() => estimateMessageCharsCached(malformed, cache)).not.toThrow(); + }); + + it("getToolResultText skips malformed text blocks without crashing", () => { + const malformed = { + role: "toolResult", + toolName: "sentinel_control", + content: [{ type: "text" }, { type: "text", text: "valid" }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + expect(() => getToolResultText(malformed)).not.toThrow(); + expect(getToolResultText(malformed)).toBe("valid"); + }); + + it("estimates well-formed toolResult correctly", () => { + const msg = { + role: "toolResult", + toolName: "read", + content: [{ type: "text", text: "hello world" }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + const cache = createMessageCharEstimateCache(); + const chars = estimateMessageCharsCached(msg, cache); + expect(chars).toBeGreaterThanOrEqual(11); // "hello world".length + }); +}); diff --git a/src/agents/pi-embedded-runner/tool-result-char-estimator.ts b/src/agents/pi-embedded-runner/tool-result-char-estimator.ts index 6d022d62289..0b836a12f3b 100644 --- a/src/agents/pi-embedded-runner/tool-result-char-estimator.ts +++ b/src/agents/pi-embedded-runner/tool-result-char-estimator.ts @@ -7,7 +7,12 @@ const IMAGE_CHAR_ESTIMATE = 8_000; export type MessageCharEstimateCache = WeakMap; function isTextBlock(block: unknown): block is { type: "text"; text: string } { - return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text"; + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ); } function isImageBlock(block: unknown): boolean { diff --git a/src/agents/pi-hooks/context-pruning/pruner.test.ts b/src/agents/pi-hooks/context-pruning/pruner.test.ts index e4ef978b018..0b334339650 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.test.ts +++ b/src/agents/pi-hooks/context-pruning/pruner.test.ts @@ -152,6 +152,141 @@ describe("pruneContextMessages", () => { ).not.toThrow(); }); + it("does not crash on toolResult with malformed text block (missing text string)", () => { + // 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 + const malformedToolResult = { + role: "toolResult", + toolName: "sentinel_control", + content: [{ type: "text" }], + isError: false, + timestamp: Date.now(), + } as unknown as AgentMessage; + + const messages: AgentMessage[] = [ + makeUser("remove sentinel"), + makeAssistant([ + { type: "toolCall", toolCallId: "call_1", toolName: "sentinel_control", arguments: {} }, + ] as unknown as AssistantContentBlock[]), + malformedToolResult, + makeUser("follow up"), + makeAssistant([{ type: "text", text: "done" }]), + ]; + + expect(() => + pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }), + ).not.toThrow(); + }); + + it("does not crash on toolResult with malformed text block during soft-trim (image path)", () => { + // The collectPrunableToolResultSegments path is exercised when the tool result + // contains image blocks alongside a malformed text block. + const malformedToolResult = { + role: "toolResult", + toolName: "read", + content: [{ type: "text" }, { type: "image", data: "img", mimeType: "image/png" }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + const messages: AgentMessage[] = [ + makeUser("show image"), + malformedToolResult, + 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, + }, + }, + ctx: CONTEXT_WINDOW_1M, + isToolPrunable: () => true, + contextWindowTokensOverride: 1, + }), + ).not.toThrow(); + }); + + it("counts malformed non-string text blocks when deciding to trim tool results", () => { + const malformedToolResult = { + role: "toolResult", + toolName: "read", + content: [{ type: "text", text: { payload: "X".repeat(5_000) } }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + const result = pruneContextMessages({ + messages: [ + makeUser("show data"), + malformedToolResult, + makeAssistant([{ type: "text", text: "done" }]), + ], + settings: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, + keepLastAssistants: 1, + softTrimRatio: 0, + hardClear: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, + enabled: false, + }, + softTrim: { + maxChars: 200, + headChars: 80, + tailChars: 40, + }, + }, + ctx: CONTEXT_WINDOW_1M, + isToolPrunable: () => true, + contextWindowTokensOverride: 1, + }); + + const toolResult = result.find((message) => message.role === "toolResult") as Extract< + AgentMessage, + { role: "toolResult" } + >; + const textBlock = toolResult.content[0] as { type: "text"; text: string }; + expect(textBlock.text).toContain("[Tool result trimmed:"); + }); + + it("does not crash on toolResult with null content entries", () => { + const malformedToolResult = { + role: "toolResult", + toolName: "read", + content: [null, { type: "text", text: "ok" }], + timestamp: Date.now(), + } as unknown as AgentMessage; + + const messages: AgentMessage[] = [ + makeUser("hello"), + malformedToolResult, + makeAssistant([{ type: "text", text: "done" }]), + ]; + + expect(() => + pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }), + ).not.toThrow(); + }); + it("handles well-formed thinking blocks correctly", () => { const messages: AgentMessage[] = [ makeUser("hello"), diff --git a/src/agents/pi-hooks/context-pruning/pruner.ts b/src/agents/pi-hooks/context-pruning/pruner.ts index 6171480e2ac..c1b3c041169 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.ts +++ b/src/agents/pi-hooks/context-pruning/pruner.ts @@ -13,11 +13,36 @@ function asText(text: string): TextContent { return { type: "text", text }; } +function serializeMalformedTextBlock(block: unknown): string { + try { + const serialized = JSON.stringify(block); + return typeof serialized === "string" ? serialized : "[malformed text block]"; + } catch { + return "[malformed text block]"; + } +} + +function coerceTextBlock(block: unknown): string | null { + if (!block || typeof block !== "object") { + return null; + } + if ((block as { type?: unknown }).type !== "text") { + return null; + } + const text = (block as { text?: unknown }).text; + return typeof text === "string" ? text : serializeMalformedTextBlock(block); +} + +function isImageBlock(block: unknown): boolean { + return !!block && typeof block === "object" && (block as { type?: unknown }).type === "image"; +} + function collectTextSegments(content: ReadonlyArray): string[] { const parts: string[] = []; for (const block of content) { - if (block.type === "text") { - parts.push(block.text); + const text = coerceTextBlock(block); + if (text !== null) { + parts.push(text); } } return parts; @@ -28,11 +53,12 @@ function collectPrunableToolResultSegments( ): string[] { const parts: string[] = []; for (const block of content) { - if (block.type === "text") { - parts.push(block.text); + const text = coerceTextBlock(block); + if (text !== null) { + parts.push(text); continue; } - if (block.type === "image") { + if (isImageBlock(block)) { parts.push(PRUNED_CONTEXT_IMAGE_MARKER); } } @@ -105,7 +131,7 @@ function takeTailFromJoinedText(parts: string[], maxChars: number): string { function hasImageBlocks(content: ReadonlyArray): boolean { for (const block of content) { - if (block.type === "image") { + if (isImageBlock(block)) { return true; } } @@ -119,10 +145,12 @@ function estimateWeightedTextChars(text: string): number { function estimateTextAndImageChars(content: ReadonlyArray): number { let chars = 0; for (const block of content) { - if (block.type === "text") { - chars += estimateWeightedTextChars(block.text); + const text = coerceTextBlock(block); + if (text !== null) { + chars += estimateWeightedTextChars(text); + continue; } - if (block.type === "image") { + if (isImageBlock(block)) { chars += IMAGE_CHAR_ESTIMATE; } } From 3d554aefdfa773b375df207f9663fcc4b01385f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:36:57 -0700 Subject: [PATCH 66/93] fix(logging): keep log transport internals private (#71322) * fix(logging): share transports across module instances * fix(logging): share transports across module instances * fix(logging): share transports across module instances * fix(logging): remove global log transport hooks * test(agents): capture diagnostic logs after module reset --- src/agents/model-fallback.probe.test.ts | 27 ++++----- src/agents/model-fallback.test.ts | 2 +- src/agents/model-selection.test.ts | 4 +- ...pi-agent.auth-profile-rotation.e2e.test.ts | 44 ++++++-------- src/logging/logger-transport.test.ts | 60 +++++++++++++++++++ src/logging/logger.ts | 39 +----------- .../test-helpers/diagnostic-log-capture.ts | 25 ++++++++ src/logging/test-helpers/warn-log-capture.ts | 22 +++---- src/plugin-sdk/diagnostics-otel.ts | 1 - 9 files changed, 128 insertions(+), 96 deletions(-) create mode 100644 src/logging/logger-transport.test.ts create mode 100644 src/logging/test-helpers/diagnostic-log-capture.ts diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index d7b359e10bd..ff9a0e045fd 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { createDiagnosticLogRecordCapture } from "../logging/test-helpers/diagnostic-log-capture.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -56,12 +57,11 @@ let mockedResolveAuthProfileOrder: ReturnType< >; let runWithModelFallback: ModelFallbackModule["runWithModelFallback"]; let _probeThrottleInternals: ModelFallbackModule["_probeThrottleInternals"]; -let registerLogTransport: LoggerModule["registerLogTransport"]; let resetLogger: LoggerModule["resetLogger"]; let setLoggerOverride: LoggerModule["setLoggerOverride"]; const makeCfg = makeModelFallbackCfg; -let unregisterLogTransport: (() => void) | undefined; +let cleanupLogCapture: (() => void) | undefined; async function loadModelFallbackProbeModules() { const authProfilesStoreModule = await import("./auth-profiles/store.js"); @@ -82,7 +82,6 @@ async function loadModelFallbackProbeModules() { mockedResolveAuthProfileOrder = vi.mocked(authProfilesOrderModule.resolveAuthProfileOrder); runWithModelFallback = modelFallbackModule.runWithModelFallback; _probeThrottleInternals = modelFallbackModule._probeThrottleInternals; - registerLogTransport = loggerModule.registerLogTransport; resetLogger = loggerModule.resetLogger; setLoggerOverride = loggerModule.setLoggerOverride; } @@ -236,8 +235,8 @@ describe("runWithModelFallback – probe logic", () => { afterEach(() => { Date.now = realDateNow; - unregisterLogTransport?.(); - unregisterLogTransport = undefined; + cleanupLogCapture?.(); + cleanupLogCapture = undefined; setLoggerOverride(null); resetLogger(); vi.restoreAllMocks(); @@ -275,16 +274,14 @@ describe("runWithModelFallback – probe logic", () => { it("logs primary metadata on probe success and failure fallback decisions", async () => { const cfg = makeCfg(); - const records: Array> = []; + const logCapture = createDiagnosticLogRecordCapture(); + cleanupLogCapture = logCapture.cleanup; mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000); setLoggerOverride({ level: "trace", consoleLevel: "silent", file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`), }); - unregisterLogTransport = registerLogTransport((record) => { - records.push(record); - }); const run = vi.fn().mockResolvedValue("probed-ok"); @@ -311,6 +308,7 @@ describe("runWithModelFallback – probe logic", () => { .mockResolvedValueOnce("fallback-ok"); const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun); + await logCapture.flush(); expect(fallbackResult.result).toBe("fallback-ok"); expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { @@ -318,14 +316,9 @@ describe("runWithModelFallback – probe logic", () => { }); expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); - const decisionPayloads = records - .filter( - (record) => - record["2"] === "model fallback decision" && - record["1"] && - typeof record["1"] === "object", - ) - .map((record) => record["1"] as Record); + const decisionPayloads = logCapture.records + .filter((record) => record.message === "model fallback decision") + .map((record) => record.attributes ?? {}); expect(decisionPayloads).toEqual( expect.arrayContaining([ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index b11456ecb5c..e9be6319e88 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -1014,7 +1014,7 @@ describe("runWithModelFallback", () => { }); expect(result.result).toBe("ok"); - const warning = warnLogs.findText('Model "openai/gpt-6spoof" not found'); + const warning = await warnLogs.findText('Model "openai/gpt-6spoof" not found'); expect(warning).toContain('Model "openai/gpt-6spoof" not found'); expect(warning).not.toContain("\u001B"); expect(warning).not.toContain("\n"); diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 57597e392cb..b7de04c4f7b 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -966,7 +966,7 @@ describe("model-selection", () => { } }); - it("sanitizes control characters in providerless-model warnings", () => { + it("sanitizes control characters in providerless-model warnings", async () => { const warnLogs = createWarnLogCapture("openclaw-model-selection-test"); try { const cfg: Partial = { @@ -987,7 +987,7 @@ describe("model-selection", () => { provider: "google", model: "\u001B[31mclaude-3-5-sonnet\nspoof", }); - const warning = warnLogs.findText('Falling back to "google/claude-3-5-sonnet"'); + const warning = await warnLogs.findText('Falling back to "google/claude-3-5-sonnet"'); expect(warning).toContain('Falling back to "google/claude-3-5-sonnet"'); expect(warning).not.toContain("\u001B"); expect(warning).not.toContain("\n"); 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 b242e091993..21ba2f372b3 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 @@ -106,8 +106,8 @@ const installRunEmbeddedMocks = () => { }; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; -let unregisterLogTransport: (() => void) | undefined; -let registerLogTransportFn: typeof import("../logging/logger.js").registerLogTransport; +let createDiagnosticLogRecordCaptureFn: typeof import("../logging/test-helpers/diagnostic-log-capture.js").createDiagnosticLogRecordCapture; +let cleanupLogCapture: (() => void) | undefined; let resetLoggerFn: typeof import("../logging/logger.js").resetLogger; let setLoggerOverrideFn: typeof import("../logging/logger.js").setLoggerOverride; const originalFetch = globalThis.fetch; @@ -116,11 +116,10 @@ beforeAll(async () => { vi.resetModules(); installRunEmbeddedMocks(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); - ({ - registerLogTransport: registerLogTransportFn, - resetLogger: resetLoggerFn, - setLoggerOverride: setLoggerOverrideFn, - } = await import("../logging/logger.js")); + ({ createDiagnosticLogRecordCapture: createDiagnosticLogRecordCaptureFn } = + await import("../logging/test-helpers/diagnostic-log-capture.js")); + ({ resetLogger: resetLoggerFn, setLoggerOverride: setLoggerOverrideFn } = + await import("../logging/logger.js")); }); async function runEmbeddedPiAgentInline( @@ -152,8 +151,8 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; - unregisterLogTransport?.(); - unregisterLogTransport = undefined; + cleanupLogCapture?.(); + cleanupLogCapture = undefined; setLoggerOverrideFn(null); resetLoggerFn(); }); @@ -864,15 +863,13 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); it("logs structured failover decision metadata for overloaded assistant rotation", async () => { - const records: Array> = []; + const logCapture = createDiagnosticLogRecordCaptureFn(); + cleanupLogCapture = logCapture.cleanup; setLoggerOverrideFn({ level: "trace", consoleLevel: "silent", file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`), }); - unregisterLogTransport = registerLogTransportFn((record) => { - records.push(record); - }); await runAutoPinnedRotationCase({ errorMessage: @@ -880,18 +877,17 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { sessionKey: "agent:test:overloaded-logging", runId: "run:overloaded-logging", }); + await logCapture.flush(); - const decisionRecord = records.find( + const decisionRecord = logCapture.records.find( (record) => - record["2"] === "embedded run failover decision" && - record["1"] && - typeof record["1"] === "object" && - (record["1"] as Record).decision === "rotate_profile", + record.message === "embedded run failover decision" && + record.attributes?.decision === "rotate_profile", ); expect(decisionRecord).toBeDefined(); const safeProfileId = redactIdentifier("openai:p1", { len: 12 }); - expect((decisionRecord as Record)["1"]).toMatchObject({ + expect(decisionRecord?.attributes).toMatchObject({ event: "embedded_run_failover_decision", runId: "run:overloaded-logging", decision: "rotate_profile", @@ -903,16 +899,14 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { rawErrorPreview: expect.stringContaining('"request_id":"sha256:'), }); - const stateRecord = records.find( + const stateRecord = logCapture.records.find( (record) => - record["2"] === "auth profile failure state updated" && - record["1"] && - typeof record["1"] === "object" && - (record["1"] as Record).profileId === safeProfileId, + record.message === "auth profile failure state updated" && + record.attributes?.profileId === safeProfileId, ); expect(stateRecord).toBeDefined(); - expect((stateRecord as Record)["1"]).toMatchObject({ + expect(stateRecord?.attributes).toMatchObject({ event: "auth_profile_failure_state_updated", runId: "run:overloaded-logging", profileId: safeProfileId, diff --git a/src/logging/logger-transport.test.ts b/src/logging/logger-transport.test.ts new file mode 100644 index 00000000000..1a68a967881 --- /dev/null +++ b/src/logging/logger-transport.test.ts @@ -0,0 +1,60 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { createSuiteLogPathTracker } from "./log-test-helpers.js"; + +type LoggerModule = typeof import("./logger.js"); + +const logPathTracker = createSuiteLogPathTracker("openclaw-logger-transport-"); +const importedModules: LoggerModule[] = []; + +async function importLoggerModule(scope: string): Promise { + const module = await importFreshModule( + import.meta.url, + `./logger.js?scope=${scope}`, + ); + importedModules.push(module); + module.setLoggerOverride({ + level: "info", + file: logPathTracker.nextPath(), + }); + return module; +} + +describe("logger transport registry", () => { + beforeAll(async () => { + await logPathTracker.setup(); + }); + + afterEach(() => { + while (importedModules.length > 0) { + const module = importedModules.pop(); + module?.resetLogger(); + module?.setLoggerOverride(null); + } + }); + + afterAll(async () => { + await logPathTracker.cleanup(); + }); + + it("does not expose production or test log transport registration", async () => { + const loggerModule = await importLoggerModule("public-api"); + + expect( + (loggerModule as unknown as Record).registerLogTransport, + ).toBeUndefined(); + expect( + (loggerModule.__test__ as unknown as Record).registerLogTransportForTest, + ).toBeUndefined(); + }); + + it("does not publish mutable log transport state on a well-known global symbol", async () => { + await importLoggerModule("global-state"); + + expect( + (globalThis as typeof globalThis & Record)[ + Symbol.for("openclaw.logging.transports") + ], + ).toBeUndefined(); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 7c401324806..5be11d17177 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -68,10 +68,7 @@ type ResolvedSettings = { maxFileBytes: number; }; export type LoggerResolvedSettings = ResolvedSettings; -export type LogTransportRecord = Record; -export type LogTransport = (logObj: LogTransportRecord) => void; - -const externalTransports = new Set(); +type TsLogRecord = Record; type DiagnosticLogCode = { line?: number; @@ -87,19 +84,6 @@ const DIAGNOSTIC_LOG_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,64}$/u; type DiagnosticLogAttributes = Record; -function attachExternalTransport(logger: TsLogger, transport: LogTransport): void { - logger.attachTransport((logObj: LogObj) => { - if (!externalTransports.has(transport)) { - return; - } - try { - transport(logObj as LogTransportRecord); - } catch { - // never block on logging failures - } - }); -} - function clampDiagnosticLogText(value: string, maxChars: number): string { return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value; } @@ -237,7 +221,7 @@ function findLogTraceContext( return undefined; } -function buildDiagnosticLogRecord(logObj: LogTransportRecord) { +function buildDiagnosticLogRecord(logObj: TsLogRecord) { const meta = logObj._meta as | { logLevelName?: string; @@ -335,7 +319,7 @@ function buildDiagnosticLogRecord(logObj: LogTransportRecord) { function attachDiagnosticEventTransport(logger: TsLogger): void { logger.attachTransport((logObj: LogObj) => { try { - emitDiagnosticEvent(buildDiagnosticLogRecord(logObj as LogTransportRecord)); + emitDiagnosticEvent(buildDiagnosticLogRecord(logObj as TsLogRecord)); } catch { // never block on logging failures } @@ -425,9 +409,6 @@ function buildLogger(settings: ResolvedSettings): TsLogger { // Silent logging does not write files; skip all filesystem setup in this path. if (settings.level === "silent") { attachDiagnosticEventTransport(logger); - for (const transport of externalTransports) { - attachExternalTransport(logger, transport); - } return logger; } @@ -470,9 +451,6 @@ function buildLogger(settings: ResolvedSettings): TsLogger { } }); attachDiagnosticEventTransport(logger); - for (const transport of externalTransports) { - attachExternalTransport(logger, transport); - } return logger; } @@ -579,17 +557,6 @@ export function resetLogger() { loggingState.overrideSettings = null; } -export function registerLogTransport(transport: LogTransport): () => void { - externalTransports.add(transport); - const logger = loggingState.cachedLogger as TsLogger | null; - if (logger) { - attachExternalTransport(logger, transport); - } - return () => { - externalTransports.delete(transport); - }; -} - export const __test__ = { shouldSkipMutatingLoggingConfigRead, }; diff --git a/src/logging/test-helpers/diagnostic-log-capture.ts b/src/logging/test-helpers/diagnostic-log-capture.ts new file mode 100644 index 00000000000..f40b50619ea --- /dev/null +++ b/src/logging/test-helpers/diagnostic-log-capture.ts @@ -0,0 +1,25 @@ +import { + onInternalDiagnosticEvent, + type DiagnosticEventPayload, +} from "../../infra/diagnostic-events.js"; + +export type CapturedDiagnosticLogRecord = Extract; + +export function flushDiagnosticLogRecords(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +export function createDiagnosticLogRecordCapture() { + const records: CapturedDiagnosticLogRecord[] = []; + const unsubscribe = onInternalDiagnosticEvent((event) => { + if (event.type === "log.record") { + records.push(event); + } + }); + + return { + records, + flush: flushDiagnosticLogRecords, + cleanup: unsubscribe, + }; +} diff --git a/src/logging/test-helpers/warn-log-capture.ts b/src/logging/test-helpers/warn-log-capture.ts index cdd8dd9e771..1a0aac70207 100644 --- a/src/logging/test-helpers/warn-log-capture.ts +++ b/src/logging/test-helpers/warn-log-capture.ts @@ -1,31 +1,25 @@ import path from "node:path"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; -import { - registerLogTransport, - resetLogger, - setLoggerOverride, - type LogTransportRecord, -} from "../logger.js"; +import { resetLogger, setLoggerOverride } from "../logger.js"; +import { createDiagnosticLogRecordCapture } from "./diagnostic-log-capture.js"; export function createWarnLogCapture(prefix: string) { - const records: LogTransportRecord[] = []; + const capture = createDiagnosticLogRecordCapture(); setLoggerOverride({ level: "warn", consoleLevel: "silent", file: path.join(resolvePreferredOpenClawTmpDir(), `${prefix}-${process.pid}-${Date.now()}.log`), }); - const unregister = registerLogTransport((record) => { - records.push(record); - }); return { - findText(needle: string): string | undefined { - return records - .flatMap((record) => Object.values(record)) + async findText(needle: string): Promise { + await capture.flush(); + return capture.records + .flatMap((record) => [record.message, ...Object.values(record.attributes ?? {})]) .filter((value): value is string => typeof value === "string") .find((value) => value.includes(needle)); }, cleanup() { - unregister(); + capture.cleanup(); setLoggerOverride(null); resetLogger(); }, diff --git a/src/plugin-sdk/diagnostics-otel.ts b/src/plugin-sdk/diagnostics-otel.ts index 684d273cd8c..b423d1cd4f8 100644 --- a/src/plugin-sdk/diagnostics-otel.ts +++ b/src/plugin-sdk/diagnostics-otel.ts @@ -17,7 +17,6 @@ export { isValidDiagnosticTraceId, parseDiagnosticTraceparent, } from "../infra/diagnostic-trace-context.js"; -export { registerLogTransport } from "../logging/logger.js"; export { redactSensitiveText } from "../logging/redact.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { From 2ff7eb36cf669364f891429b717fcd0c263b85e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:37:56 +0100 Subject: [PATCH 67/93] fix(models): expose codex runtime context caps --- CHANGELOG.md | 1 + docs/cli/models.md | 4 ++ docs/concepts/model-providers.md | 20 +++--- docs/gateway/config-tools.md | 2 +- .../openai/openai-codex-provider.test.ts | 7 +- extensions/openai/openai-codex-provider.ts | 35 ++++++++-- .../model.provider-runtime.test-support.ts | 49 ++++++++----- .../pi-embedded-runner/model.test-harness.ts | 5 +- src/agents/pi-embedded-runner/model.test.ts | 2 +- .../list.list-command.forward-compat.test.ts | 69 +++++++++++++++++++ src/commands/models/list.model-row.test.ts | 17 +++++ src/commands/models/list.model-row.ts | 2 + src/commands/models/list.row-sources.ts | 2 + src/commands/models/list.rows.ts | 23 ++++++- src/commands/models/list.table.test.ts | 26 +++++++ src/commands/models/list.table.ts | 16 ++++- src/commands/models/list.types.ts | 1 + .../plugins/provider-runtime-contract.ts | 5 +- 18 files changed, 240 insertions(+), 46 deletions(-) create mode 100644 src/commands/models/list.table.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3447be3bc54..db79b8104e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI. - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. +- Models/CLI: show provider runtime `contextTokens` beside native `contextWindow` in `openclaw models list`, and align `openai-codex/gpt-5.5` with Codex's 272K runtime cap plus 400K native window. Fixes #71403. - Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan. - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Control UI/browser: defer temp-dir access-mode constants until Node-only temp-dir resolution runs, preventing browser bundles from crashing when `node:fs` constants are stubbed. (#48930) Thanks @Valentinws. diff --git a/docs/cli/models.md b/docs/cli/models.md index 0a1f7a66ed5..1fd734a505c 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -50,6 +50,10 @@ Notes: - `models list --all` includes bundled provider-owned static catalog rows even when you have not authenticated with that provider yet. Those rows still show as unavailable until matching auth is configured. +- `models list` keeps native model metadata and runtime caps distinct. In table + output, `Ctx` shows `contextTokens/contextWindow` when an effective runtime + cap differs from the native context window; JSON rows include `contextTokens` + when a provider exposes that cap. - `models list --provider ` filters by provider id, such as `moonshot` or `openai-codex`. It does not accept display labels from interactive provider pickers, such as `Moonshot AI`. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index bf233135cf9..0299ab687f6 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -30,11 +30,9 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram) `google-gemini-cli`, or `codex-cli` when you want a local CLI backend. Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately. -- GPT-5.5 is currently available through subscription/OAuth routes: - `openai-codex/gpt-5.5` in PI or `openai/gpt-5.5` with the Codex app-server - harness. The direct API-key route for `openai/gpt-5.5` is supported once - OpenAI enables GPT-5.5 on the public API; until then use API-enabled models - such as `openai/gpt-5.4` for `OPENAI_API_KEY` setups. +- GPT-5.5 is available through `openai-codex/gpt-5.5` in PI, the native + Codex app-server harness, and the public OpenAI API when the bundled PI + catalog exposes `openai/gpt-5.5` for your install. ## Plugin-owned provider behavior @@ -73,10 +71,10 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `openai` - Auth: `OPENAI_API_KEY` - Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override) -- Example models: `openai/gpt-5.4`, `openai/gpt-5.4-mini` -- GPT-5.5 direct API support is future-ready here once OpenAI exposes GPT-5.5 on the API -- Verify direct API availability with `openclaw models list --provider openai` - before using `openai/gpt-5.5` without the Codex app-server runtime +- Example models: `openai/gpt-5.5`, `openai/gpt-5.4`, `openai/gpt-5.4-mini` +- GPT-5.5 direct API support depends on the bundled PI catalog version for + your install; verify with `openclaw models list --provider openai` before + using `openai/gpt-5.5` without the Codex app-server runtime. - CLI: `openclaw onboard --auth-choice openai-api-key` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) @@ -133,9 +131,9 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** `User-Agent`) are only attached on native Codex traffic to `chatgpt.com/backend-api`, not generic OpenAI-compatible proxies - Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`; OpenClaw maps that to `service_tier=priority` -- `openai-codex/gpt-5.5` keeps native `contextWindow = 1000000` and a default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens` +- `openai-codex/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens` - Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw. -- Current GPT-5.5 access uses this OAuth/subscription route until OpenAI enables GPT-5.5 on the public API. +- Use `openai-codex/gpt-5.5` when you want the Codex OAuth/subscription route; use `openai/gpt-5.5` when your API-key setup and local catalog expose the public API route. ```json5 { diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 8882c363c92..4795d1ef71e 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -415,7 +415,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi - `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`. - `models.providers.*.models`: explicit provider model catalog entries. - `models.providers.*.models.*.contextWindow`: native model context window metadata. -- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`. +- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`; `openclaw models list` shows both values when they differ. - `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior. - `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request. - `plugins.entries.amazon-bedrock.config.discovery`: Bedrock auto-discovery settings root. diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 406717258c0..732fe0f600c 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -333,7 +333,7 @@ describe("openai codex provider", () => { }); }); - it("uses Pi metadata for gpt-5.5 and local launch metadata for gpt-5.5-pro", () => { + it("keeps Pi cost metadata but applies Codex context metadata for gpt-5.5", () => { const provider = buildOpenAICodexProviderPlugin(); const model = provider.resolveDynamicModel?.({ @@ -343,7 +343,7 @@ describe("openai codex provider", () => { createCodexTemplate({ id: "gpt-5.5", cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: 400_000, + contextWindow: 272_000, }), ) as never, }); @@ -358,6 +358,7 @@ describe("openai codex provider", () => { api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api", contextWindow: 400_000, + contextTokens: 272_000, maxTokens: 128_000, cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, }); @@ -387,7 +388,7 @@ describe("openai codex provider", () => { baseUrl: "https://chatgpt.com/backend-api/codex", reasoning: true, input: ["text", "image"], - contextWindow: 1_000_000, + contextWindow: 400_000, contextTokens: 272_000, maxTokens: 128_000, }); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 032fee6d599..5ee57ab96a6 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -50,8 +50,8 @@ const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex"; const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; -const OPENAI_CODEX_GPT_55_NATIVE_CONTEXT_TOKENS = 1_000_000; -const OPENAI_CODEX_GPT_55_DEFAULT_CONTEXT_TOKENS = 272_000; +const OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS = 400_000; +const OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS = 272_000; const OPENAI_CODEX_GPT_55_PRO_NATIVE_CONTEXT_TOKENS = 1_000_000; const OPENAI_CODEX_GPT_55_PRO_DEFAULT_CONTEXT_TOKENS = 272_000; const OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS = 1_050_000; @@ -188,7 +188,11 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) | ProviderRuntimeModel | undefined; return ( - model ?? + withDefaultCodexContextMetadata({ + model, + contextWindow: OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS, + contextTokens: OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS, + }) ?? normalizeModelCompat({ id: trimmedModelId, name: trimmedModelId, @@ -198,8 +202,8 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_CODEX_GPT_55_NATIVE_CONTEXT_TOKENS, - contextTokens: OPENAI_CODEX_GPT_55_DEFAULT_CONTEXT_TOKENS, + contextWindow: OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS, + contextTokens: OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS, maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, } as ProviderRuntimeModel) ); @@ -280,6 +284,27 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) ); } +function withDefaultCodexContextMetadata(params: { + model: ProviderRuntimeModel | undefined; + contextWindow: number; + contextTokens: number; +}): ProviderRuntimeModel | undefined { + if (!params.model) { + return undefined; + } + const contextTokens = + typeof params.model.contextTokens === "number" + ? params.model.contextTokens + : typeof params.model.contextWindow === "number" && params.model.contextWindow > 0 + ? Math.min(params.contextTokens, params.model.contextWindow) + : params.contextTokens; + return { + ...params.model, + contextWindow: params.contextWindow, + contextTokens, + }; +} + async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) { try { const { refreshOpenAICodexToken } = await import("./openai-codex-provider.runtime.js"); diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index 4e3413856ae..18823f619c7 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -231,24 +231,37 @@ function buildDynamicModel( case "openai-codex": { const isLegacyGpt54Alias = lower === "gpt-5.4-codex"; if (lower === "gpt-5.5") { - return ( - (params.modelRegistry.find("openai-codex", modelId) as ResolvedModelLike | null) ?? - cloneTemplate( - undefined, - modelId, - { - provider: "openai-codex", - api: "openai-codex-responses", - baseUrl: OPENAI_CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: OPENROUTER_FALLBACK_COST, - contextWindow: 1_000_000, - contextTokens: 272_000, - maxTokens: 128_000, - }, - {}, - ) + const model = params.modelRegistry.find( + "openai-codex", + modelId, + ) as ResolvedModelLike | null; + if (model) { + const modelContextTokens = model.contextTokens; + const modelContextWindow = model.contextWindow; + const contextTokens = + typeof modelContextTokens === "number" + ? modelContextTokens + : Math.min( + 272_000, + typeof modelContextWindow === "number" ? modelContextWindow : 272_000, + ); + return { ...model, contextWindow: 400_000, contextTokens }; + } + return cloneTemplate( + undefined, + modelId, + { + provider: "openai-codex", + api: "openai-codex-responses", + baseUrl: OPENAI_CODEX_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: OPENROUTER_FALLBACK_COST, + contextWindow: 400_000, + contextTokens: 272_000, + maxTokens: 128_000, + }, + {}, ); } const template = diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/pi-embedded-runner/model.test-harness.ts index 0469d72650d..3105a10d4bb 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/pi-embedded-runner/model.test-harness.ts @@ -58,6 +58,7 @@ export function buildOpenAICodexForwardCompatExpectation( baseUrl: string; } { const isGpt54 = id === "gpt-5.4"; + const isGpt55 = id === "gpt-5.5"; const isGpt54Mini = id === "gpt-5.4-mini"; const isSpark = id === "gpt-5.3-codex-spark"; return { @@ -74,8 +75,8 @@ export function buildOpenAICodexForwardCompatExpectation( : isGpt54Mini ? { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 } : OPENAI_CODEX_TEMPLATE_MODEL.cost, - contextWindow: isGpt54 ? 1_050_000 : isSpark ? 128_000 : 272000, - ...(isGpt54 ? { contextTokens: 272_000 } : {}), + contextWindow: isGpt54 ? 1_050_000 : isGpt55 ? 400_000 : isSpark ? 128_000 : 272000, + ...(isGpt54 || isGpt55 ? { contextTokens: 272_000 } : {}), maxTokens: 128000, }; } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 0e18e02615e..f4377edd46e 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1156,7 +1156,7 @@ describe("resolveModel", () => { baseUrl: "https://chatgpt.com/backend-api", reasoning: true, input: ["text", "image"], - contextWindow: 1_000_000, + contextWindow: 400_000, contextTokens: 272_000, maxTokens: 128_000, }); diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index d1d2d64b001..2c8f246f20c 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -229,6 +229,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { const seenKeys = listRowsModule.appendDiscoveredRows({ rows: rows as never, models: loaded.models as never, + modelRegistry: loaded.registry as never, context: context as never, }); if (opts.supplementCatalog !== false) { @@ -576,6 +577,74 @@ describe("modelsListCommand forward-compat", () => { ]); }); + it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: new Set(["openai-codex/gpt-5.5"]), + registry: { + getAll: () => [ + { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }); + mocks.resolveModelWithRegistry.mockImplementation( + ({ provider, modelId }: { provider: string; modelId: string }) => + provider === "openai-codex" && modelId === "gpt-5.5" + ? { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text", "image"], + contextWindow: 400000, + contextTokens: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } + : undefined, + ); + + const runtime = createRuntime(); + await modelsListCommand( + { all: true, provider: "openai-codex", json: true }, + runtime as never, + ); + + expect( + lastPrintedRows<{ key: string; contextWindow: number; contextTokens?: number }>(), + ).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.5", + contextWindow: 400000, + contextTokens: 272000, + }), + ]); + }); + it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); const rows: unknown[] = []; diff --git a/src/commands/models/list.model-row.test.ts b/src/commands/models/list.model-row.test.ts index 26aa9f09fb0..df093030a2d 100644 --- a/src/commands/models/list.model-row.test.ts +++ b/src/commands/models/list.model-row.test.ts @@ -15,6 +15,23 @@ const OPENROUTER_MODEL = { } as const; describe("toModelRow", () => { + it("keeps native context metadata and effective runtime context tokens distinct", () => { + const row = toModelRow({ + model: { + ...OPENROUTER_MODEL, + contextWindow: 400_000, + contextTokens: 272_000, + } as never, + key: "openrouter/openai/gpt-5.4", + tags: [], + }); + + expect(row).toMatchObject({ + contextWindow: 400_000, + contextTokens: 272_000, + }); + }); + it("marks models available from auth profiles without loading model discovery", () => { const authStore: AuthProfileStore = { version: 1, diff --git a/src/commands/models/list.model-row.ts b/src/commands/models/list.model-row.ts index 52e67d2f6bc..f02086d8847 100644 --- a/src/commands/models/list.model-row.ts +++ b/src/commands/models/list.model-row.ts @@ -11,6 +11,7 @@ export type ListRowModel = { input: Array<"text" | "image">; baseUrl?: string; contextWindow?: number | null; + contextTokens?: number | null; }; export type ModelAuthAvailabilityResolver = (params: { @@ -97,6 +98,7 @@ export function toModelRow(params: { name: model.name || model.id, input, contextWindow: model.contextWindow ?? null, + ...(typeof model.contextTokens === "number" ? { contextTokens: model.contextTokens } : {}), local, available, tags: Array.from(mergedTags), diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index 8330aded170..39f5cd35f66 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -57,6 +57,7 @@ export async function appendAllModelRowSources( appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry.getAll(), + modelRegistry: params.modelRegistry, context: params.context, }); } @@ -66,6 +67,7 @@ export async function appendAllModelRowSources( const seenKeys = appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry?.getAll() ?? [], + modelRegistry: params.modelRegistry, context: params.context, }); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 0c63358790d..902763065e8 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -130,6 +130,7 @@ function toConfiguredProviderListModel(params: { baseUrl: params.model.baseUrl ?? params.providerConfig.baseUrl, input: resolveConfiguredModelInput({ model: params.model }), contextWindow: params.model.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + contextTokens: params.model.contextTokens, }; } @@ -143,6 +144,7 @@ function shouldListConfiguredProviderModel(params: { export function appendDiscoveredRows(params: { rows: ModelRow[]; models: Model[]; + modelRegistry?: ModelRegistry; context: RowBuilderContext; }): Set { const seenKeys = new Set(); @@ -156,7 +158,26 @@ export function appendDiscoveredRows(params: { for (const model of sorted) { const key = modelKey(model.provider, model.id); - appendVisibleRow({ rows: params.rows, model, key, context: params.context, seenKeys }); + const resolvedModel = params.modelRegistry + ? resolveModelWithRegistry({ + provider: model.provider, + modelId: model.id, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + agentDir: params.context.agentDir, + }) + : undefined; + const rowModel = + resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key + ? resolvedModel + : model; + appendVisibleRow({ + rows: params.rows, + model: rowModel, + key, + context: params.context, + seenKeys, + }); } return seenKeys; diff --git a/src/commands/models/list.table.test.ts b/src/commands/models/list.table.test.ts new file mode 100644 index 00000000000..5b70f4242e7 --- /dev/null +++ b/src/commands/models/list.table.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { printModelTable } from "./list.table.js"; +import type { ModelRow } from "./list.types.js"; + +describe("printModelTable", () => { + it("prints effective and native context values when a runtime cap differs", () => { + const runtime = { log: vi.fn(), error: vi.fn() }; + const rows: ModelRow[] = [ + { + key: "openai-codex/gpt-5.5", + name: "GPT-5.5", + input: "text+image", + contextWindow: 400_000, + contextTokens: 272_000, + local: false, + available: true, + tags: [], + missing: false, + }, + ]; + + printModelTable(rows, runtime as never); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("266k/391k")); + }); +}); diff --git a/src/commands/models/list.table.ts b/src/commands/models/list.table.ts index 70249e18136..cb9cc968330 100644 --- a/src/commands/models/list.table.ts +++ b/src/commands/models/list.table.ts @@ -7,10 +7,22 @@ import { formatTokenK } from "./shared.js"; const MODEL_PAD = 42; const INPUT_PAD = 10; -const CTX_PAD = 8; +const CTX_PAD = 11; const LOCAL_PAD = 5; const AUTH_PAD = 5; +function formatContextLabel(row: ModelRow): string { + if ( + typeof row.contextTokens === "number" && + Number.isFinite(row.contextTokens) && + row.contextTokens > 0 && + row.contextTokens !== row.contextWindow + ) { + return `${formatTokenK(row.contextTokens)}/${formatTokenK(row.contextWindow)}`; + } + return formatTokenK(row.contextWindow); +} + export function printModelTable( rows: ModelRow[], runtime: RuntimeEnv, @@ -45,7 +57,7 @@ export function printModelTable( for (const row of rows) { const keyLabel = pad(truncate(sanitizeTerminalText(row.key), MODEL_PAD), MODEL_PAD); const inputLabel = pad(sanitizeTerminalText(row.input) || "-", INPUT_PAD); - const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD); + const ctxLabel = pad(formatContextLabel(row), CTX_PAD); const localText = row.local === null ? "-" : row.local ? "yes" : "no"; const localLabel = pad(localText, LOCAL_PAD); const authText = row.available === null ? "-" : row.available ? "yes" : "no"; diff --git a/src/commands/models/list.types.ts b/src/commands/models/list.types.ts index ba5c45893ef..060286a888d 100644 --- a/src/commands/models/list.types.ts +++ b/src/commands/models/list.types.ts @@ -10,6 +10,7 @@ export type ModelRow = { name: string; input: string; contextWindow: number | null; + contextTokens?: number; local: boolean | null; available: boolean | null; tags: string[]; diff --git a/test/helpers/plugins/provider-runtime-contract.ts b/test/helpers/plugins/provider-runtime-contract.ts index 20e43e0c22e..32b9f1a9f3a 100644 --- a/test/helpers/plugins/provider-runtime-contract.ts +++ b/test/helpers/plugins/provider-runtime-contract.ts @@ -563,7 +563,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr }); }); - it("uses Pi registry metadata for codex gpt-5.5 models", () => { + it("keeps Pi cost metadata but applies Codex context metadata for gpt-5.5 models", () => { const provider = requireProviderContractProvider("openai-codex"); const model = provider.resolveDynamicModel?.({ provider: "openai-codex", @@ -578,7 +578,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr baseUrl: "https://chatgpt.com/backend-api", input: ["text", "image"], cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: 400_000, + contextWindow: 272_000, maxTokens: 128_000, }) : null, @@ -590,6 +590,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr provider: "openai-codex", api: "openai-codex-responses", contextWindow: 400_000, + contextTokens: 272_000, maxTokens: 128_000, }); }); From d37f165bee3d7aa796f8b58de5dff91cff06d6e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:36:11 +0100 Subject: [PATCH 68/93] feat(google-meet): add oauth doctor --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 157 +++++++++++++++++++++++- extensions/google-meet/index.test.ts | 115 +++++++++++++++++ extensions/google-meet/src/cli.ts | 176 ++++++++++++++++++++++++++- 4 files changed, 445 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db79b8104e5..0646ae2ed5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. - Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 930299d406e..96d342740fe 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -437,8 +437,51 @@ OAuth is optional for creating a Meet link because `googlemeet create` can fall back to browser automation. Configure OAuth when you want official API create, space resolution, or Meet Media API preflight checks. -Google Meet API access uses a personal OAuth client first. Configure -`oauth.clientId` and optionally `oauth.clientSecret`, then run: +Google Meet API access uses user OAuth: create a Google Cloud OAuth client, +request the required scopes, authorize a Google account, then store the +resulting refresh token in the Google Meet plugin config or provide the +`OPENCLAW_GOOGLE_MEET_*` environment variables. + +OAuth does not replace the Chrome join path. Chrome and Chrome-node transports +still join through a signed-in Chrome profile, BlackHole/SoX, and a connected +node when you use browser participation. OAuth is only for the official Google +Meet API path: create meeting spaces, resolve spaces, and run Meet Media API +preflight checks. + +### Create Google credentials + +In Google Cloud Console: + +1. Create or select a Google Cloud project. +2. Enable **Google Meet REST API** for that project. +3. Configure the OAuth consent screen. + - **Internal** is simplest for a Google Workspace organization. + - **External** works for personal/test setups; while the app is in Testing, + add each Google account that will authorize the app as a test user. +4. Add the scopes OpenClaw requests: + - `https://www.googleapis.com/auth/meetings.space.created` + - `https://www.googleapis.com/auth/meetings.space.readonly` + - `https://www.googleapis.com/auth/meetings.conference.media.readonly` +5. Create an OAuth client ID. + - Application type: **Web application**. + - Authorized redirect URI: + + ```text + http://localhost:8085/oauth2callback + ``` + +6. Copy the client ID and client secret. + +`meetings.space.created` is required by Google Meet `spaces.create`. +`meetings.space.readonly` lets OpenClaw resolve Meet URLs/codes to spaces. +`meetings.conference.media.readonly` is for Meet Media API preflight and media +work; Google may require Developer Preview enrollment for actual Media API use. +If you only need browser-based Chrome joins, skip OAuth entirely. + +### Mint the refresh token + +Configure `oauth.clientId` and optionally `oauth.clientSecret`, or pass them as +environment variables, then run: ```bash openclaw googlemeet auth login --json @@ -448,11 +491,116 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE, localhost callback on `http://localhost:8085/oauth2callback`, and a manual copy/paste flow with `--manual`. +Examples: + +```bash +OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \ +OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \ +openclaw googlemeet auth login --json +``` + +Use manual mode when the browser cannot reach the local callback: + +```bash +OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \ +OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \ +openclaw googlemeet auth login --json --manual +``` + +The JSON output includes: + +```json +{ + "oauth": { + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "refreshToken": "refresh-token", + "accessToken": "access-token", + "expiresAt": 1770000000000 + }, + "scope": "..." +} +``` + +Store the `oauth` object under the Google Meet plugin config: + +```json5 +{ + plugins: { + entries: { + "google-meet": { + enabled: true, + config: { + oauth: { + clientId: "your-client-id", + clientSecret: "your-client-secret", + refreshToken: "refresh-token", + }, + }, + }, + }, + }, +} +``` + +Prefer environment variables when you do not want the refresh token in config. +If both config and environment values are present, the plugin resolves config +first and then environment fallback. + The OAuth consent includes Meet space creation, Meet space read access, and Meet conference media read access. If you authenticated before meeting creation support existed, rerun `openclaw googlemeet auth login --json` so the refresh token has the `meetings.space.created` scope. +### Verify OAuth with doctor + +Run the OAuth doctor when you want a fast, non-secret health check: + +```bash +openclaw googlemeet doctor --oauth --json +``` + +This does not load the Chrome runtime or require a connected Chrome node. It +checks that OAuth config exists and that the refresh token can mint an access +token. The JSON report includes only status fields such as `ok`, `configured`, +`tokenSource`, `expiresAt`, and check messages; it does not print the access +token, refresh token, or client secret. + +Common results: + +| Check | Meaning | +| -------------------- | --------------------------------------------------------------------------------------- | +| `oauth-config` | `oauth.clientId` plus `oauth.refreshToken`, or a cached access token, is present. | +| `oauth-token` | The cached access token is still valid, or the refresh token minted a new access token. | +| `meet-spaces-get` | Optional `--meeting` check resolved an existing Meet space. | +| `meet-spaces-create` | Optional `--create-space` check created a new Meet space. | + +To prove Google Meet API enablement and `spaces.create` scope as well, run the +side-effecting create check: + +```bash +openclaw googlemeet doctor --oauth --create-space --json +openclaw googlemeet create --no-join --json +``` + +`--create-space` creates a throwaway Meet URL. Use it when you need to confirm +that the Google Cloud project has the Meet API enabled and that the authorized +account has the `meetings.space.created` scope. + +To prove read access for an existing meeting space: + +```bash +openclaw googlemeet doctor --oauth --meeting https://meet.google.com/abc-defg-hij --json +openclaw googlemeet resolve-space --meeting https://meet.google.com/abc-defg-hij +``` + +`doctor --oauth --meeting` and `resolve-space` prove read access to an existing +space that the authorized Google account can access. A `403` from these checks +usually means the Google Meet REST API is disabled, the consented refresh token +is missing the required scope, or the Google account cannot access that Meet +space. A refresh-token error means rerun `openclaw googlemeet auth login +--json` and store the new `oauth` block. + No OAuth credentials are needed for the browser fallback. In that mode, Google auth comes from the signed-in Chrome profile on the selected node, not from OpenClaw config. @@ -967,7 +1115,10 @@ Also verify: `googlemeet doctor [session-id]` prints the session, node, in-call state, manual action reason, realtime provider connection, `realtimeReady`, audio input/output activity, last audio timestamps, byte counters, and browser URL. -Use `googlemeet status [session-id]` when you need the raw JSON. +Use `googlemeet status [session-id]` when you need the raw JSON. Use +`googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh +without exposing tokens; add `--meeting` or `--create-space` when you need a +Google Meet API proof as well. If an agent timed out and you can see a Meet tab already open, inspect that tab without opening another one: diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index d5d97f5d90a..c5be3528d2a 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -966,6 +966,121 @@ describe("google-meet plugin", () => { } }); + it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => { + const program = new Command(); + const stdout = captureStdout(); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const ensureRuntime = vi.fn(async () => { + throw new Error("runtime should not be loaded for OAuth doctor"); + }); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({ + oauth: { + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "rt-secret", + }, + }), + ensureRuntime: ensureRuntime as unknown as () => Promise, + }); + + try { + await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); + const output = stdout.output(); + expect(output).not.toContain("new-access-token"); + expect(output).not.toContain("rt-secret"); + expect(output).not.toContain("client-secret"); + expect(JSON.parse(output)).toMatchObject({ + ok: true, + configured: true, + tokenSource: "refresh-token", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + ], + }); + expect(ensureRuntime).not.toHaveBeenCalled(); + const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + } finally { + stdout.restore(); + } + }); + + it("CLI doctor can prove Google Meet API create access", async () => { + const program = new Command(); + const stdout = captureStdout(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://oauth2.googleapis.com/token") { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://meet.googleapis.com/v2/spaces") { + return new Response( + JSON.stringify({ + name: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }), + ); + + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({ + oauth: { + clientId: "client-id", + refreshToken: "refresh-token", + }, + }), + ensureRuntime: async () => ({}) as GoogleMeetRuntime, + }); + + try { + await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { + from: "user", + }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: true, + tokenSource: "refresh-token", + createdSpace: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + { id: "meet-spaces-create", ok: true }, + ], + }); + } finally { + stdout.restore(); + } + }); + it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index c2ce43744da..c2c6b7c7ba7 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -57,6 +57,18 @@ type SetupOptions = { json?: boolean; }; +type DoctorOptions = { + json?: boolean; + oauth?: boolean; + meeting?: string; + createSpace?: boolean; + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; +}; + type JsonOptions = { json?: boolean; }; @@ -173,6 +185,151 @@ function writeDoctorStatus(status: ReturnType): voi } } +type OAuthDoctorCheck = { + id: string; + ok: boolean; + message: string; +}; + +type OAuthDoctorReport = { + ok: boolean; + configured: boolean; + tokenSource?: "cached-access-token" | "refresh-token"; + expiresAt?: number; + scope?: string; + meetingUri?: string; + createdSpace?: string; + checks: OAuthDoctorCheck[]; +}; + +function sanitizeOAuthErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]"); +} + +async function buildOAuthDoctorReport( + config: GoogleMeetConfig, + options: DoctorOptions, +): Promise { + const clientId = options.clientId?.trim() || config.oauth.clientId; + const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret; + const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken; + const accessToken = options.accessToken?.trim() || config.oauth.accessToken; + const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt; + const checks: OAuthDoctorCheck[] = []; + + const hasRefreshConfig = Boolean(clientId && refreshToken); + const hasAccessConfig = Boolean(accessToken); + if (!hasRefreshConfig && !hasAccessConfig) { + checks.push({ + id: "oauth-config", + ok: false, + message: + "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.", + }); + return { ok: false, configured: false, checks }; + } + + checks.push({ + id: "oauth-config", + ok: true, + message: hasRefreshConfig + ? "Google Meet OAuth refresh credentials are configured" + : "Google Meet cached access token is configured", + }); + + let token: Awaited>; + try { + token = await resolveGoogleMeetAccessToken({ + clientId, + clientSecret, + refreshToken, + accessToken, + expiresAt, + }); + checks.push({ + id: "oauth-token", + ok: true, + message: token.refreshed + ? "Refresh token minted an access token" + : "Cached access token is still valid", + }); + } catch (error) { + checks.push({ + id: "oauth-token", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + return { ok: false, configured: true, checks }; + } + + const report: OAuthDoctorReport = { + ok: true, + configured: true, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + expiresAt: token.expiresAt, + checks, + }; + + const meeting = options.meeting?.trim(); + if (meeting) { + try { + const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting }); + checks.push({ + id: "meet-spaces-get", + ok: true, + message: `Resolved ${space.name}`, + }); + report.meetingUri = space.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-get", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + if (options.createSpace) { + try { + const created = await createGoogleMeetSpace({ accessToken: token.accessToken }); + checks.push({ + id: "meet-spaces-create", + ok: true, + message: `Created ${created.space.name}`, + }); + report.createdSpace = created.space.name; + report.meetingUri = created.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-create", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + report.ok = checks.every((check) => check.ok); + return report; +} + +function writeOAuthDoctorReport(report: OAuthDoctorReport): void { + writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention"); + writeStdoutLine("configured: %s", report.configured ? "yes" : "no"); + if (report.tokenSource) { + writeStdoutLine("token source: %s", report.tokenSource); + } + if (report.meetingUri) { + writeStdoutLine("meeting uri: %s", report.meetingUri); + } + for (const check of report.checks) { + writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); + } +} + function writeRecoverCurrentTabResult( result: Awaited>, ): void { @@ -754,8 +911,25 @@ export function registerGoogleMeetCli(params: { .command("doctor") .description("Show human-readable Meet session/browser/realtime health") .argument("[session-id]", "Meet session ID") + .option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false) + .option("--meeting ", "Also verify spaces.get for a Meet URL, code, or spaces/{id}") + .option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false) + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--json", "Print JSON output", false) - .action(async (sessionId: string | undefined, options: JsonOptions) => { + .action(async (sessionId: string | undefined, options: DoctorOptions) => { + if (options.oauth) { + const report = await buildOAuthDoctorReport(params.config, options); + if (options.json) { + writeStdoutJson(report); + return; + } + writeOAuthDoctorReport(report); + return; + } const rt = await params.ensureRuntime(); const status = rt.status(sessionId); if (options.json) { From 4df0e106239da6fa5f9e46b5857f457bded4ec95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:41:01 -0700 Subject: [PATCH 69/93] fix(feishu): back off failed streaming card starts --- CHANGELOG.md | 1 + extensions/feishu/openclaw.plugin.json | 99 ++++++++++++++++++- .../feishu/src/reply-dispatcher.test.ts | 26 ++++- extensions/feishu/src/reply-dispatcher.ts | 41 +++++++- 4 files changed, 157 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0646ae2ed5b..eb82e904761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: back off streaming-card creation after HTTP 400 startup failures, so unsupported card setups fall back without delaying every message. Fixes #56981. Thanks @JinnanDuan. - Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI. - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 312c531014d..a93616507ef 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -12,7 +12,102 @@ "skills": ["./skills"], "configSchema": { "type": "object", - "additionalProperties": false, - "properties": {} + "additionalProperties": true, + "$defs": { + "secretRef": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": ["env", "file", "exec"] + }, + "provider": { "type": "string" }, + "id": { "type": "string" } + }, + "required": ["source", "provider", "id"] + }, + "secretInput": { + "anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }] + }, + "account": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "appId": { "type": "string" }, + "appSecret": { "$ref": "#/$defs/secretInput" }, + "encryptKey": { "$ref": "#/$defs/secretInput" }, + "verificationToken": { "$ref": "#/$defs/secretInput" }, + "domain": { + "anyOf": [ + { + "type": "string", + "enum": ["feishu", "lark"] + }, + { + "type": "string", + "format": "uri" + } + ] + }, + "connectionMode": { + "type": "string", + "enum": ["websocket", "webhook"] + }, + "renderMode": { + "type": "string", + "enum": ["auto", "raw", "card"] + }, + "streaming": { "type": "boolean" }, + "replyInThread": { + "type": "string", + "enum": ["disabled", "enabled"] + }, + "typingIndicator": { "type": "boolean" } + } + } + }, + "properties": { + "enabled": { "type": "boolean" }, + "defaultAccount": { "type": "string" }, + "appId": { "type": "string" }, + "appSecret": { "$ref": "#/$defs/secretInput" }, + "encryptKey": { "$ref": "#/$defs/secretInput" }, + "verificationToken": { "$ref": "#/$defs/secretInput" }, + "domain": { + "anyOf": [ + { + "type": "string", + "enum": ["feishu", "lark"] + }, + { + "type": "string", + "format": "uri" + } + ] + }, + "connectionMode": { + "type": "string", + "enum": ["websocket", "webhook"] + }, + "renderMode": { + "type": "string", + "enum": ["auto", "raw", "card"] + }, + "streaming": { "type": "boolean" }, + "replyInThread": { + "type": "string", + "enum": ["disabled", "enabled"] + }, + "typingIndicator": { "type": "boolean" }, + "accounts": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/account" + } + } + } } } diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 190bc007a4a..16411a9ed51 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -86,13 +86,17 @@ vi.mock("./streaming-card.js", () => { }; }); -import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; +import { + clearFeishuStreamingStartBackoffForTests, + createFeishuReplyDispatcher, +} from "./reply-dispatcher.js"; describe("createFeishuReplyDispatcher streaming behavior", () => { type ReplyDispatcherArgs = Parameters[0]; beforeEach(() => { vi.clearAllMocks(); + clearFeishuStreamingStartBackoffForTests(); streamingInstances.length = 0; sendMediaFeishuMock.mockResolvedValue(undefined); sendStructuredCardFeishuMock.mockResolvedValue(undefined); @@ -731,9 +735,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); }); - it("recovers streaming after start() throws (HTTP 400)", async () => { + it("backs off streaming retries after start() throws (HTTP 400)", async () => { const errorMock = vi.fn(); let shouldFailStart = true; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); // Intercept streaming instance creation to make first start() reject const origPush = streamingInstances.push.bind(streamingInstances); @@ -758,22 +763,33 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; // First deliver with markdown triggers startStreaming - which will fail - await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" }); + await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); // Wait for the async error to propagate await vi.waitFor(() => { expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed")); }); + expect(streamingInstances).toHaveLength(1); + expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1); - // Second deliver should create a NEW streaming session (not stuck) + // Immediate next markdown reply should skip a new streaming start and + // fall back directly to a normal card instead of paying the 400 latency. await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" }); - // Two instances created: first failed, second succeeded and closed + expect(streamingInstances).toHaveLength(1); + expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2); + + // After the short backoff expires, retry streaming so fixed permissions + // or transient Feishu failures recover without a process restart. + nowSpy.mockReturnValue(62_000); + await options.deliver({ text: "```ts\nconst z = 3\n```" }, { kind: "final" }); + expect(streamingInstances).toHaveLength(2); expect(streamingInstances[1].start).toHaveBeenCalled(); expect(streamingInstances[1].close).toHaveBeenCalled(); } finally { streamingInstances.push = origPush; + nowSpy.mockRestore(); } }); }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index b4fa4f909d9..460c0413137 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -32,6 +32,30 @@ function shouldUseCard(text: string): boolean { * Messages older than this are likely replays after context compaction (#30418). */ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000; const MS_EPOCH_MIN = 1_000_000_000_000; +const STREAMING_START_FAILURE_BACKOFF_MS = 60_000; +const streamingStartBackoffUntilByAccount = new Map(); + +function isStreamingStartBackedOff(accountId: string, now = Date.now()): boolean { + const backoffUntil = streamingStartBackoffUntilByAccount.get(accountId); + if (backoffUntil === undefined) { + return false; + } + if (backoffUntil <= now) { + streamingStartBackoffUntilByAccount.delete(accountId); + return false; + } + return true; +} + +function rememberStreamingStartFailure(accountId: string, now = Date.now()): number { + const backoffUntil = now + STREAMING_START_FAILURE_BACKOFF_MS; + streamingStartBackoffUntilByAccount.set(accountId, backoffUntil); + return backoffUntil; +} + +export function clearFeishuStreamingStartBackoffForTests() { + streamingStartBackoffUntilByAccount.clear(); +} function normalizeEpochMs(timestamp: number | undefined): number | undefined { if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) { @@ -266,7 +290,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP }; const startStreaming = () => { - if (!streamingEnabled || streamingStartPromise || streaming) { + if ( + !streamingEnabled || + streamingStartPromise || + streaming || + isStreamingStartBackedOff(account.accountId) + ) { return; } streamingStartPromise = (async () => { @@ -291,10 +320,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP header: cardHeader, note: cardNote, }); + streamingStartBackoffUntilByAccount.delete(account.accountId); } catch (error) { - params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); + rememberStreamingStartFailure(account.accountId); + params.runtime.error?.( + `feishu[${account.accountId}]: streaming start failed; using non-streaming card fallback for ${ + STREAMING_START_FAILURE_BACKOFF_MS / 1000 + }s: ${String(error)}`, + ); streaming = null; - streamingStartPromise = null; // allow retry on next deliver + streamingStartPromise = null; } })(); }; From 0ac81d41b613210a501ada35cf2c05549ed732bd Mon Sep 17 00:00:00 2001 From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:15:06 -0600 Subject: [PATCH 70/93] fix(gateway): durably hand off restart continuations --- CHANGELOG.md | 1 + src/gateway/server-restart-sentinel.test.ts | 339 ++++++++++++------ src/gateway/server-restart-sentinel.ts | 280 ++++++++++----- src/gateway/server-runtime-services.test.ts | 16 + src/gateway/server-runtime-services.ts | 23 ++ src/gateway/server.impl.ts | 1 + src/infra/session-delivery-queue-recovery.ts | 259 +++++++++++++ src/infra/session-delivery-queue-storage.ts | 238 ++++++++++++ .../session-delivery-queue.recovery.test.ts | 68 ++++ .../session-delivery-queue.storage.test.ts | 59 +++ src/infra/session-delivery-queue.ts | 29 ++ 11 files changed, 1107 insertions(+), 206 deletions(-) create mode 100644 src/infra/session-delivery-queue-recovery.ts create mode 100644 src/infra/session-delivery-queue-storage.ts create mode 100644 src/infra/session-delivery-queue.recovery.test.ts create mode 100644 src/infra/session-delivery-queue.storage.test.ts create mode 100644 src/infra/session-delivery-queue.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb82e904761..0fbde953a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -360,6 +360,7 @@ Docs: https://docs.openclaw.ai - Approvals/startup: let native approval handlers report ready after gateway authentication while replaying pending approvals in the background, so slow or failing replay delivery no longer blocks handler startup or amplifies reconnect storms. Thanks @steipete. - WhatsApp/security: keep contact/vCard/location structured-object free text out of the inline message body and render it through fenced untrusted metadata JSON, limiting hidden prompt-injection payloads in names, phone fields, and location labels/comments. Thanks @steipete. - Group-chat/security: keep channel-sourced group names and participant labels out of inline group system prompts and render them through fenced untrusted metadata JSON. Thanks @steipete. +- Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Agents/replay: preserve Kimi-style `functions.:` tool-call IDs during strict replay sanitization so custom OpenAI-compatible Kimi routes keep multi-turn tool use intact. (#70693) Thanks @geri4. - Discord/replies: preserve final reply permission context through outbound delivery so Discord replies keep the same channel/member routing rules at send time. Thanks @steipete. - Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair. (#70852) Thanks @simonemacario. diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 46e1d18cef9..8fc9ea98f76 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -7,79 +7,139 @@ type RecordInboundSessionAndDispatchReplyParams = Parameters< typeof import("../plugin-sdk/inbound-reply-dispatch.js").recordInboundSessionAndDispatchReply >[0]; -const mocks = vi.hoisted(() => ({ - resolveSessionAgentId: vi.fn(() => "agent-from-key"), - consumeRestartSentinel: vi.fn(async () => ({ - payload: { - sessionKey: "agent:main:main", - deliveryContext: { - channel: "whatsapp", - to: "+15550002", - accountId: "acct-2", - }, +const mocks = vi.hoisted(() => { + const state = { + queuedSessionDelivery: null as Record | null, + }; + + return { + resolveSessionAgentId: vi.fn(() => "agent-from-key"), + get queuedSessionDelivery() { + return state.queuedSessionDelivery; }, - })), - formatRestartSentinelMessage: vi.fn(() => "restart message"), - summarizeRestartSentinel: vi.fn(() => "restart summary"), - resolveMainSessionKeyFromConfig: vi.fn(() => "agent:main:main"), - parseSessionThreadInfo: vi.fn( - (): { baseSessionKey: string | null | undefined; threadId: string | undefined } => ({ - baseSessionKey: null, - threadId: undefined, - }), - ), - loadSessionEntry: vi.fn( - (): LoadedSessionEntry => ({ - cfg: {}, - entry: { - sessionId: "agent:main:main", - updatedAt: 0, + set queuedSessionDelivery(value: Record | null) { + state.queuedSessionDelivery = value; + }, + readRestartSentinel: vi.fn(async () => ({ + payload: { + sessionKey: "agent:main:main", + deliveryContext: { + channel: "whatsapp", + to: "+15550002", + accountId: "acct-2", + }, }, - store: {}, - storePath: "/tmp/sessions.json", - canonicalKey: "agent:main:main", - legacyKey: undefined, + })), + removeRestartSentinelFile: vi.fn(async () => undefined), + resolveRestartSentinelPath: vi.fn(() => "/tmp/restart-sentinel.json"), + formatRestartSentinelMessage: vi.fn(() => "restart message"), + summarizeRestartSentinel: vi.fn(() => "restart summary"), + resolveMainSessionKeyFromConfig: vi.fn(() => "agent:main:main"), + parseSessionThreadInfo: vi.fn( + (): { baseSessionKey: string | null | undefined; threadId: string | undefined } => ({ + baseSessionKey: null, + threadId: undefined, + }), + ), + loadSessionEntry: vi.fn( + (): LoadedSessionEntry => ({ + cfg: {}, + entry: { + sessionId: "agent:main:main", + updatedAt: 0, + }, + store: {}, + storePath: "/tmp/sessions.json", + canonicalKey: "agent:main:main", + legacyKey: undefined, + }), + ), + deliveryContextFromSession: vi.fn( + (): + | { channel?: string; to?: string; accountId?: string; threadId?: string | number } + | undefined => undefined, + ), + mergeDeliveryContext: vi.fn((a?: Record, b?: Record) => ({ + ...b, + ...a, + })), + getChannelPlugin: vi.fn((): ChannelPlugin | undefined => undefined), + normalizeChannelId: vi.fn<(channel?: string | null) => string | null>(), + resolveOutboundTarget: vi.fn(((_params?: { to?: string }) => ({ + ok: true as const, + to: "+15550002", + })) as (params?: { to?: string }) => { ok: true; to: string } | { ok: false; error: Error }), + deliverOutboundPayloads: vi.fn(async () => [{ channel: "whatsapp", messageId: "msg-1" }]), + enqueueDelivery: vi.fn(async () => "queue-1"), + ackDelivery: vi.fn(async () => {}), + failDelivery: vi.fn(async () => {}), + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + enqueueSessionDelivery: vi.fn(async (payload: Record) => { + state.queuedSessionDelivery = payload; + return "session-delivery-1"; }), - ), - deliveryContextFromSession: vi.fn( - (): - | { channel?: string; to?: string; accountId?: string; threadId?: string | number } - | undefined => undefined, - ), - mergeDeliveryContext: vi.fn((a?: Record, b?: Record) => ({ - ...b, - ...a, - })), - getChannelPlugin: vi.fn((): ChannelPlugin | undefined => undefined), - normalizeChannelId: vi.fn<(channel?: string | null) => string | null>(), - resolveOutboundTarget: vi.fn(((_params?: { to?: string }) => ({ - ok: true as const, - to: "+15550002", - })) as (params?: { to?: string }) => { ok: true; to: string } | { ok: false; error: Error }), - deliverOutboundPayloads: vi.fn(async () => [{ channel: "whatsapp", messageId: "msg-1" }]), - enqueueDelivery: vi.fn(async () => "queue-1"), - ackDelivery: vi.fn(async () => {}), - failDelivery: vi.fn(async () => {}), - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), - injectTimestamp: vi.fn((message: string) => `stamped:${message}`), - timestampOptsFromConfig: vi.fn(() => ({})), - recordInboundSessionAndDispatchReply: vi.fn( - async (_params: RecordInboundSessionAndDispatchReplyParams) => {}, - ), - logWarn: vi.fn(), -})); + drainPendingSessionDeliveries: vi.fn( + async (params: { + logLabel: string; + log: { warn: (message: string) => void }; + selectEntry: (entry: Record, now: number) => { match: boolean }; + deliver: (entry: Record) => Promise; + }) => { + if (!state.queuedSessionDelivery) { + return; + } + const entry = { + id: "session-delivery-1", + enqueuedAt: 1, + retryCount: 0, + ...state.queuedSessionDelivery, + }; + if (!params.selectEntry(entry, Date.now()).match) { + return; + } + try { + await params.deliver(entry); + } catch (err) { + params.log.warn(`${params.logLabel}: retry failed for entry ${entry.id}: ${String(err)}`); + } + }, + ), + recoverPendingSessionDeliveries: vi.fn(async () => ({ + recovered: 0, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 0, + })), + injectTimestamp: vi.fn((message: string) => `stamped:${message}`), + timestampOptsFromConfig: vi.fn(() => ({})), + recordInboundSessionAndDispatchReply: vi.fn( + async (_params: RecordInboundSessionAndDispatchReplyParams) => {}, + ), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + }; +}); vi.mock("../agents/agent-scope.js", () => ({ resolveSessionAgentId: mocks.resolveSessionAgentId, })); vi.mock("../infra/restart-sentinel.js", () => ({ - consumeRestartSentinel: mocks.consumeRestartSentinel, + readRestartSentinel: mocks.readRestartSentinel, + removeRestartSentinelFile: mocks.removeRestartSentinelFile, + resolveRestartSentinelPath: mocks.resolveRestartSentinelPath, formatRestartSentinelMessage: mocks.formatRestartSentinelMessage, summarizeRestartSentinel: mocks.summarizeRestartSentinel, })); +vi.mock("../infra/session-delivery-queue.js", () => ({ + enqueueSessionDelivery: mocks.enqueueSessionDelivery, + drainPendingSessionDeliveries: mocks.drainPendingSessionDeliveries, + recoverPendingSessionDeliveries: mocks.recoverPendingSessionDeliveries, +})); + vi.mock("../config/sessions.js", () => ({ resolveMainSessionKeyFromConfig: mocks.resolveMainSessionKeyFromConfig, })); @@ -150,7 +210,9 @@ vi.mock("../infra/heartbeat-wake.js", async () => { vi.mock("../logging/subsystem.js", () => ({ createSubsystemLogger: vi.fn(() => ({ + info: mocks.logInfo, warn: mocks.logWarn, + error: mocks.logError, })), })); @@ -168,7 +230,8 @@ describe("scheduleRestartSentinelWake", () => { beforeEach(() => { vi.useRealTimers(); - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.queuedSessionDelivery = null; + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -207,11 +270,17 @@ describe("scheduleRestartSentinelWake", () => { mocks.failDelivery.mockClear(); mocks.enqueueSystemEvent.mockClear(); mocks.requestHeartbeatNow.mockClear(); + mocks.enqueueSessionDelivery.mockClear(); + mocks.drainPendingSessionDeliveries.mockClear(); + mocks.recoverPendingSessionDeliveries.mockClear(); + mocks.removeRestartSentinelFile.mockClear(); mocks.injectTimestamp.mockClear(); mocks.timestampOptsFromConfig.mockClear(); mocks.recordInboundSessionAndDispatchReply.mockReset(); mocks.recordInboundSessionAndDispatchReply.mockResolvedValue(undefined); + mocks.logInfo.mockClear(); mocks.logWarn.mockClear(); + mocks.logError.mockClear(); }); it("enqueues the sentinel note and wakes the session even when outbound delivery succeeds", async () => { @@ -316,7 +385,7 @@ describe("scheduleRestartSentinelWake", () => { it("still dispatches continuation after restart notice retries are exhausted", async () => { vi.useFakeTimers(); mocks.deliverOutboundPayloads.mockRejectedValue(new Error("transport still not ready")); - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -330,7 +399,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as unknown as Awaited>); + } as unknown as Awaited>); const wakePromise = scheduleRestartSentinelWake({ deps: {} as never }); await Promise.resolve(); @@ -354,7 +423,7 @@ describe("scheduleRestartSentinelWake", () => { it("prefers top-level sentinel threadId for wake routing context", async () => { // Legacy or malformed sentinel JSON can still carry a nested threadId. - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -365,7 +434,7 @@ describe("scheduleRestartSentinelWake", () => { } as never, threadId: "fresh-thread", }, - } as unknown as Awaited>); + } as unknown as Awaited>); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -381,7 +450,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("dispatches agentTurn continuation after the restart notice in the same routed thread", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -396,7 +465,7 @@ describe("scheduleRestartSentinelWake", () => { message: "Reply with exactly: Yay! I did it!", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => { await params.deliver({ text: "done", @@ -436,7 +505,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("preserves the session chat type for agentTurn continuations", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:group", deliveryContext: { @@ -450,7 +519,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.loadSessionEntry.mockReturnValue({ cfg: {}, entry: { @@ -502,7 +571,7 @@ describe("scheduleRestartSentinelWake", () => { }), }, }); - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -517,7 +586,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => { await params.deliver({ text: "done", @@ -548,7 +617,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("strips synthetic reply transport ids when no real reply target exists", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -562,7 +631,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => { await params.deliver({ text: "done", @@ -580,7 +649,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("preserves non-synthetic reply transport ids from continuation payloads", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -594,7 +663,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => { await params.deliver({ text: "done", @@ -617,7 +686,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("dispatches agentTurn continuation from session delivery context when sentinel routing is empty", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", ts: 123, @@ -626,7 +695,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as unknown as Awaited>); + } as unknown as Awaited>); mocks.deliveryContextFromSession.mockReturnValue({ channel: "telegram", to: "telegram:200482621", @@ -653,7 +722,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("requests another wake after enqueueing a systemEvent continuation", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -668,7 +737,7 @@ describe("scheduleRestartSentinelWake", () => { text: "continue after restart", }, }, - } as Awaited>); + } as Awaited>); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -696,7 +765,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("enqueues systemEvent continuation without stale partial delivery context", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -711,7 +780,7 @@ describe("scheduleRestartSentinelWake", () => { text: "continue after restart", }, }, - } as Awaited>); + } as Awaited>); mocks.resolveOutboundTarget.mockReturnValueOnce({ ok: false, error: new Error("missing route"), @@ -719,13 +788,23 @@ describe("scheduleRestartSentinelWake", () => { await scheduleRestartSentinelWake({ deps: {} as never }); - expect(mocks.enqueueSystemEvent).toHaveBeenNthCalledWith(2, "continue after restart", { - sessionKey: "agent:main:main", - }); + expect(mocks.enqueueSystemEvent).toHaveBeenNthCalledWith( + 2, + "continue after restart", + expect.objectContaining({ + sessionKey: "agent:main:main", + deliveryContext: expect.objectContaining({ + channel: "whatsapp", + to: "+15550002", + accountId: "acct-2", + threadId: "thread-42", + }), + }), + ); }); it("logs and continues when continuation delivery fails", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -739,7 +818,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockRejectedValueOnce(new Error("dispatch failed")); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -751,16 +830,12 @@ describe("scheduleRestartSentinelWake", () => { }), ); expect(mocks.logWarn).toHaveBeenCalledWith( - expect.stringContaining("continuation delivery failed"), - expect.objectContaining({ - sessionKey: "agent:main:main", - continuationKind: "agentTurn", - }), + expect.stringContaining("retry failed for entry session-delivery-1: Error: dispatch failed"), ); }); it("logs and continues when continuation dispatch reports a delivery error", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -774,7 +849,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce( async (params: { onDispatchError: (err: unknown, info: { kind: string }) => void }) => { params.onDispatchError(new Error("route failed"), { kind: "final" }); @@ -784,16 +859,12 @@ describe("scheduleRestartSentinelWake", () => { await scheduleRestartSentinelWake({ deps: {} as never }); expect(mocks.logWarn).toHaveBeenCalledWith( - expect.stringContaining("continuation delivery failed"), - expect.objectContaining({ - sessionKey: "agent:main:main", - continuationKind: "agentTurn", - }), + expect.stringContaining("retry failed for entry session-delivery-1: Error: route failed"), ); }); - it("warns and skips agentTurn continuation when restart routing cannot resolve a destination", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + it("falls back to a session wake when restart routing cannot resolve a destination", async () => { + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:main", deliveryContext: { @@ -807,7 +878,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>); + } as Awaited>); mocks.resolveOutboundTarget.mockReturnValueOnce({ ok: false, error: new Error("missing route"), @@ -816,17 +887,51 @@ describe("scheduleRestartSentinelWake", () => { await scheduleRestartSentinelWake({ deps: {} as never }); expect(mocks.recordInboundSessionAndDispatchReply).not.toHaveBeenCalled(); - expect(mocks.logWarn).toHaveBeenCalledWith( - expect.stringContaining("restart continuation route unavailable"), + expect(mocks.enqueueSystemEvent).toHaveBeenNthCalledWith( + 2, + "continue", expect.objectContaining({ sessionKey: "agent:main:main", - continuationKind: "agentTurn", + }), + ); + expect(mocks.requestHeartbeatNow).toHaveBeenCalledTimes(2); + expect(mocks.logWarn).not.toHaveBeenCalled(); + }); + + it("keeps the sentinel file when durable continuation handoff fails", async () => { + mocks.readRestartSentinel.mockResolvedValue({ + payload: { + sessionKey: "agent:main:main", + deliveryContext: { + channel: "whatsapp", + to: "+15550002", + accountId: "acct-2", + }, + ts: 123, + continuation: { + kind: "agentTurn", + message: "continue", + }, + }, + } as Awaited>); + mocks.enqueueSessionDelivery.mockRejectedValueOnce(new Error("queue write failed")); + + await scheduleRestartSentinelWake({ deps: {} as never }); + + expect(mocks.removeRestartSentinelFile).not.toHaveBeenCalled(); + expect(mocks.drainPendingSessionDeliveries).not.toHaveBeenCalled(); + expect(mocks.logWarn).toHaveBeenCalledWith( + "startup task failed", + expect.objectContaining({ + source: "restart-sentinel", + sessionKey: "agent:main:main", + reason: "queue write failed", }), ); }); it("consumes continuation once and does not replay it on later startup cycles", async () => { - mocks.consumeRestartSentinel + mocks.readRestartSentinel .mockResolvedValueOnce({ payload: { sessionKey: "agent:main:main", @@ -841,9 +946,9 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as Awaited>) + } as Awaited>) .mockResolvedValueOnce( - null as unknown as Awaited>, + null as unknown as Awaited>, ); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -853,11 +958,11 @@ describe("scheduleRestartSentinelWake", () => { }); it("does not wake the main session when the sentinel has no sessionKey", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { message: "restart message", }, - } as unknown as Awaited>); + } as unknown as Awaited>); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -869,7 +974,7 @@ describe("scheduleRestartSentinelWake", () => { }); it("warns when continuation cannot run because the restart sentinel has no sessionKey", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { message: "restart message", continuation: { @@ -877,7 +982,7 @@ describe("scheduleRestartSentinelWake", () => { message: "continue", }, }, - } as unknown as Awaited>); + } as unknown as Awaited>); await scheduleRestartSentinelWake({ deps: {} as never }); @@ -894,11 +999,11 @@ describe("scheduleRestartSentinelWake", () => { ); }); it("skips outbound restart notice when no canonical delivery context survives restart", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:matrix:channel:!lowercased:example.org", }, - } as Awaited>); + } as Awaited>); mocks.parseSessionThreadInfo.mockReturnValue({ baseSessionKey: "agent:main:matrix:channel:!lowercased:example.org", threadId: undefined, @@ -919,11 +1024,11 @@ describe("scheduleRestartSentinelWake", () => { }); it("resolves session routing before queueing the heartbeat wake", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:qa-channel:channel:qa-room", }, - } as Awaited>); + } as Awaited>); mocks.parseSessionThreadInfo.mockReturnValue({ baseSessionKey: "agent:main:qa-channel:channel:qa-room", threadId: undefined, @@ -961,11 +1066,11 @@ describe("scheduleRestartSentinelWake", () => { }); it("merges base session routing into partial thread metadata", async () => { - mocks.consumeRestartSentinel.mockResolvedValue({ + mocks.readRestartSentinel.mockResolvedValue({ payload: { sessionKey: "agent:main:matrix:channel:!lowercased:example.org:thread:$thread-event", }, - } as Awaited>); + } as Awaited>); mocks.parseSessionThreadInfo.mockReturnValue({ baseSessionKey: "agent:main:matrix:channel:!lowercased:example.org", threadId: "$thread-event", diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 55cbd06a076..19811f3c934 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -14,11 +14,22 @@ import { ackDelivery, enqueueDelivery, failDelivery } from "../infra/outbound/de import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { - consumeRestartSentinel, formatRestartSentinelMessage, + readRestartSentinel, + removeRestartSentinelFile, type RestartSentinelContinuation, + resolveRestartSentinelPath, summarizeRestartSentinel, } from "../infra/restart-sentinel.js"; +import { + drainPendingSessionDeliveries, + enqueueSessionDelivery, + recoverPendingSessionDeliveries, + type QueuedSessionDelivery, + type QueuedSessionDeliveryPayload, + type SessionDeliveryRecoveryLogger, + type SessionDeliveryRoute, +} from "../infra/session-delivery-queue.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { recordInboundSessionAndDispatchReply } from "../plugin-sdk/inbound-reply-dispatch.js"; @@ -144,15 +155,6 @@ function buildRestartContinuationMessageId(params: { return `restart-sentinel:${params.sessionKey}:${params.kind}:${params.ts}`; } -type RestartContinuationRoute = { - channel: string; - to: string; - accountId?: string; - replyToId?: string; - threadId?: string; - chatType: ChatType; -}; - function resolveRestartContinuationRoute(params: { channel?: string; to?: string; @@ -160,7 +162,7 @@ function resolveRestartContinuationRoute(params: { replyToId?: string; threadId?: string; chatType: ChatType; -}): RestartContinuationRoute | undefined { +}): SessionDeliveryRoute | undefined { if (!params.channel || !params.to) { return undefined; } @@ -187,64 +189,84 @@ function resolveRestartContinuationOutboundPayload(params: { return params.replyToId ? { ...payload, replyToId: params.replyToId } : payload; } -async function dispatchRestartSentinelContinuation(params: { +function resolveQueuedSessionDeliveryContext(entry: QueuedSessionDelivery): + | { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + } + | undefined { + if (entry.kind === "agentTurn" && entry.route) { + return { + channel: entry.route.channel, + to: entry.route.to, + ...(entry.route.accountId ? { accountId: entry.route.accountId } : {}), + ...(entry.route.threadId ? { threadId: entry.route.threadId } : {}), + }; + } + return entry.deliveryContext; +} + +async function deliverQueuedSessionDelivery(params: { deps: CliDeps; - cfg: ReturnType["cfg"]; - storePath: string; - sessionKey: string; - continuation: RestartSentinelContinuation; - ts: number; - route?: RestartContinuationRoute; + entry: QueuedSessionDelivery; }) { - if (params.continuation.kind === "systemEvent") { - enqueueSystemEvent(params.continuation.text, { - sessionKey: params.sessionKey, - ...(params.route + const { cfg, storePath, canonicalKey } = loadSessionEntry(params.entry.sessionKey); + + if (params.entry.kind === "systemEvent") { + enqueueSystemEvent(params.entry.text, { + sessionKey: canonicalKey, + ...(resolveQueuedSessionDeliveryContext(params.entry) ? { deliveryContext: { - channel: params.route.channel, - to: params.route.to, - ...(params.route.accountId ? { accountId: params.route.accountId } : {}), - ...(params.route.threadId ? { threadId: params.route.threadId } : {}), + ...resolveQueuedSessionDeliveryContext(params.entry), }, } : {}), }); - requestHeartbeatNow({ reason: "wake", sessionKey: params.sessionKey }); + requestHeartbeatNow({ reason: "wake", sessionKey: canonicalKey }); return; } - if (!params.route) { - throw new Error("restart continuation route unavailable"); + if (!params.entry.route) { + enqueueSystemEvent(params.entry.message, { + sessionKey: canonicalKey, + ...(resolveQueuedSessionDeliveryContext(params.entry) + ? { + deliveryContext: { + ...resolveQueuedSessionDeliveryContext(params.entry), + }, + } + : {}), + }); + requestHeartbeatNow({ reason: "wake", sessionKey: canonicalKey }); + return; } - const route = params.route; - const messageId = buildRestartContinuationMessageId({ - sessionKey: params.sessionKey, - kind: params.continuation.kind, - ts: params.ts, - }); - const userMessage = params.continuation.message.trim(); + const route = params.entry.route; + const messageId = params.entry.messageId; + const userMessage = params.entry.message.trim(); const agentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, + sessionKey: canonicalKey, + config: cfg, }); let dispatchError: unknown; await recordInboundSessionAndDispatchReply({ - cfg: params.cfg, + cfg, channel: route.channel, accountId: route.accountId, agentId, - routeSessionKey: params.sessionKey, - storePath: params.storePath, + routeSessionKey: canonicalKey, + storePath, ctxPayload: finalizeInboundContext( { Body: userMessage, - BodyForAgent: injectTimestamp(userMessage, timestampOptsFromConfig(params.cfg)), + BodyForAgent: injectTimestamp(userMessage, timestampOptsFromConfig(cfg)), BodyForCommands: "", RawBody: userMessage, CommandBody: "", - SessionKey: params.sessionKey, + SessionKey: canonicalKey, AccountId: route.accountId, MessageSid: messageId, Timestamp: Date.now(), @@ -272,7 +294,7 @@ async function dispatchRestartSentinelContinuation(params: { replyToId: route.replyToId, }); const results = await deliverOutboundPayloads({ - cfg: params.cfg, + cfg, channel: route.channel, to: route.to, accountId: route.accountId, @@ -280,8 +302,8 @@ async function dispatchRestartSentinelContinuation(params: { threadId: route.threadId, payloads: [outboundPayload], session: buildOutboundSessionContext({ - cfg: params.cfg, - sessionKey: params.sessionKey, + cfg, + sessionKey: canonicalKey, }), deps: params.deps, bestEffort: false, @@ -292,7 +314,7 @@ async function dispatchRestartSentinelContinuation(params: { }, onRecordError: (err) => { log.warn(`restart continuation failed to record inbound session metadata: ${String(err)}`, { - sessionKey: params.sessionKey, + sessionKey: canonicalKey, }); }, onDispatchError: (err) => { @@ -304,13 +326,78 @@ async function dispatchRestartSentinelContinuation(params: { } } +function buildQueuedRestartContinuation(params: { + sessionKey: string; + continuation: RestartSentinelContinuation; + route?: SessionDeliveryRoute; + ts: number; + deliveryContext?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; +}): QueuedSessionDeliveryPayload { + const idempotencyKey = buildRestartContinuationMessageId({ + sessionKey: params.sessionKey, + kind: params.continuation.kind, + ts: params.ts, + }); + if (params.continuation.kind === "systemEvent") { + return { + kind: "systemEvent", + sessionKey: params.sessionKey, + text: params.continuation.text, + ...(params.deliveryContext ? { deliveryContext: params.deliveryContext } : {}), + idempotencyKey, + }; + } + return { + kind: "agentTurn", + sessionKey: params.sessionKey, + message: params.continuation.message, + messageId: idempotencyKey, + ...(params.route ? { route: params.route } : {}), + ...(params.deliveryContext ? { deliveryContext: params.deliveryContext } : {}), + idempotencyKey, + }; +} + +async function drainRestartContinuationQueue(params: { + deps: CliDeps; + entryId: string; + log: SessionDeliveryRecoveryLogger; +}) { + await drainPendingSessionDeliveries({ + drainKey: `restart-continuation:${params.entryId}`, + logLabel: "restart continuation", + log: params.log, + deliver: (entry) => deliverQueuedSessionDelivery({ deps: params.deps, entry }), + selectEntry: (entry) => ({ + match: entry.id === params.entryId, + bypassBackoff: true, + }), + }); +} + +export async function recoverPendingRestartContinuationDeliveries(params: { + deps: CliDeps; + log?: SessionDeliveryRecoveryLogger; +}) { + await recoverPendingSessionDeliveries({ + deliver: (entry) => deliverQueuedSessionDelivery({ deps: params.deps, entry }), + log: params.log ?? log, + }); +} + async function loadRestartSentinelStartupTask(params: { deps: CliDeps; }): Promise { - const sentinel = await consumeRestartSentinel(); + const sentinel = await readRestartSentinel(); if (!sentinel) { return null; } + const sentinelPath = resolveRestartSentinelPath(); const payload = sentinel.payload; const sessionKey = payload.sessionKey?.trim(); const message = formatRestartSentinelMessage(payload); @@ -332,12 +419,13 @@ async function loadRestartSentinelStartupTask(params: { continuationKind: payload.continuation.kind, }); } + await removeRestartSentinelFile(sentinelPath); return { status: "ran" as const }; } const { baseSessionKey, threadId: sessionThreadId } = parseSessionThreadInfo(sessionKey); - const { cfg, entry, canonicalKey, storePath } = loadSessionEntry(sessionKey); + const { cfg, entry, canonicalKey } = loadSessionEntry(sessionKey); const sentinelContext = payload.deliveryContext; let sessionDeliveryContext = deliveryContextFromSession(entry); @@ -357,8 +445,6 @@ async function loadRestartSentinelStartupTask(params: { const origin = mergeDeliveryContext(sentinelContext, sessionDeliveryContext); - enqueueRestartSentinelWake(message, sessionKey, wakeDeliveryContext); - const channelRaw = origin?.channel; const channel = channelRaw ? normalizeChannelId(channelRaw) : null; const to = origin?.to; @@ -369,6 +455,7 @@ async function loadRestartSentinelStartupTask(params: { let resolvedTo: string | undefined; let replyToId: string | undefined; let resolvedThreadId = threadId; + let continuationQueueId: string | undefined; if (channel && to) { const resolved = resolveOutboundTarget({ @@ -393,54 +480,69 @@ async function loadRestartSentinelStartupTask(params: { ? String(replyTransport.threadId) : undefined : threadId; - const outboundSession = buildOutboundSessionContext({ - cfg, - sessionKey: canonicalKey, - }); - - await deliverRestartSentinelNotice({ - deps: params.deps, - cfg, - sessionKey: canonicalKey, - summary, - message, - channel, - to: resolvedTo, - accountId: origin?.accountId, - replyToId, - threadId: resolvedThreadId, - session: outboundSession, - }); + // Keep the resolved route for the queued continuation and restart notice. } } - if (!payload.continuation) { - return { status: "ran" as const }; + if (payload.continuation) { + continuationQueueId = await enqueueSessionDelivery( + buildQueuedRestartContinuation({ + sessionKey: canonicalKey, + continuation: payload.continuation, + ts: payload.ts, + route: resolveRestartContinuationRoute({ + channel: channel ?? undefined, + to: resolvedTo, + accountId: origin?.accountId, + replyToId, + threadId: resolvedThreadId, + chatType, + }), + deliveryContext: + resolvedTo && channel + ? { + channel, + to: resolvedTo, + ...(origin?.accountId ? { accountId: origin.accountId } : {}), + ...(resolvedThreadId ? { threadId: resolvedThreadId } : {}), + } + : wakeDeliveryContext, + }), + ); } - try { - await dispatchRestartSentinelContinuation({ + await removeRestartSentinelFile(sentinelPath); + enqueueRestartSentinelWake(message, sessionKey, wakeDeliveryContext); + + if (resolvedTo && channel) { + const outboundSession = buildOutboundSessionContext({ + cfg, + sessionKey: canonicalKey, + }); + + await deliverRestartSentinelNotice({ deps: params.deps, cfg, - storePath, sessionKey: canonicalKey, - continuation: payload.continuation, - ts: payload.ts, - route: resolveRestartContinuationRoute({ - channel: channel ?? undefined, - to: resolvedTo, - accountId: origin?.accountId, - replyToId, - threadId: resolvedThreadId, - chatType, - }), - }); - } catch (err) { - log.warn(`${summary}: continuation delivery failed: ${String(err)}`, { - sessionKey: canonicalKey, - continuationKind: payload.continuation.kind, + summary, + message, + channel, + to: resolvedTo, + accountId: origin?.accountId, + replyToId, + threadId: resolvedThreadId, + session: outboundSession, }); } + + if (continuationQueueId) { + await drainRestartContinuationQueue({ + deps: params.deps, + entryId: continuationQueueId, + log, + }); + } + return { status: "ran" as const }; }; diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 729ca45caaf..6105891b670 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -11,6 +11,7 @@ const hoisted = vi.hoisted(() => { startChannelHealthMonitor: vi.fn(() => ({ stop: vi.fn() })), startGatewayModelPricingRefresh: vi.fn(() => vi.fn()), recoverPendingDeliveries: vi.fn(async () => undefined), + recoverPendingRestartContinuationDeliveries: vi.fn(async () => undefined), deliverOutboundPayloads: vi.fn(), }; }); @@ -27,6 +28,10 @@ vi.mock("../infra/outbound/delivery-queue.js", () => ({ recoverPendingDeliveries: hoisted.recoverPendingDeliveries, })); +vi.mock("./server-restart-sentinel.js", () => ({ + recoverPendingRestartContinuationDeliveries: hoisted.recoverPendingRestartContinuationDeliveries, +})); + vi.mock("./channel-health-monitor.js", () => ({ startChannelHealthMonitor: hoisted.startChannelHealthMonitor, })); @@ -47,6 +52,7 @@ describe("server-runtime-services", () => { hoisted.startChannelHealthMonitor.mockClear(); hoisted.startGatewayModelPricingRefresh.mockClear(); hoisted.recoverPendingDeliveries.mockClear(); + hoisted.recoverPendingRestartContinuationDeliveries.mockClear(); hoisted.deliverOutboundPayloads.mockClear(); }); @@ -71,12 +77,14 @@ describe("server-runtime-services", () => { }); it("activates heartbeat, cron, and delivery recovery after sidecars are ready", async () => { + vi.useFakeTimers(); const cron = { start: vi.fn(async () => undefined) }; const log = createLog(); const services = activateGatewayScheduledServices({ minimalTestGateway: false, cfgAtStart: {} as never, + deps: {} as never, cron, logCron: { error: vi.fn() }, log, @@ -85,6 +93,7 @@ describe("server-runtime-services", () => { expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1); expect(cron.start).toHaveBeenCalledTimes(1); expect(services.heartbeatRunner).toBe(hoisted.heartbeatRunner); + await vi.advanceTimersByTimeAsync(1_250); await vi.dynamicImportSettled(); expect(hoisted.recoverPendingDeliveries).toHaveBeenCalledWith( expect.objectContaining({ @@ -92,6 +101,11 @@ describe("server-runtime-services", () => { cfg: {}, }), ); + expect(hoisted.recoverPendingRestartContinuationDeliveries).toHaveBeenCalledWith( + expect.objectContaining({ + deps: {}, + }), + ); }); it("keeps scheduled services disabled for minimal test gateways", () => { @@ -100,6 +114,7 @@ describe("server-runtime-services", () => { const services = activateGatewayScheduledServices({ minimalTestGateway: true, cfgAtStart: {} as never, + deps: {} as never, cron, logCron: { error: vi.fn() }, log: createLog(), @@ -108,6 +123,7 @@ describe("server-runtime-services", () => { expect(hoisted.startHeartbeatRunner).not.toHaveBeenCalled(); expect(cron.start).not.toHaveBeenCalled(); expect(hoisted.recoverPendingDeliveries).not.toHaveBeenCalled(); + expect(hoisted.recoverPendingRestartContinuationDeliveries).not.toHaveBeenCalled(); services.heartbeatRunner.stop(); expect(hoisted.heartbeatRunner.stop).not.toHaveBeenCalled(); diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index dd5cf2460df..0f62c66f326 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -68,6 +68,24 @@ function recoverPendingOutboundDeliveries(params: { })().catch((err) => params.log.error(`Delivery recovery failed: ${String(err)}`)); } +function recoverPendingSessionDeliveries(params: { + deps: import("../cli/deps.types.js").CliDeps; + log: GatewayRuntimeServiceLogger; +}): void { + const timer = setTimeout(() => { + void (async () => { + const { recoverPendingRestartContinuationDeliveries } = + await import("./server-restart-sentinel.js"); + const logRecovery = params.log.child("session-delivery-recovery"); + await recoverPendingRestartContinuationDeliveries({ + deps: params.deps, + log: logRecovery, + }); + })().catch((err) => params.log.error(`Session delivery recovery failed: ${String(err)}`)); + }, 1_250); + timer.unref?.(); +} + export function startGatewayRuntimeServices(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; @@ -101,6 +119,7 @@ export function startGatewayRuntimeServices(params: { export function activateGatewayScheduledServices(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; + deps: import("../cli/deps.types.js").CliDeps; cron: { start: () => Promise }; logCron: { error: (message: string) => void }; log: GatewayRuntimeServiceLogger; @@ -117,5 +136,9 @@ export function activateGatewayScheduledServices(params: { cfg: params.cfgAtStart, log: params.log, }); + recoverPendingSessionDeliveries({ + deps: params.deps, + log: params.log, + }); return { heartbeatRunner }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index d35ae5be2ba..81578434e7a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -855,6 +855,7 @@ export async function startGatewayServer( const activated = activateGatewayScheduledServices({ minimalTestGateway, cfgAtStart, + deps, cron: runtimeState.cronState.cron, logCron, log, diff --git a/src/infra/session-delivery-queue-recovery.ts b/src/infra/session-delivery-queue-recovery.ts new file mode 100644 index 00000000000..803b6c90de2 --- /dev/null +++ b/src/infra/session-delivery-queue-recovery.ts @@ -0,0 +1,259 @@ +import { formatErrorMessage } from "./errors.js"; +import { + ackSessionDelivery, + failSessionDelivery, + loadPendingSessionDelivery, + loadPendingSessionDeliveries, + moveSessionDeliveryToFailed, + type QueuedSessionDelivery, +} from "./session-delivery-queue-storage.js"; + +export type SessionDeliveryRecoverySummary = { + recovered: number; + failed: number; + skippedMaxRetries: number; + deferredBackoff: number; +}; + +export type DeliverSessionDeliveryFn = (entry: QueuedSessionDelivery) => Promise; + +export interface SessionDeliveryRecoveryLogger { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} + +export interface PendingSessionDeliveryDrainDecision { + match: boolean; + bypassBackoff?: boolean; +} + +export const MAX_SESSION_DELIVERY_RETRIES = 5; + +const BACKOFF_MS: readonly number[] = [5_000, 25_000, 120_000, 600_000]; +const drainInProgress = new Map(); +const entriesInProgress = new Set(); + +function getErrnoCode(err: unknown): string | null { + return err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : null; +} + +function createEmptyRecoverySummary(): SessionDeliveryRecoverySummary { + return { + recovered: 0, + failed: 0, + skippedMaxRetries: 0, + deferredBackoff: 0, + }; +} + +function claimRecoveryEntry(entryId: string): boolean { + if (entriesInProgress.has(entryId)) { + return false; + } + entriesInProgress.add(entryId); + return true; +} + +function releaseRecoveryEntry(entryId: string): void { + entriesInProgress.delete(entryId); +} + +export function computeSessionDeliveryBackoffMs(retryCount: number): number { + if (retryCount <= 0) { + return 0; + } + return BACKOFF_MS[Math.min(retryCount - 1, BACKOFF_MS.length - 1)] ?? BACKOFF_MS.at(-1) ?? 0; +} + +export function isSessionDeliveryEligibleForRetry( + entry: QueuedSessionDelivery, + now: number, +): { eligible: true } | { eligible: false; remainingBackoffMs: number } { + const backoff = computeSessionDeliveryBackoffMs(entry.retryCount + 1); + if (backoff <= 0) { + return { eligible: true }; + } + const firstReplayAfterCrash = entry.retryCount === 0 && entry.lastAttemptAt === undefined; + if (firstReplayAfterCrash) { + return { eligible: true }; + } + const baseAttemptAt = + typeof entry.lastAttemptAt === "number" && entry.lastAttemptAt > 0 + ? entry.lastAttemptAt + : entry.enqueuedAt; + const nextEligibleAt = baseAttemptAt + backoff; + if (now >= nextEligibleAt) { + return { eligible: true }; + } + return { eligible: false, remainingBackoffMs: nextEligibleAt - now }; +} + +async function drainQueuedEntry(opts: { + entry: QueuedSessionDelivery; + deliver: DeliverSessionDeliveryFn; + stateDir?: string; + onRecovered?: (entry: QueuedSessionDelivery) => void; + onFailed?: (entry: QueuedSessionDelivery, errMsg: string) => void; +}): Promise<"recovered" | "failed" | "moved-to-failed" | "already-gone"> { + const { entry } = opts; + try { + await opts.deliver(entry); + await ackSessionDelivery(entry.id, opts.stateDir); + opts.onRecovered?.(entry); + return "recovered"; + } catch (err) { + const errMsg = formatErrorMessage(err); + opts.onFailed?.(entry, errMsg); + try { + await failSessionDelivery(entry.id, errMsg, opts.stateDir); + return "failed"; + } catch (failErr) { + if (getErrnoCode(failErr) === "ENOENT") { + return "already-gone"; + } + return "failed"; + } + } +} + +export async function drainPendingSessionDeliveries(opts: { + drainKey: string; + logLabel: string; + log: SessionDeliveryRecoveryLogger; + stateDir?: string; + deliver: DeliverSessionDeliveryFn; + selectEntry: (entry: QueuedSessionDelivery, now: number) => PendingSessionDeliveryDrainDecision; +}): Promise { + if (drainInProgress.get(opts.drainKey)) { + opts.log.info(`${opts.logLabel}: already in progress for ${opts.drainKey}, skipping`); + return; + } + + drainInProgress.set(opts.drainKey, true); + try { + const matchingEntries = (await loadPendingSessionDeliveries(opts.stateDir)) + .filter((entry) => opts.selectEntry(entry, Date.now()).match) + .toSorted((a, b) => a.enqueuedAt - b.enqueuedAt); + + for (const entry of matchingEntries) { + if (!claimRecoveryEntry(entry.id)) { + opts.log.info(`${opts.logLabel}: entry ${entry.id} is already being recovered`); + continue; + } + + try { + const currentEntry = await loadPendingSessionDelivery(entry.id, opts.stateDir); + if (!currentEntry) { + continue; + } + const currentDecision = opts.selectEntry(currentEntry, Date.now()); + if (!currentDecision.match) { + continue; + } + if (currentEntry.retryCount >= MAX_SESSION_DELIVERY_RETRIES) { + try { + await moveSessionDeliveryToFailed(currentEntry.id, opts.stateDir); + } catch (err) { + if (getErrnoCode(err) !== "ENOENT") { + throw err; + } + } + opts.log.warn( + `${opts.logLabel}: entry ${currentEntry.id} exceeded max retries and was moved to failed/`, + ); + continue; + } + + if (!currentDecision.bypassBackoff) { + const retryEligibility = isSessionDeliveryEligibleForRetry(currentEntry, Date.now()); + if (!retryEligibility.eligible) { + opts.log.info( + `${opts.logLabel}: entry ${currentEntry.id} not ready for retry yet — backoff ${retryEligibility.remainingBackoffMs}ms remaining`, + ); + continue; + } + } + + await drainQueuedEntry({ + entry: currentEntry, + deliver: opts.deliver, + stateDir: opts.stateDir, + onFailed: (failedEntry, errMsg) => { + opts.log.warn(`${opts.logLabel}: retry failed for entry ${failedEntry.id}: ${errMsg}`); + }, + }); + } finally { + releaseRecoveryEntry(entry.id); + } + } + } finally { + drainInProgress.delete(opts.drainKey); + } +} + +export async function recoverPendingSessionDeliveries(opts: { + deliver: DeliverSessionDeliveryFn; + log: SessionDeliveryRecoveryLogger; + stateDir?: string; + maxRecoveryMs?: number; +}): Promise { + const pending = await loadPendingSessionDeliveries(opts.stateDir); + if (pending.length === 0) { + return createEmptyRecoverySummary(); + } + + pending.sort((a, b) => a.enqueuedAt - b.enqueuedAt); + const summary = createEmptyRecoverySummary(); + const deadline = Date.now() + (opts.maxRecoveryMs ?? 60_000); + + for (const entry of pending) { + if (Date.now() >= deadline) { + opts.log.warn("Session delivery recovery time budget exceeded — remaining entries deferred"); + break; + } + if (!claimRecoveryEntry(entry.id)) { + continue; + } + + try { + const currentEntry = await loadPendingSessionDelivery(entry.id, opts.stateDir); + if (!currentEntry) { + continue; + } + if (currentEntry.retryCount >= MAX_SESSION_DELIVERY_RETRIES) { + summary.skippedMaxRetries += 1; + await moveSessionDeliveryToFailed(currentEntry.id, opts.stateDir).catch(() => {}); + continue; + } + + const retryEligibility = isSessionDeliveryEligibleForRetry(currentEntry, Date.now()); + if (!retryEligibility.eligible) { + summary.deferredBackoff += 1; + continue; + } + + const result = await drainQueuedEntry({ + entry: currentEntry, + deliver: opts.deliver, + stateDir: opts.stateDir, + onRecovered: () => { + summary.recovered += 1; + }, + onFailed: (_failedEntry, errMsg) => { + summary.failed += 1; + opts.log.warn(`Session delivery retry failed: ${errMsg}`); + }, + }); + if (result === "recovered") { + opts.log.info(`Recovered session delivery ${currentEntry.id}`); + } + } finally { + releaseRecoveryEntry(entry.id); + } + } + + return summary; +} diff --git a/src/infra/session-delivery-queue-storage.ts b/src/infra/session-delivery-queue-storage.ts new file mode 100644 index 00000000000..59839625c23 --- /dev/null +++ b/src/infra/session-delivery-queue-storage.ts @@ -0,0 +1,238 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { ChatType } from "../channels/chat-type.js"; +import { resolveStateDir } from "../config/paths.js"; +import { generateSecureUuid } from "./secure-random.js"; + +const QUEUE_DIRNAME = "session-delivery-queue"; +const FAILED_DIRNAME = "failed"; + +export type SessionDeliveryContext = { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; +}; + +export type SessionDeliveryRoute = { + channel: string; + to: string; + accountId?: string; + replyToId?: string; + threadId?: string; + chatType: ChatType; +}; + +export type QueuedSessionDeliveryPayload = + | { + kind: "systemEvent"; + sessionKey: string; + text: string; + deliveryContext?: SessionDeliveryContext; + idempotencyKey?: string; + } + | { + kind: "agentTurn"; + sessionKey: string; + message: string; + messageId: string; + route?: SessionDeliveryRoute; + deliveryContext?: SessionDeliveryContext; + idempotencyKey?: string; + }; + +export type QueuedSessionDelivery = QueuedSessionDeliveryPayload & { + id: string; + enqueuedAt: number; + retryCount: number; + lastAttemptAt?: number; + lastError?: string; +}; + +function getErrnoCode(err: unknown): string | null { + return err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : null; +} + +function buildEntryId(idempotencyKey?: string): string { + if (!idempotencyKey) { + return generateSecureUuid(); + } + return createHash("sha256").update(idempotencyKey).digest("hex"); +} + +async function unlinkBestEffort(filePath: string): Promise { + try { + await fs.promises.unlink(filePath); + } catch { + // Best-effort cleanup. + } +} + +async function writeQueueEntry(filePath: string, entry: QueuedSessionDelivery): Promise { + const tmp = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile(tmp, JSON.stringify(entry, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + await fs.promises.rename(tmp, filePath); +} + +async function readQueueEntry(filePath: string): Promise { + return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as QueuedSessionDelivery; +} + +export function resolveSessionDeliveryQueueDir(stateDir?: string): string { + const base = stateDir ?? resolveStateDir(); + return path.join(base, QUEUE_DIRNAME); +} + +function resolveFailedDir(stateDir?: string): string { + return path.join(resolveSessionDeliveryQueueDir(stateDir), FAILED_DIRNAME); +} + +function resolveQueueEntryPaths( + id: string, + stateDir?: string, +): { + jsonPath: string; + deliveredPath: string; +} { + const queueDir = resolveSessionDeliveryQueueDir(stateDir); + return { + jsonPath: path.join(queueDir, `${id}.json`), + deliveredPath: path.join(queueDir, `${id}.delivered`), + }; +} + +export async function ensureSessionDeliveryQueueDir(stateDir?: string): Promise { + const queueDir = resolveSessionDeliveryQueueDir(stateDir); + await fs.promises.mkdir(queueDir, { recursive: true, mode: 0o700 }); + await fs.promises.mkdir(resolveFailedDir(stateDir), { recursive: true, mode: 0o700 }); + return queueDir; +} + +export async function enqueueSessionDelivery( + params: QueuedSessionDeliveryPayload, + stateDir?: string, +): Promise { + const queueDir = await ensureSessionDeliveryQueueDir(stateDir); + const id = buildEntryId(params.idempotencyKey); + const filePath = path.join(queueDir, `${id}.json`); + + if (params.idempotencyKey) { + try { + const stat = await fs.promises.stat(filePath); + if (stat.isFile()) { + return id; + } + } catch (err) { + if (getErrnoCode(err) !== "ENOENT") { + throw err; + } + } + } + + await writeQueueEntry(filePath, { + ...params, + id, + enqueuedAt: Date.now(), + retryCount: 0, + }); + return id; +} + +export async function ackSessionDelivery(id: string, stateDir?: string): Promise { + const { jsonPath, deliveredPath } = resolveQueueEntryPaths(id, stateDir); + try { + await fs.promises.rename(jsonPath, deliveredPath); + } catch (err) { + const code = getErrnoCode(err); + if (code === "ENOENT") { + await unlinkBestEffort(deliveredPath); + return; + } + throw err; + } + await unlinkBestEffort(deliveredPath); +} + +export async function failSessionDelivery( + id: string, + error: string, + stateDir?: string, +): Promise { + const filePath = path.join(resolveSessionDeliveryQueueDir(stateDir), `${id}.json`); + const entry = await readQueueEntry(filePath); + entry.retryCount += 1; + entry.lastAttemptAt = Date.now(); + entry.lastError = error; + await writeQueueEntry(filePath, entry); +} + +export async function loadPendingSessionDelivery( + id: string, + stateDir?: string, +): Promise { + const { jsonPath } = resolveQueueEntryPaths(id, stateDir); + try { + const stat = await fs.promises.stat(jsonPath); + if (!stat.isFile()) { + return null; + } + return await readQueueEntry(jsonPath); + } catch (err) { + if (getErrnoCode(err) === "ENOENT") { + return null; + } + throw err; + } +} + +export async function loadPendingSessionDeliveries( + stateDir?: string, +): Promise { + const queueDir = resolveSessionDeliveryQueueDir(stateDir); + let files: string[]; + try { + files = await fs.promises.readdir(queueDir); + } catch (err) { + if (getErrnoCode(err) === "ENOENT") { + return []; + } + throw err; + } + + for (const file of files) { + if (file.endsWith(".delivered")) { + await unlinkBestEffort(path.join(queueDir, file)); + } + } + + const entries: QueuedSessionDelivery[] = []; + for (const file of files) { + if (!file.endsWith(".json")) { + continue; + } + const filePath = path.join(queueDir, file); + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + continue; + } + entries.push(await readQueueEntry(filePath)); + } catch { + // Skip malformed or inaccessible entries. + } + } + return entries; +} + +export async function moveSessionDeliveryToFailed(id: string, stateDir?: string): Promise { + const queueDir = resolveSessionDeliveryQueueDir(stateDir); + const failedDir = resolveFailedDir(stateDir); + await fs.promises.mkdir(failedDir, { recursive: true, mode: 0o700 }); + await fs.promises.rename(path.join(queueDir, `${id}.json`), path.join(failedDir, `${id}.json`)); +} diff --git a/src/infra/session-delivery-queue.recovery.test.ts b/src/infra/session-delivery-queue.recovery.test.ts new file mode 100644 index 00000000000..8aef5194f55 --- /dev/null +++ b/src/infra/session-delivery-queue.recovery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { + enqueueSessionDelivery, + loadPendingSessionDeliveries, + recoverPendingSessionDeliveries, +} from "./session-delivery-queue.js"; + +describe("session-delivery queue recovery", () => { + it("replays and acks pending entries on recovery", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "restart complete", + }, + tempDir, + ); + + const deliver = vi.fn(async () => undefined); + const summary = await recoverPendingSessionDeliveries({ + deliver, + stateDir: tempDir, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(summary.recovered).toBe(1); + expect(await loadPendingSessionDeliveries(tempDir)).toEqual([]); + }); + }); + + it("keeps failed entries queued with retry metadata for later recovery", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + await enqueueSessionDelivery( + { + kind: "agentTurn", + sessionKey: "agent:main:main", + message: "continue", + messageId: "restart-sentinel:agent:main:main:agentTurn:123", + }, + tempDir, + ); + + const summary = await recoverPendingSessionDeliveries({ + deliver: vi.fn(async () => { + throw new Error("transient failure"); + }), + stateDir: tempDir, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + const [failedEntry] = await loadPendingSessionDeliveries(tempDir); + expect(summary.failed).toBe(1); + expect(failedEntry?.retryCount).toBe(1); + expect(failedEntry?.lastError).toBe("transient failure"); + }); + }); +}); diff --git a/src/infra/session-delivery-queue.storage.test.ts b/src/infra/session-delivery-queue.storage.test.ts new file mode 100644 index 00000000000..59ecbf3a6bd --- /dev/null +++ b/src/infra/session-delivery-queue.storage.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { + ackSessionDelivery, + enqueueSessionDelivery, + failSessionDelivery, + loadPendingSessionDeliveries, +} from "./session-delivery-queue.js"; + +describe("session-delivery queue storage", () => { + it("dedupes entries when an idempotency key is reused", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + const firstId = await enqueueSessionDelivery( + { + kind: "agentTurn", + sessionKey: "agent:main:main", + message: "continue after restart", + messageId: "restart-sentinel:agent:main:main:agentTurn:123", + idempotencyKey: "restart-sentinel:agent:main:main:agentTurn:123", + }, + tempDir, + ); + const secondId = await enqueueSessionDelivery( + { + kind: "agentTurn", + sessionKey: "agent:main:main", + message: "continue after restart", + messageId: "restart-sentinel:agent:main:main:agentTurn:123", + idempotencyKey: "restart-sentinel:agent:main:main:agentTurn:123", + }, + tempDir, + ); + + expect(secondId).toBe(firstId); + expect(await loadPendingSessionDeliveries(tempDir)).toHaveLength(1); + }); + }); + + it("persists retry metadata and removes acked entries", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + const id = await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "restart complete", + }, + tempDir, + ); + + await failSessionDelivery(id, "dispatch failed", tempDir); + const [failedEntry] = await loadPendingSessionDeliveries(tempDir); + expect(failedEntry?.retryCount).toBe(1); + expect(failedEntry?.lastError).toBe("dispatch failed"); + + await ackSessionDelivery(id, tempDir); + expect(await loadPendingSessionDeliveries(tempDir)).toEqual([]); + }); + }); +}); diff --git a/src/infra/session-delivery-queue.ts b/src/infra/session-delivery-queue.ts new file mode 100644 index 00000000000..784857798ef --- /dev/null +++ b/src/infra/session-delivery-queue.ts @@ -0,0 +1,29 @@ +export { + ackSessionDelivery, + enqueueSessionDelivery, + ensureSessionDeliveryQueueDir, + failSessionDelivery, + loadPendingSessionDelivery, + loadPendingSessionDeliveries, + moveSessionDeliveryToFailed, + resolveSessionDeliveryQueueDir, +} from "./session-delivery-queue-storage.js"; +export type { + QueuedSessionDelivery, + QueuedSessionDeliveryPayload, + SessionDeliveryContext, + SessionDeliveryRoute, +} from "./session-delivery-queue-storage.js"; +export { + computeSessionDeliveryBackoffMs, + drainPendingSessionDeliveries, + isSessionDeliveryEligibleForRetry, + MAX_SESSION_DELIVERY_RETRIES, + recoverPendingSessionDeliveries, +} from "./session-delivery-queue-recovery.js"; +export type { + DeliverSessionDeliveryFn, + PendingSessionDeliveryDrainDecision, + SessionDeliveryRecoveryLogger, + SessionDeliveryRecoverySummary, +} from "./session-delivery-queue-recovery.js"; From 22615506333fa6f952e99e7e10db3745c521b8ac Mon Sep 17 00:00:00 2001 From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:58:31 -0600 Subject: [PATCH 71/93] fix(gateway): address restart continuation review comments --- src/gateway/server-restart-sentinel.ts | 11 ++-- src/gateway/server-runtime-services.test.ts | 1 + src/gateway/server-runtime-services.ts | 2 + src/infra/session-delivery-queue-recovery.ts | 16 +++++- src/infra/session-delivery-queue-storage.ts | 2 +- .../session-delivery-queue.recovery.test.ts | 50 +++++++++++++++++++ .../session-delivery-queue.storage.test.ts | 22 ++++++++ 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 19811f3c934..e5e34df19cd 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -213,14 +213,15 @@ async function deliverQueuedSessionDelivery(params: { entry: QueuedSessionDelivery; }) { const { cfg, storePath, canonicalKey } = loadSessionEntry(params.entry.sessionKey); + const queuedDeliveryContext = resolveQueuedSessionDeliveryContext(params.entry); if (params.entry.kind === "systemEvent") { enqueueSystemEvent(params.entry.text, { sessionKey: canonicalKey, - ...(resolveQueuedSessionDeliveryContext(params.entry) + ...(queuedDeliveryContext ? { deliveryContext: { - ...resolveQueuedSessionDeliveryContext(params.entry), + ...queuedDeliveryContext, }, } : {}), @@ -232,10 +233,10 @@ async function deliverQueuedSessionDelivery(params: { if (!params.entry.route) { enqueueSystemEvent(params.entry.message, { sessionKey: canonicalKey, - ...(resolveQueuedSessionDeliveryContext(params.entry) + ...(queuedDeliveryContext ? { deliveryContext: { - ...resolveQueuedSessionDeliveryContext(params.entry), + ...queuedDeliveryContext, }, } : {}), @@ -383,10 +384,12 @@ async function drainRestartContinuationQueue(params: { export async function recoverPendingRestartContinuationDeliveries(params: { deps: CliDeps; log?: SessionDeliveryRecoveryLogger; + maxEnqueuedAt?: number; }) { await recoverPendingSessionDeliveries({ deliver: (entry) => deliverQueuedSessionDelivery({ deps: params.deps, entry }), log: params.log ?? log, + maxEnqueuedAt: params.maxEnqueuedAt, }); } diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 6105891b670..20b20db4852 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -104,6 +104,7 @@ describe("server-runtime-services", () => { expect(hoisted.recoverPendingRestartContinuationDeliveries).toHaveBeenCalledWith( expect.objectContaining({ deps: {}, + maxEnqueuedAt: expect.any(Number), }), ); }); diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index 0f62c66f326..f021562ae37 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -72,6 +72,7 @@ function recoverPendingSessionDeliveries(params: { deps: import("../cli/deps.types.js").CliDeps; log: GatewayRuntimeServiceLogger; }): void { + const maxEnqueuedAt = Date.now(); const timer = setTimeout(() => { void (async () => { const { recoverPendingRestartContinuationDeliveries } = @@ -80,6 +81,7 @@ function recoverPendingSessionDeliveries(params: { await recoverPendingRestartContinuationDeliveries({ deps: params.deps, log: logRecovery, + maxEnqueuedAt, }); })().catch((err) => params.log.error(`Session delivery recovery failed: ${String(err)}`)); }, 1_250); diff --git a/src/infra/session-delivery-queue-recovery.ts b/src/infra/session-delivery-queue-recovery.ts index 803b6c90de2..86902617858 100644 --- a/src/infra/session-delivery-queue-recovery.ts +++ b/src/infra/session-delivery-queue-recovery.ts @@ -199,8 +199,11 @@ export async function recoverPendingSessionDeliveries(opts: { log: SessionDeliveryRecoveryLogger; stateDir?: string; maxRecoveryMs?: number; + maxEnqueuedAt?: number; }): Promise { - const pending = await loadPendingSessionDeliveries(opts.stateDir); + const pending = (await loadPendingSessionDeliveries(opts.stateDir)).filter( + (entry) => opts.maxEnqueuedAt == null || entry.enqueuedAt <= opts.maxEnqueuedAt, + ); if (pending.length === 0) { return createEmptyRecoverySummary(); } @@ -223,9 +226,18 @@ export async function recoverPendingSessionDeliveries(opts: { if (!currentEntry) { continue; } + if (opts.maxEnqueuedAt != null && currentEntry.enqueuedAt > opts.maxEnqueuedAt) { + continue; + } if (currentEntry.retryCount >= MAX_SESSION_DELIVERY_RETRIES) { summary.skippedMaxRetries += 1; - await moveSessionDeliveryToFailed(currentEntry.id, opts.stateDir).catch(() => {}); + try { + await moveSessionDeliveryToFailed(currentEntry.id, opts.stateDir); + } catch (err) { + if (getErrnoCode(err) !== "ENOENT") { + throw err; + } + } continue; } diff --git a/src/infra/session-delivery-queue-storage.ts b/src/infra/session-delivery-queue-storage.ts index 59839625c23..09760c7d8fc 100644 --- a/src/infra/session-delivery-queue-storage.ts +++ b/src/infra/session-delivery-queue-storage.ts @@ -206,7 +206,7 @@ export async function loadPendingSessionDeliveries( } for (const file of files) { - if (file.endsWith(".delivered")) { + if (file.endsWith(".delivered") || file.endsWith(".tmp")) { await unlinkBestEffort(path.join(queueDir, file)); } } diff --git a/src/infra/session-delivery-queue.recovery.test.ts b/src/infra/session-delivery-queue.recovery.test.ts index 8aef5194f55..6d9662298a6 100644 --- a/src/infra/session-delivery-queue.recovery.test.ts +++ b/src/infra/session-delivery-queue.recovery.test.ts @@ -65,4 +65,54 @@ describe("session-delivery queue recovery", () => { expect(failedEntry?.lastError).toBe("transient failure"); }); }); + + it("skips entries queued after the startup recovery cutoff", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-23T00:00:00.000Z")); + + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "recover old entry", + }, + tempDir, + ); + const maxEnqueuedAt = Date.now(); + + vi.setSystemTime(new Date("2026-04-23T00:00:05.000Z")); + await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "leave fresh entry queued", + }, + tempDir, + ); + + const deliver = vi.fn(async () => undefined); + const summary = await recoverPendingSessionDeliveries({ + deliver, + stateDir: tempDir, + maxEnqueuedAt, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(summary.recovered).toBe(1); + const pending = await loadPendingSessionDeliveries(tempDir); + expect(pending).toHaveLength(1); + expect(pending[0]?.kind).toBe("systemEvent"); + if (pending[0]?.kind === "systemEvent") { + expect(pending[0].text).toBe("leave fresh entry queued"); + } + }); + + vi.useRealTimers(); + }); }); diff --git a/src/infra/session-delivery-queue.storage.test.ts b/src/infra/session-delivery-queue.storage.test.ts index 59ecbf3a6bd..6bfdafa256a 100644 --- a/src/infra/session-delivery-queue.storage.test.ts +++ b/src/infra/session-delivery-queue.storage.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { @@ -5,6 +7,7 @@ import { enqueueSessionDelivery, failSessionDelivery, loadPendingSessionDeliveries, + resolveSessionDeliveryQueueDir, } from "./session-delivery-queue.js"; describe("session-delivery queue storage", () => { @@ -56,4 +59,23 @@ describe("session-delivery queue storage", () => { expect(await loadPendingSessionDeliveries(tempDir)).toEqual([]); }); }); + + it("cleans up orphaned temporary queue files during load", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "restart complete", + }, + tempDir, + ); + const tmpPath = path.join(resolveSessionDeliveryQueueDir(tempDir), "orphan-entry.tmp"); + fs.writeFileSync(tmpPath, "stale tmp"); + + await loadPendingSessionDeliveries(tempDir); + + expect(fs.existsSync(tmpPath)).toBe(false); + }); + }); }); From 03addfe9ba59f368df91b48efaaabf1f46271103 Mon Sep 17 00:00:00 2001 From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:23:31 -0600 Subject: [PATCH 72/93] fix(gateway): tighten session delivery recovery --- src/infra/session-delivery-queue-recovery.ts | 2 +- src/infra/session-delivery-queue-storage.ts | 23 ++++++++++++- .../session-delivery-queue.recovery.test.ts | 33 +++++++++++++++++++ .../session-delivery-queue.storage.test.ts | 14 ++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/infra/session-delivery-queue-recovery.ts b/src/infra/session-delivery-queue-recovery.ts index 86902617858..5ac3121268a 100644 --- a/src/infra/session-delivery-queue-recovery.ts +++ b/src/infra/session-delivery-queue-recovery.ts @@ -72,7 +72,7 @@ export function isSessionDeliveryEligibleForRetry( entry: QueuedSessionDelivery, now: number, ): { eligible: true } | { eligible: false; remainingBackoffMs: number } { - const backoff = computeSessionDeliveryBackoffMs(entry.retryCount + 1); + const backoff = computeSessionDeliveryBackoffMs(entry.retryCount); if (backoff <= 0) { return { eligible: true }; } diff --git a/src/infra/session-delivery-queue-storage.ts b/src/infra/session-delivery-queue-storage.ts index 09760c7d8fc..ba4b928f75a 100644 --- a/src/infra/session-delivery-queue-storage.ts +++ b/src/infra/session-delivery-queue-storage.ts @@ -7,6 +7,7 @@ import { generateSecureUuid } from "./secure-random.js"; const QUEUE_DIRNAME = "session-delivery-queue"; const FAILED_DIRNAME = "failed"; +const TMP_SWEEP_MAX_AGE_MS = 5_000; export type SessionDeliveryContext = { channel?: string; @@ -71,6 +72,23 @@ async function unlinkBestEffort(filePath: string): Promise { } } +async function unlinkStaleTmpBestEffort(filePath: string, now: number): Promise { + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + return; + } + if (now - stat.mtimeMs < TMP_SWEEP_MAX_AGE_MS) { + return; + } + await unlinkBestEffort(filePath); + } catch (err) { + if (getErrnoCode(err) !== "ENOENT") { + throw err; + } + } +} + async function writeQueueEntry(filePath: string, entry: QueuedSessionDelivery): Promise { const tmp = `${filePath}.${process.pid}.tmp`; await fs.promises.writeFile(tmp, JSON.stringify(entry, null, 2), { @@ -205,9 +223,12 @@ export async function loadPendingSessionDeliveries( throw err; } + const now = Date.now(); for (const file of files) { - if (file.endsWith(".delivered") || file.endsWith(".tmp")) { + if (file.endsWith(".delivered")) { await unlinkBestEffort(path.join(queueDir, file)); + } else if (file.endsWith(".tmp")) { + await unlinkStaleTmpBestEffort(path.join(queueDir, file), now); } } diff --git a/src/infra/session-delivery-queue.recovery.test.ts b/src/infra/session-delivery-queue.recovery.test.ts index 6d9662298a6..1f62dcf0e19 100644 --- a/src/infra/session-delivery-queue.recovery.test.ts +++ b/src/infra/session-delivery-queue.recovery.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { enqueueSessionDelivery, + failSessionDelivery, + isSessionDeliveryEligibleForRetry, loadPendingSessionDeliveries, recoverPendingSessionDeliveries, } from "./session-delivery-queue.js"; @@ -115,4 +117,35 @@ describe("session-delivery queue recovery", () => { vi.useRealTimers(); }); + + it("uses the persisted retryCount for the first backoff tier", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-23T00:00:00.000Z")); + + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + const id = await enqueueSessionDelivery( + { + kind: "systemEvent", + sessionKey: "agent:main:main", + text: "retry me", + }, + tempDir, + ); + await failSessionDelivery(id, "transient failure", tempDir); + + const [failedEntry] = await loadPendingSessionDeliveries(tempDir); + expect(failedEntry).toBeDefined(); + expect(failedEntry?.retryCount).toBe(1); + expect(failedEntry?.lastAttemptAt).toBeDefined(); + + const lastAttemptAt = failedEntry?.lastAttemptAt ?? 0; + const notReady = isSessionDeliveryEligibleForRetry(failedEntry, lastAttemptAt + 4_999); + expect(notReady).toEqual({ eligible: false, remainingBackoffMs: 1 }); + + const ready = isSessionDeliveryEligibleForRetry(failedEntry, lastAttemptAt + 5_000); + expect(ready).toEqual({ eligible: true }); + }); + + vi.useRealTimers(); + }); }); diff --git a/src/infra/session-delivery-queue.storage.test.ts b/src/infra/session-delivery-queue.storage.test.ts index 6bfdafa256a..38422e28ed9 100644 --- a/src/infra/session-delivery-queue.storage.test.ts +++ b/src/infra/session-delivery-queue.storage.test.ts @@ -72,10 +72,24 @@ describe("session-delivery queue storage", () => { ); const tmpPath = path.join(resolveSessionDeliveryQueueDir(tempDir), "orphan-entry.tmp"); fs.writeFileSync(tmpPath, "stale tmp"); + const staleAt = new Date(Date.now() - 60_000); + fs.utimesSync(tmpPath, staleAt, staleAt); await loadPendingSessionDeliveries(tempDir); expect(fs.existsSync(tmpPath)).toBe(false); }); }); + + it("keeps fresh temporary queue files while a write may still be in flight", async () => { + await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { + const tmpPath = path.join(resolveSessionDeliveryQueueDir(tempDir), "active-entry.tmp"); + fs.mkdirSync(path.dirname(tmpPath), { recursive: true }); + fs.writeFileSync(tmpPath, "active tmp"); + + await loadPendingSessionDeliveries(tempDir); + + expect(fs.existsSync(tmpPath)).toBe(true); + }); + }); }); From a903df02f5ad5b166127a700fa4f2a56a6351162 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 25 Apr 2026 11:41:59 +0530 Subject: [PATCH 73/93] fix(gateway): bound restart continuation recovery --- src/gateway/server-restart-sentinel.ts | 7 +------ src/gateway/server-runtime-services.test.ts | 4 +++- src/gateway/server-runtime-services.ts | 11 ++++------- src/gateway/server.impl.ts | 3 ++- src/infra/session-delivery-queue-storage.ts | 8 ++------ 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index e5e34df19cd..446b3eaa415 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -91,8 +91,6 @@ async function deliverRestartSentinelNotice(params: { session: ReturnType; }) { const payloads = [{ text: params.message }]; - // Persist one recoverable notice across the whole retry loop so a transient - // failure does not leave behind a stale duplicate queue entry. const queueId = await enqueueDelivery({ channel: params.channel, to: params.to, @@ -136,9 +134,7 @@ async function deliverRestartSentinelNotice(params: { }); if (!retrying) { if (queueId) { - await failDelivery(queueId, formatErrorMessage(err)).catch(() => { - // Best-effort queue bookkeeping. - }); + await failDelivery(queueId, formatErrorMessage(err)).catch(() => undefined); } return; } @@ -483,7 +479,6 @@ async function loadRestartSentinelStartupTask(params: { ? String(replyTransport.threadId) : undefined : threadId; - // Keep the resolved route for the queued continuation and restart notice. } } diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 20b20db4852..1466cfbbb91 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -85,6 +85,7 @@ describe("server-runtime-services", () => { minimalTestGateway: false, cfgAtStart: {} as never, deps: {} as never, + sessionDeliveryRecoveryMaxEnqueuedAt: 123, cron, logCron: { error: vi.fn() }, log, @@ -104,7 +105,7 @@ describe("server-runtime-services", () => { expect(hoisted.recoverPendingRestartContinuationDeliveries).toHaveBeenCalledWith( expect.objectContaining({ deps: {}, - maxEnqueuedAt: expect.any(Number), + maxEnqueuedAt: 123, }), ); }); @@ -116,6 +117,7 @@ describe("server-runtime-services", () => { minimalTestGateway: true, cfgAtStart: {} as never, deps: {} as never, + sessionDeliveryRecoveryMaxEnqueuedAt: 123, cron, logCron: { error: vi.fn() }, log: createLog(), diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index f021562ae37..70cbe4c0773 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -71,8 +71,8 @@ function recoverPendingOutboundDeliveries(params: { function recoverPendingSessionDeliveries(params: { deps: import("../cli/deps.types.js").CliDeps; log: GatewayRuntimeServiceLogger; + maxEnqueuedAt: number; }): void { - const maxEnqueuedAt = Date.now(); const timer = setTimeout(() => { void (async () => { const { recoverPendingRestartContinuationDeliveries } = @@ -81,7 +81,7 @@ function recoverPendingSessionDeliveries(params: { await recoverPendingRestartContinuationDeliveries({ deps: params.deps, log: logRecovery, - maxEnqueuedAt, + maxEnqueuedAt: params.maxEnqueuedAt, }); })().catch((err) => params.log.error(`Session delivery recovery failed: ${String(err)}`)); }, 1_250); @@ -98,7 +98,6 @@ export function startGatewayRuntimeServices(params: { channelHealthMonitor: ChannelHealthMonitor | null; stopModelPricingRefresh: () => void; } { - // Keep scheduled work inert until post-attach sidecars finish. const channelHealthMonitor = startGatewayChannelHealthMonitor({ cfg: params.cfgAtStart, channelManager: params.channelManager, @@ -114,14 +113,11 @@ export function startGatewayRuntimeServices(params: { }; } -/** - * Activate cron scheduler, heartbeat runner, and pending delivery recovery - * after gateway sidecars are fully started and chat.history is available. - */ export function activateGatewayScheduledServices(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; deps: import("../cli/deps.types.js").CliDeps; + sessionDeliveryRecoveryMaxEnqueuedAt: number; cron: { start: () => Promise }; logCron: { error: (message: string) => void }; log: GatewayRuntimeServiceLogger; @@ -141,6 +137,7 @@ export function activateGatewayScheduledServices(params: { recoverPendingSessionDeliveries({ deps: params.deps, log: params.log, + maxEnqueuedAt: params.sessionDeliveryRecoveryMaxEnqueuedAt, }); return { heartbeatRunner }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 81578434e7a..f335dafb680 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -811,6 +811,7 @@ export async function startGatewayServer( }); await startListening(); startupTrace.mark("http.bound"); + const sessionDeliveryRecoveryMaxEnqueuedAt = Date.now(); ({ stopGatewayUpdateCheck: runtimeState.stopGatewayUpdateCheck, tailscaleCleanup: runtimeState.tailscaleCleanup, @@ -851,11 +852,11 @@ export async function startGatewayServer( )); startupTrace.mark("ready"); - // HTTP is live before sidecars finish; /readyz stays red until the startup sidecars settle. const activated = activateGatewayScheduledServices({ minimalTestGateway, cfgAtStart, deps, + sessionDeliveryRecoveryMaxEnqueuedAt, cron: runtimeState.cronState.cron, logCron, log, diff --git a/src/infra/session-delivery-queue-storage.ts b/src/infra/session-delivery-queue-storage.ts index ba4b928f75a..f6e7a6238f8 100644 --- a/src/infra/session-delivery-queue-storage.ts +++ b/src/infra/session-delivery-queue-storage.ts @@ -65,11 +65,7 @@ function buildEntryId(idempotencyKey?: string): string { } async function unlinkBestEffort(filePath: string): Promise { - try { - await fs.promises.unlink(filePath); - } catch { - // Best-effort cleanup. - } + await fs.promises.unlink(filePath).catch(() => undefined); } async function unlinkStaleTmpBestEffort(filePath: string, now: number): Promise { @@ -245,7 +241,7 @@ export async function loadPendingSessionDeliveries( } entries.push(await readQueueEntry(filePath)); } catch { - // Skip malformed or inaccessible entries. + continue; } } return entries; From 356530598ad4036b560caed68ce9a01605b41d21 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 25 Apr 2026 12:01:15 +0530 Subject: [PATCH 74/93] docs(changelog): note restart continuation recovery (#70780) (thanks @fuller-stack-dev) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbde953a42..7d559830031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. - fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt +- Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. ## 2026.4.24 (Unreleased) @@ -360,7 +361,6 @@ Docs: https://docs.openclaw.ai - Approvals/startup: let native approval handlers report ready after gateway authentication while replaying pending approvals in the background, so slow or failing replay delivery no longer blocks handler startup or amplifies reconnect storms. Thanks @steipete. - WhatsApp/security: keep contact/vCard/location structured-object free text out of the inline message body and render it through fenced untrusted metadata JSON, limiting hidden prompt-injection payloads in names, phone fields, and location labels/comments. Thanks @steipete. - Group-chat/security: keep channel-sourced group names and participant labels out of inline group system prompts and render them through fenced untrusted metadata JSON. Thanks @steipete. -- Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Agents/replay: preserve Kimi-style `functions.:` tool-call IDs during strict replay sanitization so custom OpenAI-compatible Kimi routes keep multi-turn tool use intact. (#70693) Thanks @geri4. - Discord/replies: preserve final reply permission context through outbound delivery so Discord replies keep the same channel/member routing rules at send time. Thanks @steipete. - Plugins/startup: restore bundled plugin `openclaw/plugin-sdk/*` resolution from packaged installs and external runtime-deps stage roots, so Telegram/Discord no longer crash-loop with `Cannot find package 'openclaw'` after missing dependency repair. (#70852) Thanks @simonemacario. From a983ea61ac326c3a1103b1891a243ff232644b2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:43:44 +0100 Subject: [PATCH 75/93] feat(google-meet): include transcript entries in artifacts --- CHANGELOG.md | 2 +- docs/plugins/google-meet.md | 11 +++--- extensions/google-meet/index.test.ts | 37 ++++++++++++++++++ extensions/google-meet/index.ts | 6 +++ extensions/google-meet/src/cli.ts | 18 +++++++++ extensions/google-meet/src/meet.ts | 56 ++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d559830031..6eb53bc55d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete. - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. -- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts and transcript entries, smart notes, and participant sessions. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 96d342740fe..53c419c6b92 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -643,11 +643,12 @@ openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --js ``` `artifacts` returns conference record metadata plus participant, recording, -transcript, and smart-note resource metadata when Google exposes it for the -meeting. `attendance` expands participants into participant-session rows with -join/leave timestamps. These commands use the Meet REST API only; transcript or -smart-note document body download is intentionally out of scope because that -requires separate Google Docs/Drive access. +transcript, structured transcript-entry, and smart-note resource metadata when +Google exposes it for the meeting. Use `--no-transcript-entries` to skip +entry lookup for large meetings. `attendance` expands participants into +participant-session rows with join/leave timestamps. These commands use the Meet +REST API only; Google Docs/Drive document body download is intentionally out of +scope because that requires separate Google Docs/Drive access. Create a fresh Meet space: diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index c5be3528d2a..66ef97d6adb 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -156,6 +156,20 @@ function stubMeetArtifactsApi() { ], }); } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") { + return jsonResponse({ + transcriptEntries: [ + { + name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", + participant: "conferenceRecords/rec-1/participants/p1", + text: "Hello from the transcript.", + languageCode: "en-US", + startTime: "2026-04-25T10:01:00Z", + endTime: "2026-04-25T10:01:05Z", + }, + ], + }); + } if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { return jsonResponse({ smartNotes: [ @@ -439,6 +453,17 @@ describe("google-meet plugin", () => { participants: [{ name: "conferenceRecords/rec-1/participants/p1" }], recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + transcriptEntries: [ + { + transcript: "conferenceRecords/rec-1/transcripts/t1", + entries: [ + { + name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", + text: "Hello from the transcript.", + }, + ], + }, + ], smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], }, ], @@ -460,6 +485,12 @@ describe("google-meet plugin", () => { auditContext: "google-meet.conferenceRecords.smartNotes.list", }), ); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/transcripts/t1/entries?pageSize=2", + auditContext: "google-meet.conferenceRecords.transcripts.entries.list", + }), + ); }); it("lists Meet attendance rows with participant sessions", async () => { @@ -868,6 +899,12 @@ describe("google-meet plugin", () => { { recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + transcriptEntries: [ + { + transcript: "conferenceRecords/rec-1/transcripts/t1", + entries: [{ text: "Hello from the transcript." }], + }, + ], smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], }, ], diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 10a668ae0f5..d875dccfaa0 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -186,6 +186,9 @@ const GoogleMeetToolSchema = Type.Object({ Type.String({ description: "Meet conferenceRecords/{id} resource name or id" }), ), pageSize: Type.Optional(Type.Number({ description: "Meet API page size for list actions" })), + includeTranscriptEntries: Type.Optional( + Type.Boolean({ description: "For artifacts, include structured transcript entries" }), + ), accessToken: Type.Optional(Type.String({ description: "Access token override" })), refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), @@ -271,6 +274,7 @@ async function resolveArtifactQueryFromParams( meeting, conferenceRecord, pageSize: resolveOptionalPositiveInteger(raw.pageSize), + includeTranscriptEntries: raw.includeTranscriptEntries !== false, }; } @@ -397,6 +401,7 @@ export default definePluginEntry({ meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, }), ); } catch (err) { @@ -566,6 +571,7 @@ export default definePluginEntry({ meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, }), ); } diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index c2c6b7c7ba7..afc4b77f0a7 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -51,6 +51,7 @@ type ResolveSpaceOptions = { type MeetArtifactOptions = ResolveSpaceOptions & { conferenceRecord?: string; pageSize?: string; + transcriptEntries?: boolean; }; type SetupOptions = { @@ -429,6 +430,7 @@ function resolveArtifactTokenOptions( accessToken?: string; expiresAt?: number; pageSize?: number; + includeTranscriptEntries?: boolean; } { const meeting = options.meeting?.trim() || config.defaults.meeting; const conferenceRecord = options.conferenceRecord?.trim(); @@ -446,6 +448,7 @@ function resolveArtifactTokenOptions( accessToken: options.accessToken?.trim() || config.oauth.accessToken, expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, pageSize: parseOptionalNumber(options.pageSize), + includeTranscriptEntries: options.transcriptEntries !== false, }; } @@ -474,6 +477,10 @@ function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { writeStdoutLine("participants: %d", entry.participants.length); writeStdoutLine("recordings: %d", entry.recordings.length); writeStdoutLine("transcripts: %d", entry.transcripts.length); + writeStdoutLine( + "transcript entries: %d", + entry.transcriptEntries.reduce((count, transcript) => count + transcript.entries.length, 0), + ); writeStdoutLine("smart notes: %d", entry.smartNotes.length); if (entry.smartNotesError) { writeStdoutLine("smart notes warning: %s", entry.smartNotesError); @@ -484,6 +491,15 @@ function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { for (const transcript of entry.transcripts) { writeStdoutLine("- transcript: %s", transcript.name); } + for (const transcriptEntries of entry.transcriptEntries) { + if (transcriptEntries.entriesError) { + writeStdoutLine( + "- transcript entries warning: %s: %s", + transcriptEntries.transcript, + transcriptEntries.entriesError, + ); + } + } for (const smartNote of entry.smartNotes) { writeStdoutLine("- smart note: %s", smartNote.name); } @@ -840,6 +856,7 @@ export function registerGoogleMeetCli(params: { .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") + .option("--no-transcript-entries", "Skip structured transcript entry lookup") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -849,6 +866,7 @@ export function registerGoogleMeetCli(params: { meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, }); if (options.json) { writeStdoutJson({ diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index acf1ba9af88..8cace830bfd 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -73,6 +73,21 @@ export type GoogleMeetTranscript = { docsDestination?: Record; }; +export type GoogleMeetTranscriptEntry = { + name: string; + participant?: string; + text?: string; + languageCode?: string; + startTime?: string; + endTime?: string; +}; + +export type GoogleMeetTranscriptEntries = { + transcript: string; + entries: GoogleMeetTranscriptEntry[]; + entriesError?: string; +}; + export type GoogleMeetSmartNote = { name: string; startTime?: string; @@ -85,6 +100,7 @@ export type GoogleMeetArtifactsEntry = { participants: GoogleMeetParticipant[]; recordings: GoogleMeetRecording[]; transcripts: GoogleMeetTranscript[]; + transcriptEntries: GoogleMeetTranscriptEntries[]; smartNotes: GoogleMeetSmartNote[]; smartNotesError?: string; }; @@ -434,6 +450,21 @@ export async function listGoogleMeetTranscripts(params: { }); } +export async function listGoogleMeetTranscriptEntries(params: { + accessToken: string; + transcript: string; + pageSize?: number; +}): Promise { + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(params.transcript)}/entries`, + collectionKey: "transcriptEntries", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.transcripts.entries.list", + errorPrefix: "Google Meet conferenceRecords.transcripts.entries.list", + }); +} + export async function listGoogleMeetSmartNotes(params: { accessToken: string; conferenceRecord: string; @@ -506,6 +537,7 @@ export async function fetchGoogleMeetArtifacts(params: { meeting?: string; conferenceRecord?: string; pageSize?: number; + includeTranscriptEntries?: boolean; }): Promise { const resolved = await resolveConferenceRecordQuery(params); const artifacts = await Promise.all( @@ -537,11 +569,35 @@ export async function fetchGoogleMeetArtifacts(params: { smartNotesError: getErrorMessage(error), })), ]); + const transcriptEntries = + params.includeTranscriptEntries === false + ? [] + : await Promise.all( + transcripts.map(async (transcript) => { + try { + return { + transcript: transcript.name, + entries: await listGoogleMeetTranscriptEntries({ + accessToken: params.accessToken, + transcript: transcript.name, + pageSize: params.pageSize, + }), + }; + } catch (error) { + return { + transcript: transcript.name, + entries: [], + entriesError: getErrorMessage(error), + }; + } + }), + ); return { conferenceRecord, participants, recordings, transcripts, + transcriptEntries, smartNotes: smartNotesResult.smartNotes, ...(smartNotesResult.smartNotesError ? { smartNotesError: smartNotesResult.smartNotesError } From 66e66f19c6720e9302397aa65f30d4f1f99e993e Mon Sep 17 00:00:00 2001 From: Alex Fries Date: Sat, 25 Apr 2026 02:46:03 -0400 Subject: [PATCH 76/93] feat(memory-core): expose hybrid search component scores Expose raw `vectorScore` and `textScore` alongside the combined hybrid memory search `score`. - Preserve vector/text component scores from `mergeHybridResults` output. - Add optional component-score fields to both memory host SDK type surfaces. - Extend hybrid merge tests for vector-only, text-only, and overlapping result cases. - Document that component scores remain raw retrieval diagnostics while temporal decay/MMR only adjust or reorder the combined ranking `score`. Closes #68166. Maintainer verification: - `pnpm test extensions/memory-core/src/memory/hybrid.test.ts` - `pnpm check:changed` - Fresh GitHub checks passed. Co-authored-by: Alex Fries --- extensions/memory-core/src/memory/hybrid.test.ts | 6 ++++++ extensions/memory-core/src/memory/hybrid.ts | 6 ++++++ packages/memory-host-sdk/src/host/types.ts | 2 ++ src/memory-host-sdk/host/types.ts | 2 ++ 4 files changed, 16 insertions(+) diff --git a/extensions/memory-core/src/memory/hybrid.test.ts b/extensions/memory-core/src/memory/hybrid.test.ts index 134e7bfe7eb..deb9f947710 100644 --- a/extensions/memory-core/src/memory/hybrid.test.ts +++ b/extensions/memory-core/src/memory/hybrid.test.ts @@ -60,7 +60,11 @@ describe("memory hybrid helpers", () => { const a = merged.find((r) => r.path === "memory/a.md"); const b = merged.find((r) => r.path === "memory/b.md"); expect(a?.score).toBeCloseTo(0.7 * 0.9); + expect(a?.vectorScore).toBeCloseTo(0.9); + expect(a?.textScore).toBe(0); expect(b?.score).toBeCloseTo(0.3 * 1.0); + expect(b?.vectorScore).toBe(0); + expect(b?.textScore).toBeCloseTo(1.0); }); it("mergeHybridResults prefers keyword snippet when ids overlap", async () => { @@ -94,5 +98,7 @@ describe("memory hybrid helpers", () => { expect(merged).toHaveLength(1); expect(merged[0]?.snippet).toBe("kw-a"); expect(merged[0]?.score).toBeCloseTo(0.5 * 0.2 + 0.5 * 1.0); + expect(merged[0]?.vectorScore).toBeCloseTo(0.2); + expect(merged[0]?.textScore).toBeCloseTo(1.0); }); }); diff --git a/extensions/memory-core/src/memory/hybrid.ts b/extensions/memory-core/src/memory/hybrid.ts index 209a6bc3f31..5d84ca42101 100644 --- a/extensions/memory-core/src/memory/hybrid.ts +++ b/extensions/memory-core/src/memory/hybrid.ts @@ -72,6 +72,8 @@ export async function mergeHybridResults(params: { startLine: number; endLine: number; score: number; + vectorScore: number; + textScore: number; snippet: string; source: HybridSource; }> @@ -131,11 +133,15 @@ export async function mergeHybridResults(params: { startLine: entry.startLine, endLine: entry.endLine, score, + vectorScore: entry.vectorScore, + textScore: entry.textScore, snippet: entry.snippet, source: entry.source, }; }); + // Keep component scores as raw retrieval diagnostics; temporal decay and MMR + // only adjust or reorder the combined ranking score. const temporalDecayConfig = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay }; const decayed = await applyTemporalDecayToHybridResults({ results: merged, diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 534a914e20a..602a5a1b1d0 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -5,6 +5,8 @@ export type MemorySearchResult = { startLine: number; endLine: number; score: number; + vectorScore?: number; + textScore?: number; snippet: string; source: MemorySource; citation?: string; diff --git a/src/memory-host-sdk/host/types.ts b/src/memory-host-sdk/host/types.ts index b7a068fc3d8..0f70b9f2c4d 100644 --- a/src/memory-host-sdk/host/types.ts +++ b/src/memory-host-sdk/host/types.ts @@ -5,6 +5,8 @@ export type MemorySearchResult = { startLine: number; endLine: number; score: number; + vectorScore?: number; + textScore?: number; snippet: string; source: MemorySource; citation?: string; From b34ece705f41d2f00a3d55e6a3abc1e2b9035c7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:49:05 +0100 Subject: [PATCH 77/93] fix: retire idle bundled MCP runtimes --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/cli/mcp.md | 3 + docs/gateway/cli-backends.md | 6 + docs/gateway/configuration-reference.md | 38 ++++++ extensions/active-memory/index.test.ts | 1 + extensions/active-memory/index.ts | 1 + src/agents/pi-bundle-mcp-materialize.ts | 16 ++- src/agents/pi-bundle-mcp-runtime.test.ts | 123 ++++++++++++++++- src/agents/pi-bundle-mcp-runtime.ts | 125 ++++++++++++++++-- src/agents/pi-bundle-mcp-types.ts | 3 + .../run/attempt.subscription-cleanup.ts | 6 + src/agents/pi-embedded-runner/run/attempt.ts | 23 +++- src/commands/models/list.probe.ts | 2 + src/config/schema.base.generated.ts | 12 ++ src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.mcp.ts | 6 + src/config/zod-schema.ts | 1 + src/hooks/llm-slug-generator.test.ts | 1 + src/hooks/llm-slug-generator.ts | 1 + 21 files changed, 358 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb53bc55d8..0a11d98208a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106 and #71110. - CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. - fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 6e0d27250ce..1b448c49e4a 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -3b8ff208a31b04ea61391182444bd744357577872eac279136bbc284c3dc064a config-baseline.json -4dfeadeb814fb205f5a17d797cbbe3c07685009821fe8dbf8771ea428ed5b4dd config-baseline.core.json +13b68287fec00108ca66032120909a0eac797ed541e026357e175e3fce5bacdd config-baseline.json +77ee66fb3b2cde94b393712bc03a132b096cf601c193bde1fe42902eecb0b66b config-baseline.core.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json 0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 2256616a4e0..77a6d64b8cb 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -376,6 +376,9 @@ Important behavior: - embedded Pi exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly +- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` + milliseconds of idle time (default 10 minutes; set `0` to disable) and + one-shot embedded runs clean them up at run end ## Saved MCP server definitions diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index a7b10a8c4d6..72bc541df76 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -349,6 +349,12 @@ When bundle MCP is enabled, OpenClaw: If no MCP servers are enabled, OpenClaw still injects a strict config when a backend opts into bundle MCP so background runs stay isolated. +Session-scoped bundled MCP runtimes are cached for reuse within a session, then +reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 +minutes; set `0` to disable). One-shot embedded runs such as auth probes, +slug generation, and active-memory recall request cleanup at run end so stdio +children and Streamable HTTP/SSE streams do not outlive the run. + ## Limitations - **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index bace1d62c56..0880b163454 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -51,6 +51,44 @@ Tool policy, experimental toggles, provider-backed tool config, and custom provider / base-URL setup moved to a dedicated page — see [Configuration — tools and custom providers](/gateway/config-tools). +## MCP + +OpenClaw-managed MCP server definitions live under `mcp.servers` and are +consumed by embedded Pi and other runtime adapters. The `openclaw mcp list`, +`show`, `set`, and `unset` commands manage this block without connecting to the +target server during config edits. + +```json5 +{ + mcp: { + // Optional. Default: 600000 ms (10 minutes). Set 0 to disable idle eviction. + sessionIdleTtlMs: 600000, + servers: { + docs: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-fetch"], + }, + remote: { + url: "https://example.com/mcp", + transport: "streamable-http", // streamable-http | sse + headers: { + Authorization: "Bearer ${MCP_REMOTE_TOKEN}", + }, + }, + }, + }, +} +``` + +- `mcp.servers`: named stdio or remote MCP server definitions for runtimes that + expose configured MCP tools. +- `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes. + One-shot embedded runs request run-end cleanup; this TTL is the backstop for + long-lived sessions and future callers. + +See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and +[CLI backends](/gateway/cli-backends#bundle-mcp-overlays) for runtime behavior. + ## Skills ```json5 diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 072b2b22b1c..8a32d790c94 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -566,6 +566,7 @@ describe("active-memory plugin", () => { }, }, }, + cleanupBundleMcpOnRunEnd: true, }); }); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index a7afcf84c29..3a3b548c75e 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1684,6 +1684,7 @@ async function runRecallSubagent(params: { thinkLevel: params.config.thinking, reasoningLevel: "off", silentExpected: true, + cleanupBundleMcpOnRunEnd: true, abortSignal: params.abortSignal, }); if (params.abortSignal?.aborted) { diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/pi-bundle-mcp-materialize.ts index a222ea27663..4c33286486a 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/pi-bundle-mcp-materialize.ts @@ -66,8 +66,16 @@ export async function materializeBundleMcpToolsForRun(params: { reservedToolNames?: Iterable; disposeRuntime?: () => Promise; }): Promise { + let disposed = false; + const releaseLease = params.runtime.acquireLease?.(); params.runtime.markUsed(); - const catalog = await params.runtime.getCatalog(); + let catalog; + try { + catalog = await params.runtime.getCatalog(); + } catch (error) { + releaseLease?.(); + throw error; + } const reservedNames = normalizeReservedToolNames(params.reservedToolNames); const tools: BundleMcpToolRuntime["tools"] = []; const sortedCatalogTools = [...catalog.tools].toSorted((a, b) => { @@ -104,6 +112,7 @@ export async function materializeBundleMcpToolsForRun(params: { description: tool.description || tool.fallbackDescription, parameters: tool.inputSchema, execute: async (_toolCallId: string, input: unknown) => { + params.runtime.markUsed(); const result = await params.runtime.callTool(tool.serverName, tool.toolName, input); return toAgentToolResult({ serverName: tool.serverName, @@ -127,6 +136,11 @@ export async function materializeBundleMcpToolsForRun(params: { return { tools, dispose: async () => { + if (disposed) { + return; + } + disposed = true; + releaseLease?.(); await params.disposeRuntime?.(); }, }; diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 2f2eac1a6bb..dafaedc1062 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -26,13 +26,19 @@ function makeRuntime( tools: Array<{ toolName: string; description: string }>, serverName = "bundleProbe", ): SessionMcpRuntime { + const createdAt = Date.now(); + let lastUsedAt = createdAt; return { sessionId: "session-colliding-tools", workspaceDir: "/tmp", configFingerprint: "fingerprint", - createdAt: 0, - lastUsedAt: 0, - markUsed: () => {}, + createdAt, + get lastUsedAt() { + return lastUsedAt; + }, + markUsed: () => { + lastUsedAt = Date.now(); + }, getCatalog: async () => ({ version: 1, generatedAt: 0, @@ -135,6 +141,27 @@ describe("session MCP runtime", () => { ]); }); + it("holds a runtime lease until the materialized tool runtime is disposed", async () => { + let activeLeases = 0; + const runtime = { + ...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]), + acquireLease: () => { + activeLeases += 1; + return () => { + activeLeases -= 1; + }; + }, + }; + + const materialized = await materializeBundleMcpToolsForRun({ runtime }); + expect(activeLeases).toBe(1); + + await materialized.dispose(); + await materialized.dispose(); + + expect(activeLeases).toBe(0); + }); + it("reuses repeated materialization and recreates after explicit disposal", async () => { const created: SessionMcpRuntime[] = []; const disposed: string[] = []; @@ -361,4 +388,94 @@ describe("session MCP runtime", () => { retireSessionMcpRuntimeForSessionKey({ sessionKey: "agent:test:missing", reason: "test" }), ).resolves.toBe(false); }); + + it("evicts idle runtimes after the configured TTL but skips active leases", async () => { + let now = 1_000; + const disposed: string[] = []; + const createRuntime: RuntimeFactory = (params) => { + let lastUsedAt = now; + let activeLeases = 0; + return { + ...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + configFingerprint: params.configFingerprint ?? "fingerprint", + get lastUsedAt() { + return lastUsedAt; + }, + get activeLeases() { + return activeLeases; + }, + markUsed: () => { + lastUsedAt = now; + }, + acquireLease: () => { + activeLeases += 1; + return () => { + activeLeases -= 1; + lastUsedAt = now; + }; + }, + dispose: async () => { + disposed.push(params.sessionId); + }, + }; + }; + const manager = __testing.createSessionMcpRuntimeManager({ + createRuntime, + now: () => now, + enableIdleSweepTimer: false, + }); + + const runtime = await manager.getOrCreate({ + sessionId: "session-idle", + sessionKey: "agent:test:session-idle", + workspaceDir: "/workspace", + cfg: { mcp: { servers: {}, sessionIdleTtlMs: 50 } }, + }); + const releaseLease = runtime.acquireLease?.(); + + now += 60; + await expect(manager.sweepIdleRuntimes()).resolves.toBe(0); + expect(manager.listSessionIds()).toEqual(["session-idle"]); + + releaseLease?.(); + now += 60; + await expect(manager.sweepIdleRuntimes()).resolves.toBe(1); + + expect(disposed).toEqual(["session-idle"]); + expect(manager.listSessionIds()).toEqual([]); + expect(manager.resolveSessionId("agent:test:session-idle")).toBeUndefined(); + }); + + it("keeps idle runtime eviction disabled when the TTL is zero", async () => { + let now = 1_000; + const disposed: string[] = []; + const manager = __testing.createSessionMcpRuntimeManager({ + createRuntime: (params) => ({ + ...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + configFingerprint: params.configFingerprint ?? "fingerprint", + dispose: async () => { + disposed.push(params.sessionId); + }, + }), + now: () => now, + enableIdleSweepTimer: false, + }); + + await manager.getOrCreate({ + sessionId: "session-no-ttl", + workspaceDir: "/workspace", + cfg: { mcp: { servers: {}, sessionIdleTtlMs: 0 } }, + }); + + now += 60_000_000; + await expect(manager.sweepIdleRuntimes()).resolves.toBe(0); + expect(manager.listSessionIds()).toEqual(["session-no-ttl"]); + expect(disposed).toEqual([]); + }); }); diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/pi-bundle-mcp-runtime.ts index 801a4039185..0b610e9eaf1 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/pi-bundle-mcp-runtime.ts @@ -45,6 +45,8 @@ type CreateSessionMcpRuntime = ( const require = createRequire(import.meta.url); const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager"); const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema"; +const DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS = 10 * 60 * 1000; +const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000; type Ajv2020Like = { compile: (schema: JsonSchemaType) => ValidateFunction; @@ -168,6 +170,14 @@ function createDisposedError(sessionId: string): Error { return new Error(`bundle-mcp runtime disposed for session ${sessionId}`); } +function resolveSessionMcpRuntimeIdleTtlMs(cfg?: OpenClawConfig): number { + const raw = cfg?.mcp?.sessionIdleTtlMs; + if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { + return Math.floor(raw); + } + return DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS; +} + export function createSessionMcpRuntime(params: { sessionId: string; sessionKey?: string; @@ -181,6 +191,7 @@ export function createSessionMcpRuntime(params: { }); const createdAt = Date.now(); let lastUsedAt = createdAt; + let activeLeases = 0; let disposed = false; let catalog: McpToolCatalog | null = null; let catalogInFlight: Promise | undefined; @@ -318,6 +329,21 @@ export function createSessionMcpRuntime(params: { get lastUsedAt() { return lastUsedAt; }, + get activeLeases() { + return activeLeases; + }, + acquireLease() { + activeLeases += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + activeLeases = Math.max(0, activeLeases - 1); + lastUsedAt = Date.now(); + }; + }, getCatalog, markUsed() { lastUsedAt = Date.now(); @@ -349,11 +375,18 @@ export function createSessionMcpRuntime(params: { } function createSessionMcpRuntimeManager( - opts: { createRuntime?: CreateSessionMcpRuntime } = {}, + opts: { + createRuntime?: CreateSessionMcpRuntime; + now?: () => number; + enableIdleSweepTimer?: boolean; + idleSweepIntervalMs?: number; + } = {}, ): SessionMcpRuntimeManager { const runtimesBySessionId = new Map(); const sessionIdBySessionKey = new Map(); + const idleTtlMsBySessionId = new Map(); const createRuntime = opts.createRuntime ?? createSessionMcpRuntime; + const now = opts.now ?? Date.now; const createInFlight = new Map< string, { @@ -362,9 +395,79 @@ function createSessionMcpRuntimeManager( configFingerprint: string; } >(); + const idleSweepIntervalMs = opts.idleSweepIntervalMs ?? SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS; + let idleSweepTimer: ReturnType | undefined; + let idleSweepInFlight: Promise | undefined; + + const forgetSessionKeysForSessionId = (sessionId: string) => { + for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { + if (mappedSessionId === sessionId) { + sessionIdBySessionKey.delete(sessionKey); + } + } + }; + + const sweepIdleRuntimes = async (): Promise => { + const nowMs = now(); + const expired: SessionMcpRuntime[] = []; + for (const [sessionId, runtime] of runtimesBySessionId.entries()) { + const idleTtlMs = + idleTtlMsBySessionId.get(sessionId) ?? DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS; + if (idleTtlMs <= 0 || (runtime.activeLeases ?? 0) > 0) { + continue; + } + if (nowMs - runtime.lastUsedAt < idleTtlMs) { + continue; + } + runtimesBySessionId.delete(sessionId); + idleTtlMsBySessionId.delete(sessionId); + forgetSessionKeysForSessionId(sessionId); + expired.push(runtime); + } + await Promise.allSettled(expired.map((runtime) => runtime.dispose())); + return expired.length; + }; + + const queueIdleSweep = () => { + if (idleSweepInFlight) { + return; + } + idleSweepInFlight = sweepIdleRuntimes() + .then(() => undefined) + .catch((error: unknown) => { + logWarn(`bundle-mcp: idle runtime sweep failed: ${String(error)}`); + }) + .finally(() => { + idleSweepInFlight = undefined; + }); + }; + + const ensureIdleSweepTimer = () => { + if (opts.enableIdleSweepTimer === false || idleSweepIntervalMs <= 0 || idleSweepTimer) { + return; + } + idleSweepTimer = setInterval(queueIdleSweep, idleSweepIntervalMs); + idleSweepTimer.unref?.(); + }; + + const clearIdleSweepTimer = () => { + if (!idleSweepTimer) { + return; + } + clearInterval(idleSweepTimer); + idleSweepTimer = undefined; + }; return { async getOrCreate(params) { + const idleTtlMs = resolveSessionMcpRuntimeIdleTtlMs(params.cfg); + if (runtimesBySessionId.has(params.sessionId)) { + idleTtlMsBySessionId.set(params.sessionId, idleTtlMs); + } + await sweepIdleRuntimes(); + if (idleTtlMs > 0) { + ensureIdleSweepTimer(); + } if (params.sessionKey) { sessionIdBySessionKey.set(params.sessionKey, params.sessionId); } @@ -383,6 +486,7 @@ function createSessionMcpRuntimeManager( await existing.dispose(); } else { existing.markUsed(); + idleTtlMsBySessionId.set(params.sessionId, idleTtlMs); return existing; } } @@ -397,6 +501,7 @@ function createSessionMcpRuntimeManager( createInFlight.delete(params.sessionId); const staleRuntime = await inFlight.promise.catch(() => undefined); runtimesBySessionId.delete(params.sessionId); + idleTtlMsBySessionId.delete(params.sessionId); await staleRuntime?.dispose(); } const created = Promise.resolve( @@ -410,6 +515,7 @@ function createSessionMcpRuntimeManager( ).then((runtime) => { runtime.markUsed(); runtimesBySessionId.set(params.sessionId, runtime); + idleTtlMsBySessionId.set(params.sessionId, idleTtlMs); return runtime; }); createInFlight.set(params.sessionId, { @@ -437,27 +543,22 @@ function createSessionMcpRuntimeManager( runtime = await inFlight.promise.catch(() => undefined); } runtimesBySessionId.delete(sessionId); + idleTtlMsBySessionId.delete(sessionId); if (!runtime) { - for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { - if (mappedSessionId === sessionId) { - sessionIdBySessionKey.delete(sessionKey); - } - } + forgetSessionKeysForSessionId(sessionId); return; } - for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { - if (mappedSessionId === sessionId) { - sessionIdBySessionKey.delete(sessionKey); - } - } + forgetSessionKeysForSessionId(sessionId); await runtime.dispose(); }, async disposeAll() { + clearIdleSweepTimer(); const inFlightRuntimes = Array.from(createInFlight.values()); createInFlight.clear(); const runtimes = Array.from(runtimesBySessionId.values()); runtimesBySessionId.clear(); sessionIdBySessionKey.clear(); + idleTtlMsBySessionId.clear(); const lateRuntimes = await Promise.all( inFlightRuntimes.map(async ({ promise }) => await promise.catch(() => undefined)), ); @@ -469,6 +570,7 @@ function createSessionMcpRuntimeManager( } await Promise.allSettled(Array.from(allRuntimes, (runtime) => runtime.dispose())); }, + sweepIdleRuntimes, listSessionIds() { return Array.from(runtimesBySessionId.keys()); }, @@ -539,4 +641,5 @@ export const __testing = { getCachedSessionIds() { return getSessionMcpRuntimeManager().listSessionIds(); }, + resolveSessionMcpRuntimeIdleTtlMs, }; diff --git a/src/agents/pi-bundle-mcp-types.ts b/src/agents/pi-bundle-mcp-types.ts index 83d962ea64f..951e27566b1 100644 --- a/src/agents/pi-bundle-mcp-types.ts +++ b/src/agents/pi-bundle-mcp-types.ts @@ -38,6 +38,8 @@ export type SessionMcpRuntime = { configFingerprint: string; createdAt: number; lastUsedAt: number; + activeLeases?: number; + acquireLease?: () => () => void; getCatalog: () => Promise; markUsed: () => void; callTool: (serverName: string, toolName: string, input: unknown) => Promise; @@ -55,5 +57,6 @@ export type SessionMcpRuntimeManager = { resolveSessionId: (sessionKey: string) => string | undefined; disposeSession: (sessionId: string) => Promise; disposeAll: () => Promise; + sweepIdleRuntimes: () => Promise; listSessionIds: () => string[]; }; diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index ed5647dcf9c..5c11eec1412 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -27,6 +27,7 @@ export async function cleanupEmbeddedAttemptResources(params: { releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void; allowWsSessionPool?: boolean; sessionId: string; + bundleMcpRuntime?: { dispose(): Promise | void }; bundleLspRuntime?: { dispose(): Promise | void }; sessionLock: { release(): Promise | void }; }): Promise { @@ -55,6 +56,11 @@ export async function cleanupEmbeddedAttemptResources(params: { } catch { /* best-effort */ } + try { + await params.bundleMcpRuntime?.dispose(); + } catch { + /* best-effort */ + } try { await params.bundleLspRuntime?.dispose(); } catch { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6acbf5749e4..15005d1604e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -83,6 +83,7 @@ import { supportsModelTools } from "../../model-tool-support.js"; import { releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; +import { TOOL_NAME_SEPARATOR } from "../../pi-bundle-mcp-names.js"; import { getOrCreateSessionMcpRuntime, materializeBundleMcpToolsForRun, @@ -465,6 +466,20 @@ export function applyEmbeddedAttemptToolsAllow( return tools.filter((tool) => allowSet.has(tool.name)); } +function shouldCreateBundleMcpRuntimeForAttempt(params: { + toolsEnabled: boolean; + disableTools?: boolean; + toolsAllow?: string[]; +}): boolean { + if (!params.toolsEnabled || params.disableTools === true) { + return false; + } + if (!params.toolsAllow || params.toolsAllow.length === 0) { + return true; + } + return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR)); +} + function collectAttemptExplicitToolAllowlistSources(params: { config?: EmbeddedRunAttemptParams["config"]; sessionKey?: string; @@ -835,7 +850,12 @@ export async function runEmbeddedAttempt( model: params.model, }); const clientTools = toolsEnabled ? params.clientTools : undefined; - const bundleMcpSessionRuntime = toolsEnabled + const bundleMcpEnabled = shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled, + disableTools: params.disableTools, + toolsAllow: params.toolsAllow, + }); + const bundleMcpSessionRuntime = bundleMcpEnabled ? await getOrCreateSessionMcpRuntime({ sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -3099,6 +3119,7 @@ export async function runEmbeddedAttempt( allowWsSessionPool: !promptError && !aborted && !timedOut && !idleTimedOut && !timedOutDuringCompaction, sessionId: params.sessionId, + bundleMcpRuntime, bundleLspRuntime, sessionLock, }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 9020e0e4556..8d7abc87115 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -479,6 +479,8 @@ async function probeTarget(params: { reasoningLevel: "off", verboseLevel: "off", streamParams: { maxTokens }, + disableTools: true, + cleanupBundleMcpOnRunEnd: true, }); return buildResult("ok"); } catch (err) { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 5e66ea99c2c..2926220475c 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -22503,6 +22503,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", }, + sessionIdleTtlMs: { + type: "number", + minimum: 0, + title: "MCP Runtime Idle TTL", + description: + "Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.", + }, }, additionalProperties: false, title: "MCP", @@ -26343,6 +26350,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", tags: ["advanced"], }, + "mcp.sessionIdleTtlMs": { + label: "MCP Runtime Idle TTL", + help: "Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.", + tags: ["storage"], + }, "ui.seamColor": { label: "Accent Color", help: "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d632ab41bc4..02d61ddeaa8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1313,6 +1313,8 @@ export const FIELD_HELP: Record = { mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", "mcp.servers": "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", + "mcp.sessionIdleTtlMs": + "Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.", session: "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "session.scope": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 694e2dffa01..b27b439e2ac 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -620,6 +620,7 @@ export const FIELD_LABELS: Record = { "commands.allowFrom": "Command Elevated Access Rules", mcp: "MCP", "mcp.servers": "MCP Servers", + "mcp.sessionIdleTtlMs": "MCP Runtime Idle TTL", ui: "UI", "ui.seamColor": "Accent Color", "ui.assistant": "Assistant Appearance", diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index 2de3bd4ab3b..0ca78673f44 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -23,4 +23,10 @@ export type McpServerConfig = { export type McpConfig = { /** Named MCP server definitions managed by OpenClaw. */ servers?: Record; + /** + * Idle TTL for session-scoped bundled MCP runtimes, in milliseconds. + * + * Defaults to 10 minutes. Set to 0 to disable idle eviction. + */ + sessionIdleTtlMs?: number; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2cb1d00fd17..013d919d122 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -231,6 +231,7 @@ const McpServerSchema = z const McpConfigSchema = z .object({ servers: z.record(z.string(), McpServerSchema).optional(), + sessionIdleTtlMs: z.number().finite().min(0).optional(), }) .strict() .optional(); diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 94c8d81f499..4467d21e7b5 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -34,6 +34,7 @@ describe("generateSlugViaLLM", () => { expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ timeoutMs: 15_000, + cleanupBundleMcpOnRunEnd: true, }), ); }); diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index fe600d39a4d..f32d71562d3 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -75,6 +75,7 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", model, timeoutMs, runId: `slug-gen-${Date.now()}`, + cleanupBundleMcpOnRunEnd: true, }); // Extract text from payloads From d068cb960de47d5587c5942f6beb86e0f124b449 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:49:52 +0100 Subject: [PATCH 78/93] fix: stabilize qa lab memory and thinking scenarios --- extensions/active-memory/index.test.ts | 3 + extensions/active-memory/index.ts | 1 + .../memory/active-memory-preprompt-recall.md | 2 +- qa/scenarios/memory/session-memory-ranking.md | 2 +- .../models/thinking-slash-model-remap.md | 87 +++++-------------- 5 files changed, 30 insertions(+), 65 deletions(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 8a32d790c94..b021f30bac7 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -654,6 +654,9 @@ describe("active-memory plugin", () => { "You receive conversation context, including the user's latest message.", ); expect(runParams?.prompt).toContain("Use only memory_search and memory_get."); + expect(runParams?.prompt).toContain( + "When searching for preference or habit recall, use a permissive memory_search threshold 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.", ); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 3a3b548c75e..6961e54d328 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -787,6 +787,7 @@ function buildRecallPrompt(params: { "Your job is to search memory and return only the most relevant memory context for that model.", "You receive conversation context, including the user's latest message.", "Use only memory_search and memory_get.", + "When searching for preference or habit recall, use a permissive memory_search threshold before deciding that no useful memory exists.", "Do not answer the user directly.", `Prompt style: ${params.config.promptStyle}.`, ...buildPromptStyleLines(params.config.promptStyle), diff --git a/qa/scenarios/memory/active-memory-preprompt-recall.md b/qa/scenarios/memory/active-memory-preprompt-recall.md index 4f9a1c506e0..b924f88219a 100644 --- a/qa/scenarios/memory/active-memory-preprompt-recall.md +++ b/qa/scenarios/memory/active-memory-preprompt-recall.md @@ -45,7 +45,7 @@ execution: config: baselineConversationId: qa-active-memory-off activeConversationId: qa-active-memory-on - memoryFact: "Stable QA movie night snack preference: lemon pepper wings with blue cheese." + memoryFact: "Stable QA movie night usual favorite snack preference: lemon pepper wings with blue cheese." memoryQuery: "QA movie night snack lemon pepper wings blue cheese" expectedNeedle: lemon pepper wings prompt: "Silent snack recall check: what snack do I usually want for QA movie night? Reply in one short sentence." diff --git a/qa/scenarios/memory/session-memory-ranking.md b/qa/scenarios/memory/session-memory-ranking.md index a17dbcb24fb..9569c89b04f 100644 --- a/qa/scenarios/memory/session-memory-ranking.md +++ b/qa/scenarios/memory/session-memory-ranking.md @@ -30,7 +30,7 @@ execution: transcriptId: qa-session-memory-ranking transcriptQuestion: "What is the current Project Nebula codename?" transcriptAnswer: "The current Project Nebula codename is ORBIT-10." - prompt: "Session memory ranking check: what is the current Project Nebula codename? Use memory tools first. If durable notes conflict with newer indexed session transcripts, prefer the newer current fact." + prompt: "Session memory ranking check: what is the current Project Nebula codename? Use memory_search first with corpus=sessions for indexed session transcripts. If durable notes conflict with newer indexed session transcripts, prefer the newer current fact." promptSnippet: "Session memory ranking check" ``` diff --git a/qa/scenarios/models/thinking-slash-model-remap.md b/qa/scenarios/models/thinking-slash-model-remap.md index 1b47f2bc66e..786386565da 100644 --- a/qa/scenarios/models/thinking-slash-model-remap.md +++ b/qa/scenarios/models/thinking-slash-model-remap.md @@ -10,7 +10,15 @@ coverage: secondary: - models.switching - runtime.session-continuity -objective: Verify /think lists provider-owned levels and remaps stored thinking levels when /model changes provider capabilities. +objective: Verify /think lists provider-owned levels and remaps stored thinking levels when the session model changes provider capabilities. +plugins: + - anthropic +gatewayConfigPatch: + agents: + defaults: + models: + anthropic/claude-sonnet-4-6: + params: {} successCriteria: - Anthropic Claude Sonnet 4.6 advertises adaptive but not OpenAI-only xhigh or Opus max. - A stored adaptive level remaps to medium when switching to OpenAI GPT-5.4. @@ -35,7 +43,8 @@ execution: anthropicModelRef: anthropic/claude-sonnet-4-6 openAiXhighModelRef: openai/gpt-5.4 noXhighModelRef: anthropic/claude-sonnet-4-6 - conversationId: qa-thinking-slash-remap + conversationId: thinking-slash-remap + sessionKey: agent:qa:main ``` ```yaml qa-flow @@ -55,25 +64,9 @@ steps: expr: "env.providerMode === config.requiredProviderMode" message: expr: "`thinking remap scenario requires ${config.requiredProviderMode}; got ${env.providerMode}`" - - set: cursor + - set: anthropicModelAck value: - expr: state.getSnapshot().messages.length - - call: state.addInboundMessage - args: - - conversation: - id: - expr: config.conversationId - kind: direct - senderId: qa-operator - senderName: QA Operator - text: - expr: "`/model ${config.anthropicModelRef}`" - - call: waitForCondition - saveAs: anthropicModelAck - args: - - lambda: - expr: "state.getSnapshot().messages.slice(cursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && candidate.text.includes(`Model set to ${config.anthropicModelRef}`)).at(-1)" - - expr: liveTurnTimeoutMs(env, 20000) + expr: "await env.gateway.call('sessions.patch', { key: config.sessionKey, model: config.anthropicModelRef }, { timeoutMs: liveTurnTimeoutMs(env, 45000) })" - set: cursor value: expr: state.getSnapshot().messages.length @@ -100,7 +93,7 @@ steps: expr: "!/Options: .*\\bxhigh\\b/i.test(anthropicThinkStatus.text) && !/Options: .*\\bmax\\b/i.test(anthropicThinkStatus.text)" message: expr: "`expected Sonnet /think options to omit xhigh/max, got ${anthropicThinkStatus.text}`" - detailsExpr: "`model=${anthropicModelAck.text}; think=${anthropicThinkStatus.text}`" + detailsExpr: "`model=${JSON.stringify(anthropicModelAck.resolved)}; think=${anthropicThinkStatus.text}`" - name: maps adaptive to medium when switching to OpenAI actions: - set: cursor @@ -121,29 +114,13 @@ steps: - lambda: expr: "state.getSnapshot().messages.slice(cursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking level set to adaptive/i.test(candidate.text)).at(-1)" - expr: liveTurnTimeoutMs(env, 20000) - - set: cursor + - set: openAiModelAck value: - expr: state.getSnapshot().messages.length - - call: state.addInboundMessage - args: - - conversation: - id: - expr: config.conversationId - kind: direct - senderId: qa-operator - senderName: QA Operator - text: - expr: "`/model ${config.openAiXhighModelRef}`" - - call: waitForCondition - saveAs: openAiModelAck - args: - - lambda: - expr: "state.getSnapshot().messages.slice(cursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && candidate.text.includes(config.openAiXhighModelRef) && /Model (set to|reset to default)/i.test(candidate.text)).at(-1)" - - expr: liveTurnTimeoutMs(env, 20000) + expr: "await env.gateway.call('sessions.patch', { key: config.sessionKey, model: config.openAiXhighModelRef }, { timeoutMs: liveTurnTimeoutMs(env, 45000) })" - assert: - expr: "/Thinking level set to medium \\(adaptive not supported for openai\\/gpt-5\\.4\\)/i.test(openAiModelAck.text)" + expr: "openAiModelAck.entry?.thinkingLevel === 'medium'" message: - expr: "`expected adaptive->medium remap, got ${openAiModelAck.text}`" + expr: "`expected adaptive->medium remap, got ${JSON.stringify(openAiModelAck.entry)}`" - set: cursor value: expr: state.getSnapshot().messages.length @@ -166,7 +143,7 @@ steps: expr: "/Options: .*\\bxhigh\\b/i.test(openAiThinkStatus.text) && !/Options: .*\\badaptive\\b/i.test(openAiThinkStatus.text) && !/Options: .*\\bmax\\b/i.test(openAiThinkStatus.text)" message: expr: "`expected OpenAI GPT-5.4 /think options to include xhigh only, got ${openAiThinkStatus.text}`" - detailsExpr: "`adaptive=${adaptiveAck.text}; switch=${openAiModelAck.text}; think=${openAiThinkStatus.text}`" + detailsExpr: "`adaptive=${adaptiveAck.text}; switch=${JSON.stringify(openAiModelAck.resolved)}; think=${openAiThinkStatus.text}`" - name: maps xhigh to high on a model without xhigh actions: - set: cursor @@ -187,29 +164,13 @@ steps: - lambda: expr: "state.getSnapshot().messages.slice(cursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking level set to xhigh/i.test(candidate.text)).at(-1)" - expr: liveTurnTimeoutMs(env, 20000) - - set: cursor + - set: noXhighModelAck value: - expr: state.getSnapshot().messages.length - - call: state.addInboundMessage - args: - - conversation: - id: - expr: config.conversationId - kind: direct - senderId: qa-operator - senderName: QA Operator - text: - expr: "`/model ${config.noXhighModelRef}`" - - call: waitForCondition - saveAs: noXhighModelAck - args: - - lambda: - expr: "state.getSnapshot().messages.slice(cursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && candidate.text.includes(config.noXhighModelRef) && /Model (set to|reset to default)/i.test(candidate.text)).at(-1)" - - expr: liveTurnTimeoutMs(env, 20000) + expr: "await env.gateway.call('sessions.patch', { key: config.sessionKey, model: config.noXhighModelRef }, { timeoutMs: liveTurnTimeoutMs(env, 45000) })" - assert: - expr: "/Thinking level set to high \\(xhigh not supported for anthropic\\/claude-sonnet-4-6\\)/i.test(noXhighModelAck.text)" + expr: "noXhighModelAck.entry?.thinkingLevel === 'high'" message: - expr: "`expected xhigh->high remap, got ${noXhighModelAck.text}`" + expr: "`expected xhigh->high remap, got ${JSON.stringify(noXhighModelAck.entry)}`" - set: cursor value: expr: state.getSnapshot().messages.length @@ -232,5 +193,5 @@ steps: expr: "/Options: .*\\badaptive\\b/i.test(noXhighThinkStatus.text) && !/Options: .*\\bxhigh\\b/i.test(noXhighThinkStatus.text) && !/Options: .*\\bmax\\b/i.test(noXhighThinkStatus.text)" message: expr: "`expected non-xhigh model /think options to include adaptive and omit xhigh/max, got ${noXhighThinkStatus.text}`" - detailsExpr: "`xhigh=${xhighAck.text}; switch=${noXhighModelAck.text}; think=${noXhighThinkStatus.text}`" + detailsExpr: "`xhigh=${xhighAck.text}; switch=${JSON.stringify(noXhighModelAck.resolved)}; think=${noXhighThinkStatus.text}`" ``` From bb5e278f63bd6d5fcf99e8c4b79190feed43ae46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:52:43 +0100 Subject: [PATCH 79/93] fix(feishu): stabilize topic group session keys --- CHANGELOG.md | 1 + docs/channels/feishu.md | 6 ++ extensions/feishu/src/bot-content.ts | 13 +++- extensions/feishu/src/bot.test.ts | 59 +++++++++++++++++++ extensions/feishu/src/bot.ts | 9 ++- extensions/feishu/src/config-schema.ts | 5 +- extensions/feishu/src/event-types.ts | 2 +- extensions/feishu/src/mention.ts | 3 +- extensions/feishu/src/monitor.account.ts | 4 +- .../feishu/src/monitor.message-handler.ts | 4 +- extensions/feishu/src/send.ts | 5 +- extensions/feishu/src/types.ts | 8 ++- 12 files changed, 105 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a11d98208a..6b2c01ef11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Feishu: back off streaming-card creation after HTTP 400 startup failures, so unsupported card setups fall back without delaying every message. Fixes #56981. Thanks @JinnanDuan. +- Feishu/topic groups: key native Feishu/Lark topic-group sessions by `thread_id` so starter messages and replies with different `root_id` formats stay in the same `group_topic` conversation. Fixes #71438. Thanks @1335848090. - Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI. - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index ce87a8056a8..50571935ae4 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -430,6 +430,12 @@ Full configuration: [Gateway configuration](/gateway/configuration) - ✅ Thread replies - ✅ Media replies stay thread-aware when replying to a thread message +For `groupSessionScope: "group_topic"` and `"group_topic_sender"`, native +Feishu/Lark topic groups use the event `thread_id` (`omt_*`) as the canonical +topic session key. Normal group replies that OpenClaw turns into threads keep +using the reply root message ID (`om_*`) so the first turn and follow-up turn +stay in the same session. + --- ## Related diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index ce166c99fc6..90e470e2726 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -4,7 +4,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js"; import { downloadMessageResourceFeishu } from "./media.js"; import { parsePostContent } from "./post.js"; import { getFeishuRuntime } from "./runtime.js"; -import type { FeishuMediaInfo } from "./types.js"; +import type { FeishuChatType, FeishuMediaInfo } from "./types.js"; export type FeishuMention = { key: string; @@ -54,6 +54,7 @@ export function resolveFeishuGroupSession(params: { messageId: string; rootId?: string; threadId?: string; + chatType?: FeishuChatType; groupConfig?: { groupSessionScope?: GroupSessionScope; topicSessionMode?: "enabled" | "disabled"; @@ -65,7 +66,8 @@ export function resolveFeishuGroupSession(params: { replyInThread?: "enabled" | "disabled"; }; }): ResolvedFeishuGroupSession { - const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params; + const { chatId, senderOpenId, messageId, rootId, threadId, chatType, groupConfig, feishuCfg } = + params; const normalizedThreadId = threadId?.trim(); const normalizedRootId = rootId?.trim(); const threadReply = Boolean(normalizedThreadId || normalizedRootId); @@ -78,9 +80,14 @@ export function resolveFeishuGroupSession(params: { groupConfig?.groupSessionScope ?? feishuCfg?.groupSessionScope ?? (legacyTopicSessionMode === "enabled" ? "group_topic" : "group"); + const normalizedTopicGroupThreadId = + chatType === "topic_group" ? (normalizedThreadId ?? normalizedRootId) : undefined; const topicScope = groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender" - ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null)) + ? (normalizedTopicGroupThreadId ?? + normalizedRootId ?? + normalizedThreadId ?? + (replyInThread ? messageId : null)) : null; let peerId = chatId; diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 34816ca9d52..07f7c334971 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2031,6 +2031,65 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("uses thread_id as the canonical topic key in Feishu topic groups", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic", + }, + }, + }, + }, + } as ClawdbotConfig; + + const topicStarter: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-user" } }, + message: { + message_id: "om_topic_starter_message", + chat_id: "oc-group", + chat_type: "topic_group", + root_id: "omt_topic_1", + message_type: "text", + content: JSON.stringify({ text: "topic starter" }), + }, + }; + const topicReply: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-user" } }, + message: { + message_id: "om_topic_reply_message", + chat_id: "oc-group", + chat_type: "topic_group", + root_id: "om_topic_starter_message", + thread_id: "omt_topic_1", + message_type: "text", + content: JSON.stringify({ text: "topic reply" }), + }, + }; + + await dispatchMessage({ cfg, event: topicStarter }); + await dispatchMessage({ cfg, event: topicReply }); + + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_topic_1" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_topic_1" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + }); + it("uses thread_id as topic key when root_id is missing", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index e03a82a4231..dc899d62f99 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -54,7 +54,11 @@ import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js"; export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js"; import type { FeishuMessageEvent } from "./event-types.js"; -import type { FeishuMessageContext, FeishuMessageInfo } from "./types.js"; +import { + isFeishuGroupChatType, + type FeishuMessageContext, + type FeishuMessageInfo, +} from "./types.js"; import type { DynamicAgentCreationConfig } from "./types.js"; export { toMessageResourceType } from "./bot-content.js"; @@ -300,7 +304,7 @@ export async function handleFeishuMessage(params: { } let ctx = parseFeishuMessageEvent(event, botOpenId, botName); - const isGroup = ctx.chatType === "group"; + const isGroup = isFeishuGroupChatType(ctx.chatType); const isDirect = !isGroup; const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id); @@ -391,6 +395,7 @@ export async function handleFeishuMessage(params: { messageId: ctx.messageId, rootId: ctx.rootId, threadId: ctx.threadId, + chatType: ctx.chatType, groupConfig, feishuCfg, }) diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 1efd59e12a8..c75fd98fbfc 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -122,8 +122,9 @@ const GroupSessionScopeSchema = z * - "disabled" (default): All messages in a group share one session * - "enabled": Messages in different topics get separate sessions * - * Topic routing uses `root_id` when present to keep session continuity and - * falls back to `thread_id` when `root_id` is unavailable. + * Topic routing uses Feishu topic-group `thread_id` when the event identifies a + * native topic group, and keeps `root_id` precedence for normal groups so + * reply-created threads stay on the initiating message session. */ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional(); const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional(); diff --git a/extensions/feishu/src/event-types.ts b/extensions/feishu/src/event-types.ts index 37af2f7d601..ec63f47daa6 100644 --- a/extensions/feishu/src/event-types.ts +++ b/extensions/feishu/src/event-types.ts @@ -14,7 +14,7 @@ export type FeishuMessageEvent = { parent_id?: string; thread_id?: string; chat_id: string; - chat_type: "p2p" | "group" | "private"; + chat_type: "p2p" | "group" | "topic_group" | "private"; message_type: string; content: string; create_time?: string; diff --git a/extensions/feishu/src/mention.ts b/extensions/feishu/src/mention.ts index 313b0751037..f320cb0e868 100644 --- a/extensions/feishu/src/mention.ts +++ b/extensions/feishu/src/mention.ts @@ -1,6 +1,7 @@ import type { FeishuMessageEvent } from "./event-types.js"; export type { MentionTarget } from "./mention-target.types.js"; import type { MentionTarget } from "./mention-target.types.js"; +import { isFeishuGroupChatType } from "./types.js"; /** * Escape regex metacharacters so user-controlled mention fields are treated literally. @@ -46,7 +47,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s return false; } - const isDirectMessage = event.message.chat_type !== "group"; + const isDirectMessage = !isFeishuGroupChatType(event.message.chat_type); const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId); if (isDirectMessage) { diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 9f60900e8c7..10254a2511f 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -156,7 +156,9 @@ export async function resolveReactionSyntheticEvent( } function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined { - return value === "group" || value === "private" || value === "p2p" ? value : undefined; + return value === "group" || value === "topic_group" || value === "private" || value === "p2p" + ? value + : undefined; } type RegisterEventHandlersContext = { diff --git a/extensions/feishu/src/monitor.message-handler.ts b/extensions/feishu/src/monitor.message-handler.ts index e4d44d76945..363c42eea18 100644 --- a/extensions/feishu/src/monitor.message-handler.ts +++ b/extensions/feishu/src/monitor.message-handler.ts @@ -59,7 +59,9 @@ type FeishuMessageReceiveHandlerContext = { }; function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined { - return value === "group" || value === "private" || value === "p2p" ? value : undefined; + return value === "group" || value === "topic_group" || value === "private" || value === "p2p" + ? value + : undefined; } function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null { diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index df7388d917b..338565167d0 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -268,7 +268,10 @@ function parseFeishuMessageItem( messageId: item.message_id ?? fallbackMessageId ?? "", chatId: item.chat_id ?? "", chatType: - item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p" + item.chat_type === "group" || + item.chat_type === "topic_group" || + item.chat_type === "private" || + item.chat_type === "p2p" ? item.chat_type : undefined, senderId: item.sender?.id, diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index a790cf7296e..3ffbcb9c102 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -43,7 +43,7 @@ export type FeishuMessageContext = { senderId: string; senderOpenId: string; senderName?: string; - chatType: "p2p" | "group" | "private"; + chatType: FeishuChatType; mentionedBot: boolean; hasAnyMention?: boolean; rootId?: string; @@ -60,7 +60,11 @@ export type FeishuSendResult = { chatId: string; }; -export type FeishuChatType = "p2p" | "group" | "private"; +export type FeishuChatType = "p2p" | "group" | "topic_group" | "private"; + +export function isFeishuGroupChatType(chatType: FeishuChatType | undefined): boolean { + return chatType === "group" || chatType === "topic_group"; +} export type FeishuMessageInfo = { messageId: string; From 417b1c5507c6c0b52938a7309de7cf3d380d1090 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:53:25 +0100 Subject: [PATCH 80/93] feat(google-meet): export artifacts reports --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 9 ++ extensions/google-meet/index.test.ts | 81 ++++++++++++ extensions/google-meet/src/cli.ts | 177 +++++++++++++++++++++++++-- 4 files changed, 260 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2c01ef11f..00d70621ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. - Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts and transcript entries, smart notes, and participant sessions. Thanks @steipete. +- Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 53c419c6b92..89c9e39945e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -642,6 +642,15 @@ openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --jso openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json ``` +Write a readable report: + +```bash +openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 \ + --format markdown --output meet-artifacts.md +openclaw googlemeet attendance --conference-record conferenceRecords/abc123 \ + --format markdown --output meet-attendance.md +``` + `artifacts` returns conference record metadata plus participant, recording, transcript, structured transcript-entry, and smart-note resource metadata when Google exposes it for the meeting. Use `--no-transcript-entries` to skip diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 66ef97d6adb..2ca27049171 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1,4 +1,7 @@ import { EventEmitter } from "node:events"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { Command } from "commander"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; @@ -915,6 +918,48 @@ describe("google-meet plugin", () => { } }); + it("CLI artifacts writes markdown output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); + const outputPath = path.join(tempDir, "artifacts.md"); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + "--output", + outputPath, + ], + { from: "user" }, + ); + const markdown = readFileSync(outputPath, "utf8"); + expect(stdout.output()).toContain(`wrote: ${outputPath}`); + expect(markdown).toContain("# Google Meet Artifacts"); + expect(markdown).toContain("## conferenceRecords/rec-1"); + expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); + expect(markdown).toContain("Hello from the transcript."); + } finally { + stdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("CLI attendance prints participant sessions by default", async () => { stubMeetArtifactsApi(); const program = new Command(); @@ -949,6 +994,42 @@ describe("google-meet plugin", () => { } }); + it("CLI attendance prints markdown output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("# Google Meet Attendance"); + expect(stdout.output()).toContain("## Alice"); + expect(stdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + stdout.restore(); + } + }); + it("CLI doctor prints human-readable session health", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index afc4b77f0a7..726b3bfa7f1 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -1,3 +1,4 @@ +import { writeFile } from "node:fs/promises"; import { createInterface } from "node:readline/promises"; import { format } from "node:util"; import type { Command } from "commander"; @@ -52,6 +53,8 @@ type MeetArtifactOptions = ResolveSpaceOptions & { conferenceRecord?: string; pageSize?: string; transcriptEntries?: boolean; + format?: "summary" | "markdown"; + output?: string; }; type SetupOptions = { @@ -98,6 +101,15 @@ function writeStdoutLine(...values: unknown[]): void { process.stdout.write(`${format(...values)}\n`); } +async function writeCliOutput(options: { output?: string }, text: string): Promise { + if (options.output?.trim()) { + await writeFile(options.output, text.endsWith("\n") ? text : `${text}\n`, "utf8"); + writeStdoutLine("wrote: %s", options.output); + return; + } + process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); +} + async function promptInput(message: string): Promise { const rl = createInterface({ input: process.stdin, @@ -535,6 +547,123 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { } } +function pushMarkdownLine(lines: string[], text = ""): void { + lines.push(text); +} + +function formatMarkdownOptional(value: unknown): string { + return typeof value === "string" && value.trim() ? value : "n/a"; +} + +function formatMarkdownIdentity(row: GoogleMeetAttendanceResult["attendance"][number]): string { + return row.displayName || row.user || row.participant; +} + +function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string { + const lines: string[] = ["# Google Meet Artifacts"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + if (result.space) { + pushMarkdownLine(lines, `Space: ${result.space.name}`); + } + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`); + for (const entry of result.artifacts) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`); + pushMarkdownLine(lines, `Started: ${formatMarkdownOptional(entry.conferenceRecord.startTime)}`); + pushMarkdownLine(lines, `Ended: ${formatMarkdownOptional(entry.conferenceRecord.endTime)}`); + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Participants: ${entry.participants.length}`); + pushMarkdownLine(lines, `Recordings: ${entry.recordings.length}`); + pushMarkdownLine(lines, `Transcripts: ${entry.transcripts.length}`); + pushMarkdownLine( + lines, + `Transcript entries: ${entry.transcriptEntries.reduce( + (count, transcript) => count + transcript.entries.length, + 0, + )}`, + ); + pushMarkdownLine(lines, `Smart notes: ${entry.smartNotes.length}`); + if (entry.recordings.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Recordings"); + for (const recording of entry.recordings) { + pushMarkdownLine(lines, `- ${recording.name}`); + } + } + if (entry.transcripts.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Transcripts"); + for (const transcript of entry.transcripts) { + pushMarkdownLine(lines, `- ${transcript.name}`); + } + } + for (const transcriptEntries of entry.transcriptEntries) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `### Transcript Entries: ${transcriptEntries.transcript}`); + if (transcriptEntries.entriesError) { + pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`); + continue; + } + if (transcriptEntries.entries.length === 0) { + pushMarkdownLine(lines, "_No transcript entries._"); + continue; + } + for (const transcriptEntry of transcriptEntries.entries) { + const times = + transcriptEntry.startTime || transcriptEntry.endTime + ? ` (${formatMarkdownOptional(transcriptEntry.startTime)} -> ${formatMarkdownOptional( + transcriptEntry.endTime, + )})` + : ""; + const speaker = transcriptEntry.participant ? `${transcriptEntry.participant}: ` : ""; + pushMarkdownLine(lines, `- ${speaker}${transcriptEntry.text ?? ""}${times}`); + } + } + if (entry.smartNotes.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Smart Notes"); + for (const smartNote of entry.smartNotes) { + pushMarkdownLine(lines, `- ${smartNote.name}`); + } + } + } + return `${lines.join("\n")}\n`; +} + +function renderAttendanceMarkdown(result: GoogleMeetAttendanceResult): string { + const lines: string[] = ["# Google Meet Attendance"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + if (result.space) { + pushMarkdownLine(lines, `Space: ${result.space.name}`); + } + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`); + pushMarkdownLine(lines, `Attendance rows: ${result.attendance.length}`); + for (const row of result.attendance) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${formatMarkdownIdentity(row)}`); + pushMarkdownLine(lines, `Record: ${row.conferenceRecord}`); + pushMarkdownLine(lines, `Resource: ${row.participant}`); + pushMarkdownLine(lines, `First joined: ${formatMarkdownOptional(row.earliestStartTime)}`); + pushMarkdownLine(lines, `Last left: ${formatMarkdownOptional(row.latestEndTime)}`); + pushMarkdownLine(lines, `Sessions: ${row.sessions.length}`); + for (const session of row.sessions) { + pushMarkdownLine( + lines, + `- ${session.name}: ${formatMarkdownOptional(session.startTime)} -> ${formatMarkdownOptional( + session.endTime, + )}`, + ); + } + } + return `${lines.join("\n")}\n`; +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -857,6 +986,8 @@ export function registerGoogleMeetCli(params: { .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--format ", "Output format: summary or markdown", "summary") + .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -869,12 +1000,26 @@ export function registerGoogleMeetCli(params: { includeTranscriptEntries: resolved.includeTranscriptEntries, }); if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); + await writeCliOutput( + options, + JSON.stringify( + { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }, + null, + 2, + ), + ); return; } + if (options.format === "markdown") { + await writeCliOutput(options, renderArtifactsMarkdown(result)); + return; + } + if (options.format && options.format !== "summary") { + throw new Error("Unsupported format. Expected summary or markdown."); + } writeArtifactsSummary(result); writeStdoutLine( "token source: %s", @@ -893,6 +1038,8 @@ export function registerGoogleMeetCli(params: { .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") + .option("--format ", "Output format: summary or markdown", "summary") + .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -904,12 +1051,26 @@ export function registerGoogleMeetCli(params: { pageSize: resolved.pageSize, }); if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); + await writeCliOutput( + options, + JSON.stringify( + { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }, + null, + 2, + ), + ); return; } + if (options.format === "markdown") { + await writeCliOutput(options, renderAttendanceMarkdown(result)); + return; + } + if (options.format && options.format !== "summary") { + throw new Error("Unsupported format. Expected summary or markdown."); + } writeAttendanceSummary(result); writeStdoutLine( "token source: %s", From 56eb1ffabf1dee43e6f9f2d3b7d8dda0fd081223 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:55:34 -0700 Subject: [PATCH 81/93] fix(diagnostics-otel): support preloaded sdk mode (#71450) --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 1 + docs/logging.md | 6 ++ .../diagnostics-otel/src/service.test.ts | 90 ++++++++++++++++++- extensions/diagnostics-otel/src/service.ts | 54 ++++++----- 5 files changed, 128 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d70621ebe..51c929631c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#70424) Thanks @jlapenna. - Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0880b163454..98305dbef98 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -896,6 +896,7 @@ Notes: - `otel.sampleRate`: trace sampling rate `0`–`1`. - `otel.flushIntervalMs`: periodic telemetry flush interval in ms. - `otel.captureContent`: opt-in raw content capture for OTEL span attributes. Defaults to off. Boolean `true` captures non-system message/tool content; the object form lets you enable `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, and `systemPrompt` explicitly. +- `OPENCLAW_OTEL_PRELOADED=1`: environment toggle for hosts that already registered a global OpenTelemetry SDK. OpenClaw then skips plugin-owned SDK startup/shutdown while keeping diagnostic listeners active. - `cacheTrace.enabled`: log cache trace snapshots for embedded runs (default: `false`). - `cacheTrace.filePath`: output path for cache trace JSONL (default: `$OPENCLAW_STATE_DIR/logs/cache-trace.jsonl`). - `cacheTrace.includeMessages` / `includePrompt` / `includeSystem`: control what is included in cache trace output (all default: `true`). diff --git a/docs/logging.md b/docs/logging.md index d9bc63f431e..210b3054137 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -307,6 +307,10 @@ Notes: - Set `headers` when your collector requires auth. - Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`. +- Set `OPENCLAW_OTEL_PRELOADED=1` when another preload or host process already + registered the global OpenTelemetry SDK. In that mode the plugin does not start + or shut down its own SDK, but it still wires OpenClaw diagnostic listeners and + honors `diagnostics.otel.traces`, `metrics`, and `logs`. ### Exported metrics (names + types) @@ -389,6 +393,8 @@ classes you opted into. `OTEL_EXPORTER_OTLP_ENDPOINT`. - If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is. - If the endpoint already contains `/v1/logs`, it is used as-is for logs. +- `OPENCLAW_OTEL_PRELOADED=1` reuses an externally registered OpenTelemetry SDK + for traces/metrics instead of starting a plugin-owned NodeSDK. - `diagnostics.otel.logs` enables OTLP log export for the main logger output. ### Log export behavior diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 0cda7fe9df3..a11f969ca0c 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const telemetryState = vi.hoisted(() => { const counters = new Map }>(); @@ -125,6 +125,7 @@ const GRANDCHILD_SPAN_ID = "2222222222222222"; const PROTO_KEY = "__proto__"; const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 4096; const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20; +const ORIGINAL_OPENCLAW_OTEL_PRELOADED = process.env.OPENCLAW_OTEL_PRELOADED; function createLogger() { return { @@ -194,6 +195,7 @@ function flushDiagnosticEvents() { describe("diagnostics-otel service", () => { beforeEach(() => { + delete process.env.OPENCLAW_OTEL_PRELOADED; telemetryState.counters.clear(); telemetryState.histograms.clear(); telemetryState.spans.length = 0; @@ -208,6 +210,14 @@ describe("diagnostics-otel service", () => { traceExporterCtor.mockClear(); }); + afterEach(() => { + if (ORIGINAL_OPENCLAW_OTEL_PRELOADED === undefined) { + delete process.env.OPENCLAW_OTEL_PRELOADED; + } else { + process.env.OPENCLAW_OTEL_PRELOADED = ORIGINAL_OPENCLAW_OTEL_PRELOADED; + } + }); + test("records message-flow metrics and spans", async () => { const service = createDiagnosticsOtelService(); const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true }); @@ -318,6 +328,84 @@ describe("diagnostics-otel service", () => { expect(telemetryState.tracer.startSpan).not.toHaveBeenCalled(); }); + test("uses a preloaded OpenTelemetry SDK without dropping diagnostic listeners", async () => { + process.env.OPENCLAW_OTEL_PRELOADED = "1"; + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true }); + await service.start(ctx); + + expect(sdkStart).not.toHaveBeenCalled(); + expect(traceExporterCtor).not.toHaveBeenCalled(); + expect(ctx.logger.info).toHaveBeenCalledWith( + "diagnostics-otel: using preloaded OpenTelemetry SDK", + ); + + emitDiagnosticEvent({ + type: "run.completed", + runId: "run-1", + provider: "openai", + model: "gpt-5.4", + outcome: "completed", + durationMs: 100, + }); + emitDiagnosticEvent({ + type: "log.record", + level: "INFO", + message: "preloaded log", + }); + await flushDiagnosticEvents(); + + expect(telemetryState.histograms.get("openclaw.run.duration_ms")?.record).toHaveBeenCalledWith( + 100, + expect.objectContaining({ + "openclaw.provider": "openai", + "openclaw.model": "gpt-5.4", + }), + ); + expect(telemetryState.tracer.startSpan).toHaveBeenCalledWith( + "openclaw.run", + expect.objectContaining({ + attributes: expect.objectContaining({ + "openclaw.outcome": "completed", + }), + }), + undefined, + ); + expect(logEmit).toHaveBeenCalled(); + + await service.stop?.(ctx); + expect(sdkShutdown).not.toHaveBeenCalled(); + expect(logShutdown).toHaveBeenCalledTimes(1); + }); + + test("honors disabled traces when an OpenTelemetry SDK is preloaded", async () => { + process.env.OPENCLAW_OTEL_PRELOADED = "1"; + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: false, metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "run.completed", + runId: "run-1", + provider: "openai", + model: "gpt-5.4", + outcome: "completed", + durationMs: 100, + }); + + expect(sdkStart).not.toHaveBeenCalled(); + expect(telemetryState.histograms.get("openclaw.run.duration_ms")?.record).toHaveBeenCalledWith( + 100, + expect.objectContaining({ + "openclaw.provider": "openai", + }), + ); + expect(telemetryState.tracer.startSpan).not.toHaveBeenCalled(); + + await service.stop?.(ctx); + expect(sdkShutdown).not.toHaveBeenCalled(); + }); + test("tears down active handles when restarted with diagnostics disabled", async () => { const service = createDiagnosticsOtelService(); const enabledCtx = createOtelContext(OTEL_TEST_ENDPOINT, { diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 876e2388977..8f08472b07d 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -49,6 +49,7 @@ const LOG_RECORD_EXPORT_FAILURE_REPORT_INTERVAL_MS = 60_000; const OTEL_LOG_RAW_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,64}$/u; const OTEL_LOG_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,96}$/u; const BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const PRELOADED_OTEL_SDK_ENV = "OPENCLAW_OTEL_PRELOADED"; type OtelContentCapturePolicy = { inputMessages: boolean; @@ -164,6 +165,10 @@ function resolveContentCapturePolicy(value: unknown): OtelContentCapturePolicy { }; } +function hasPreloadedOtelSdk(): boolean { + return process.env[PRELOADED_OTEL_SDK_ENV] === "1"; +} + function normalizeOtelContentValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOtelLogString(value, MAX_OTEL_CONTENT_ATTRIBUTE_CHARS); @@ -400,38 +405,39 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled && !metricsEnabled && !logsEnabled) { return; } + const sdkPreloaded = hasPreloadedOtelSdk(); const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: serviceName, }); - const traceUrl = resolveOtelUrl(endpoint, "v1/traces"); - const metricUrl = resolveOtelUrl(endpoint, "v1/metrics"); const logUrl = resolveOtelUrl(endpoint, "v1/logs"); - const traceExporter = tracesEnabled - ? new OTLPTraceExporter({ - ...(traceUrl ? { url: traceUrl } : {}), - ...(headers ? { headers } : {}), - }) - : undefined; + if (!sdkPreloaded && (tracesEnabled || metricsEnabled)) { + const traceUrl = resolveOtelUrl(endpoint, "v1/traces"); + const metricUrl = resolveOtelUrl(endpoint, "v1/metrics"); + const traceExporter = tracesEnabled + ? new OTLPTraceExporter({ + ...(traceUrl ? { url: traceUrl } : {}), + ...(headers ? { headers } : {}), + }) + : undefined; - const metricExporter = metricsEnabled - ? new OTLPMetricExporter({ - ...(metricUrl ? { url: metricUrl } : {}), - ...(headers ? { headers } : {}), - }) - : undefined; + const metricExporter = metricsEnabled + ? new OTLPMetricExporter({ + ...(metricUrl ? { url: metricUrl } : {}), + ...(headers ? { headers } : {}), + }) + : undefined; - const metricReader = metricExporter - ? new PeriodicExportingMetricReader({ - exporter: metricExporter, - ...(typeof otel.flushIntervalMs === "number" - ? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) } - : {}), - }) - : undefined; + const metricReader = metricExporter + ? new PeriodicExportingMetricReader({ + exporter: metricExporter, + ...(typeof otel.flushIntervalMs === "number" + ? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) } + : {}), + }) + : undefined; - if (tracesEnabled || metricsEnabled) { sdk = new NodeSDK({ resource, ...(traceExporter ? { traceExporter } : {}), @@ -452,6 +458,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ctx.logger.error(`diagnostics-otel: failed to start SDK: ${formatError(err)}`); throw err; } + } else if (sdkPreloaded && (tracesEnabled || metricsEnabled)) { + ctx.logger.info("diagnostics-otel: using preloaded OpenTelemetry SDK"); } const logSeverityMap: Record = { From 8e40bdba902c1a4836a3636f49a96b7657b439cf Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 25 Apr 2026 12:24:47 +0530 Subject: [PATCH 82/93] fix(cli): scope dev reset to active profile --- src/commands/onboard-helpers.test.ts | 41 ++++++++++++++++++++++++++++ src/commands/onboard-helpers.ts | 8 +++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index b66c48b2a85..fb07427e830 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -1,7 +1,10 @@ +import * as fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; import { + handleReset, normalizeGatewayTokenInput, openUrl, probeGatewayReachable, @@ -45,6 +48,44 @@ afterEach(() => { vi.unstubAllEnvs(); }); +describe("handleReset", () => { + it("uses active profile paths for destructive reset targets", async () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-")); + const profileStateDir = path.join(homeDir, ".openclaw-work"); + const defaultStateDir = path.join(homeDir, ".openclaw"); + const profileConfigPath = path.join(profileStateDir, "openclaw.json"); + const profileCredentialsDir = path.join(profileStateDir, "credentials"); + const profileSessionsDir = path.join(profileStateDir, "agents", "main", "sessions"); + const workspaceDir = path.join(profileStateDir, "workspace"); + const defaultCredentialsDir = path.join(defaultStateDir, "credentials"); + + fs.mkdirSync(profileCredentialsDir, { recursive: true }); + fs.mkdirSync(profileSessionsDir, { recursive: true }); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(defaultCredentialsDir, { recursive: true }); + fs.writeFileSync(profileConfigPath, "{}\n"); + + vi.stubEnv("HOME", homeDir); + vi.stubEnv("OPENCLAW_HOME", homeDir); + vi.stubEnv("OPENCLAW_PROFILE", "work"); + vi.stubEnv("OPENCLAW_STATE_DIR", profileStateDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", profileConfigPath); + + const runtime = { log: vi.fn() } as unknown as RuntimeEnv; + + await handleReset("full", workspaceDir, runtime); + + const trashedPaths = mocks.runCommandWithTimeout.mock.calls.map(([argv]) => argv[1]); + expect(trashedPaths).toEqual([ + profileConfigPath, + profileCredentialsDir, + profileSessionsDir, + workspaceDir, + ]); + expect(trashedPaths).not.toContain(defaultCredentialsDir); + }); +}); + describe("openUrl", () => { it("passes OAuth URLs to Windows FileProtocolHandler without cmd parsing", async () => { vi.stubEnv("VITEST", ""); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index b2fdff3be95..1b69d59faec 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -4,7 +4,7 @@ import { inspect } from "node:util"; import { cancel, isCancel } from "@clack/prompts"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { CONFIG_PATH } from "../config/paths.js"; +import { resolveConfigPath } from "../config/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveControlUiLinks } from "../gateway/control-ui-links.js"; @@ -21,7 +21,7 @@ import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; -import { CONFIG_DIR, shortenHomeInString, shortenHomePath, sleep } from "../utils.js"; +import { resolveConfigDir, shortenHomeInString, shortenHomePath, sleep } from "../utils.js"; import { VERSION } from "../version.js"; import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js"; export { randomToken } from "./random-token.js"; @@ -205,11 +205,11 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis } export async function handleReset(scope: ResetScope, workspaceDir: string, runtime: RuntimeEnv) { - await moveToTrash(CONFIG_PATH, runtime); + await moveToTrash(resolveConfigPath(), runtime); if (scope === "config") { return; } - await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime); + await moveToTrash(path.join(resolveConfigDir(), "credentials"), runtime); await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime); if (scope === "full") { await moveToTrash(workspaceDir, runtime); From d8a70a7e490da7297dbba98b0b2068c21758c3b1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:56:26 -0700 Subject: [PATCH 83/93] docs(changelog): add missing entries for 3 external-contributor PRs Three external-contributor commits from the last day landed without CHANGELOG entries: - Alex Fries (#68286, @ajfonthemove): hybrid memory search component scores. Added under Unreleased > Changes (feat). - Charles Dusek (#51267, @cgdusek): malformed tool-result text-block guard. Added under Unreleased > Fixes. - Jerome Benoit (#59935, @jerome-benoit): Nix Home Manager daemon PATH support. Added under Unreleased > Fixes. Also drop a duplicate raw-subject changelog line for #66884 that restated alexlomt's already-formatted entry one line above. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c929631c9..7af5949e70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,15 @@ Docs: https://docs.openclaw.ai - Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev. +- Memory-core/hybrid search: expose raw `vectorScore` and `textScore` alongside the combined `score` on hybrid memory search results, so callers can inspect vector-versus-text retrieval contribution before temporal decay or MMR reordering. Fixes #68166. (#68286) Thanks @ajfonthemove. ### Fixes - MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106 and #71110. - CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. -- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. +- Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed `{ type: "text" }` blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek. +- Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit. ## 2026.4.24 (Unreleased) From dfa52aaab0f4ca5a9416c1eea21b20b63631e85f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 00:00:02 -0700 Subject: [PATCH 84/93] docs(changelog): clean up top Unreleased section formatting and dedupe - Remove duplicate #66884 alexlomt entry from top Unreleased > Fixes; the canonical entry already lives under 2026.4.24 (Unreleased) per Mason Huang's earlier 'move #66884 entry to 2026.4.24' commit. - Reflow the wrapped 3-line Tool Access bullet (#71405) onto a single line so it matches every other bullet in the section. --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af5949e70f..003216ab968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,12 @@ Docs: https://docs.openclaw.ai ### Changes - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#70424) Thanks @jlapenna. -- Control UI: refine the agent Tool Access panel with compact live-tool chips, - collapsible tool groups, direct per-tool toggles, and clearer runtime/source - provenance. (#71405) Thanks @BunsDev. +- Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev. - Memory-core/hybrid search: expose raw `vectorScore` and `textScore` alongside the combined `score` on hybrid memory search results, so callers can inspect vector-versus-text retrieval contribution before temporal decay or MMR reordering. Fixes #68166. (#68286) Thanks @ajfonthemove. ### Fixes - MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106 and #71110. -- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed `{ type: "text" }` blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek. - Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit. From 459d277076488d5833a181cca7d8bf5c0f9696dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:04:29 +0100 Subject: [PATCH 85/93] feat(google-meet): add latest conference command --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 1 + extensions/google-meet/index.test.ts | 79 ++++++++++++++++++++++++++++ extensions/google-meet/index.ts | 32 +++++++++++ extensions/google-meet/src/cli.ts | 45 ++++++++++++++++ extensions/google-meet/src/meet.ts | 46 +++++++++++++--- 6 files changed, 198 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 003216ab968..4d5efd3a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. - Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts and transcript entries, smart notes, and participant sessions. Thanks @steipete. - Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet latest` plus matching tool/gateway actions to find the newest conference record for a meeting. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 89c9e39945e..0225f07634e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -638,6 +638,7 @@ openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij If you already know the conference record id, address it directly: ```bash +openclaw googlemeet latest --meeting https://meet.google.com/abc-defg-hij openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json ``` diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 2ca27049171..e8288c23ed8 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -14,6 +14,7 @@ import { createGoogleMeetSpace, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; @@ -343,6 +344,7 @@ describe("google-meet plugin", () => { "setup_status", "resolve_space", "preflight", + "latest", "artifacts", "attendance", "recover_current_tab", @@ -496,6 +498,32 @@ describe("google-meet plugin", () => { ); }); + it("fetches only the latest Meet conference record for a meeting", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchLatestGoogleMeetConferenceRecord({ + accessToken: "token", + meeting: "abc-defg-hij", + }), + ).resolves.toMatchObject({ + input: "abc-defg-hij", + space: { name: "spaces/abc-defg-hij" }, + conferenceRecord: { name: "conferenceRecords/rec-1" }, + }); + + const listCall = fetchMock.mock.calls.find(([input]) => { + const url = requestUrl(input); + return url.pathname === "/v2/conferenceRecords"; + }); + if (!listCall) { + throw new Error("Expected conferenceRecords.list fetch call"); + } + const listUrl = requestUrl(listCall[0]); + expect(listUrl.searchParams.get("pageSize")).toBe("1"); + expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); + }); + it("lists Meet attendance rows with participant sessions", async () => { const fetchMock = stubMeetArtifactsApi(); @@ -695,6 +723,26 @@ describe("google-meet plugin", () => { expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]); }); + it("reports the latest conference record through the tool", async () => { + stubMeetArtifactsApi(); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { conferenceRecord?: { name?: string } } }>; + }; + + const result = await tool.execute("id", { + action: "latest", + accessToken: "token", + expiresAt: Date.now() + 120_000, + meeting: "abc-defg-hij", + }); + + expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" }); + }); + it("fails setup status when the configured Chrome node is not connected", async () => { const { tools } = setup( { @@ -918,6 +966,37 @@ describe("google-meet plugin", () => { } }); + it("CLI latest prints the latest conference record", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--meeting", + "abc-defg-hij", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + it("CLI artifacts writes markdown output", async () => { stubMeetArtifactsApi(); const program = new Command(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index d875dccfaa0..8190d305113 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -19,6 +19,7 @@ import { buildGoogleMeetPreflightReport, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, } from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; @@ -150,6 +151,7 @@ const GoogleMeetToolSchema = Type.Object({ "setup_status", "resolve_space", "preflight", + "latest", "artifacts", "attendance", "recover_current_tab", @@ -388,6 +390,26 @@ export default definePluginEntry({ }, ); + api.registerGatewayMethod( + "googlemeet.latest", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const meeting = resolveMeetingInput(config, raw.meeting); + const token = await resolveGoogleMeetTokenFromParams(config, raw); + respond( + true, + await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + api.registerGatewayMethod( "googlemeet.artifacts", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -563,6 +585,16 @@ export default definePluginEntry({ }), ); } + case "latest": { + const meeting = resolveMeetingInput(config, raw.meeting); + const token = await resolveGoogleMeetTokenFromParams(config, raw); + return json( + await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting, + }), + ); + } case "artifacts": { const resolved = await resolveArtifactQueryFromParams(config, raw); return json( diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 726b3bfa7f1..41d1802e857 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -8,9 +8,11 @@ import { createGoogleMeetSpace, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, type GoogleMeetArtifactsResult, type GoogleMeetAttendanceResult, + type GoogleMeetLatestConferenceRecordResult, } from "./meet.js"; import { buildGoogleMeetAuthUrl, @@ -547,6 +549,18 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { } } +function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRecordResult): void { + writeStdoutLine("input: %s", result.input); + writeStdoutLine("space: %s", result.space.name); + if (!result.conferenceRecord) { + writeStdoutLine("conference record: none"); + return; + } + writeStdoutLine("conference record: %s", result.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime)); +} + function pushMarkdownLine(lines: string[], text = ""): void { lines.push(text); } @@ -974,6 +988,37 @@ export function registerGoogleMeetCli(params: { } }); + root + .command("latest") + .description("Find the latest Meet conference record for a meeting") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeLatestConferenceRecordSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("artifacts") .description("List Meet conference records and available participant/artifact metadata") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 8cace830bfd..ffebce9c0fc 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -112,6 +112,12 @@ export type GoogleMeetArtifactsResult = { artifacts: GoogleMeetArtifactsEntry[]; }; +export type GoogleMeetLatestConferenceRecordResult = { + input: string; + space: GoogleMeetSpace; + conferenceRecord?: GoogleMeetConferenceRecord; +}; + export type GoogleMeetAttendanceRow = { conferenceRecord: string; participant: string; @@ -257,6 +263,7 @@ async function listGoogleMeetCollection(params: { path: string; collectionKey: string; query?: Record; + maxItems?: number; auditContext: string; errorPrefix: string; }): Promise { @@ -270,13 +277,17 @@ async function listGoogleMeetCollection(params: { auditContext: params.auditContext, errorPrefix: params.errorPrefix, }); - items.push( - ...assertResourceArray( - payload[params.collectionKey], - params.collectionKey, - params.errorPrefix, - ), + const pageItems = assertResourceArray( + payload[params.collectionKey], + params.collectionKey, + params.errorPrefix, ); + const remaining = + typeof params.maxItems === "number" ? Math.max(params.maxItems - items.length, 0) : undefined; + items.push(...(remaining === undefined ? pageItems : pageItems.slice(0, remaining))); + if (typeof params.maxItems === "number" && items.length >= params.maxItems) { + break; + } pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined; } while (pageToken); return items; @@ -370,6 +381,7 @@ export async function listGoogleMeetConferenceRecords(params: { accessToken: string; meeting?: string; pageSize?: number; + maxItems?: number; }): Promise { const filter = params.meeting ? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"` @@ -382,11 +394,33 @@ export async function listGoogleMeetConferenceRecords(params: { pageSize: params.pageSize, filter, }, + maxItems: params.maxItems, auditContext: "google-meet.conferenceRecords.list", errorPrefix: "Google Meet conferenceRecords.list", }); } +export async function fetchLatestGoogleMeetConferenceRecord(params: { + accessToken: string; + meeting: string; +}): Promise { + const space = await fetchGoogleMeetSpace({ + accessToken: params.accessToken, + meeting: params.meeting, + }); + const [conferenceRecord] = await listGoogleMeetConferenceRecords({ + accessToken: params.accessToken, + meeting: space.name, + pageSize: 1, + maxItems: 1, + }); + return { + input: params.meeting, + space, + ...(conferenceRecord ? { conferenceRecord } : {}), + }; +} + export async function listGoogleMeetParticipants(params: { accessToken: string; conferenceRecord: string; From 4005a4f731eceea8e31153e5cc5fab3410a91dbe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:07:48 +0100 Subject: [PATCH 86/93] feat(google-meet): default artifacts to latest record --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 4 ++++ extensions/google-meet/index.test.ts | 26 ++++++++++++++++++++++++-- extensions/google-meet/index.ts | 11 +++++++++++ extensions/google-meet/src/cli.ts | 7 +++++++ extensions/google-meet/src/meet.ts | 6 +++++- 6 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5efd3a86d..9d3bca10e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts and transcript entries, smart notes, and participant sessions. Thanks @steipete. - Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete. - Plugins/Google Meet: add `googlemeet latest` plus matching tool/gateway actions to find the newest conference record for a meeting. Thanks @steipete. +- Plugins/Google Meet: make meeting-based artifact and attendance lookups use the latest conference record by default, with `--all-conference-records` for full history. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 0225f07634e..5c442b25be9 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -635,6 +635,10 @@ openclaw googlemeet artifacts --meeting https://meet.google.com/abc-defg-hij openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij ``` +With `--meeting`, `artifacts` and `attendance` use the latest conference record +by default. Pass `--all-conference-records` when you want every retained record +for that meeting. + If you already know the conference record id, address it directly: ```bash diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index e8288c23ed8..0ac206e21c5 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -439,7 +439,7 @@ describe("google-meet plugin", () => { ); }); - it("lists Meet artifact metadata for conference records", async () => { + it("lists Meet artifact metadata for the latest conference record by default", async () => { const fetchMock = stubMeetArtifactsApi(); await expect( @@ -483,7 +483,7 @@ describe("google-meet plugin", () => { } const listUrl = requestUrl(listCall[0]); expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); - expect(listUrl.searchParams.get("pageSize")).toBe("2"); + expect(listUrl.searchParams.get("pageSize")).toBe("1"); expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( expect.objectContaining({ url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2", @@ -498,6 +498,28 @@ describe("google-meet plugin", () => { ); }); + it("keeps all conference records available when requested", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await fetchGoogleMeetArtifacts({ + accessToken: "token", + meeting: "abc-defg-hij", + pageSize: 2, + allConferenceRecords: true, + }); + + const listCall = fetchMock.mock.calls.find(([input]) => { + const url = requestUrl(input); + return url.pathname === "/v2/conferenceRecords"; + }); + if (!listCall) { + throw new Error("Expected conferenceRecords.list fetch call"); + } + const listUrl = requestUrl(listCall[0]); + expect(listUrl.searchParams.get("pageSize")).toBe("2"); + expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); + }); + it("fetches only the latest Meet conference record for a meeting", async () => { const fetchMock = stubMeetArtifactsApi(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 8190d305113..01b2b57961c 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -191,6 +191,12 @@ const GoogleMeetToolSchema = Type.Object({ includeTranscriptEntries: Type.Optional( Type.Boolean({ description: "For artifacts, include structured transcript entries" }), ), + includeAllConferenceRecords: Type.Optional( + Type.Boolean({ + description: + "For artifacts or attendance with meeting input, fetch all conference records instead of only the latest.", + }), + ), accessToken: Type.Optional(Type.String({ description: "Access token override" })), refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), @@ -277,6 +283,7 @@ async function resolveArtifactQueryFromParams( conferenceRecord, pageSize: resolveOptionalPositiveInteger(raw.pageSize), includeTranscriptEntries: raw.includeTranscriptEntries !== false, + allConferenceRecords: raw.includeAllConferenceRecords === true, }; } @@ -424,6 +431,7 @@ export default definePluginEntry({ conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, }), ); } catch (err) { @@ -445,6 +453,7 @@ export default definePluginEntry({ meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, }), ); } catch (err) { @@ -604,6 +613,7 @@ export default definePluginEntry({ conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, }), ); } @@ -615,6 +625,7 @@ export default definePluginEntry({ meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, }), ); } diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 41d1802e857..0e752f1600d 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -55,6 +55,7 @@ type MeetArtifactOptions = ResolveSpaceOptions & { conferenceRecord?: string; pageSize?: string; transcriptEntries?: boolean; + allConferenceRecords?: boolean; format?: "summary" | "markdown"; output?: string; }; @@ -445,6 +446,7 @@ function resolveArtifactTokenOptions( expiresAt?: number; pageSize?: number; includeTranscriptEntries?: boolean; + allConferenceRecords?: boolean; } { const meeting = options.meeting?.trim() || config.defaults.meeting; const conferenceRecord = options.conferenceRecord?.trim(); @@ -463,6 +465,7 @@ function resolveArtifactTokenOptions( expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, pageSize: parseOptionalNumber(options.pageSize), includeTranscriptEntries: options.transcriptEntries !== false, + allConferenceRecords: Boolean(options.allConferenceRecords), }; } @@ -1030,6 +1033,7 @@ export function registerGoogleMeetCli(params: { .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") + .option("--all-conference-records", "Fetch every conference record for --meeting") .option("--no-transcript-entries", "Skip structured transcript entry lookup") .option("--format ", "Output format: summary or markdown", "summary") .option("--output ", "Write output to a file instead of stdout") @@ -1043,6 +1047,7 @@ export function registerGoogleMeetCli(params: { conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, }); if (options.json) { await writeCliOutput( @@ -1083,6 +1088,7 @@ export function registerGoogleMeetCli(params: { .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") + .option("--all-conference-records", "Fetch every conference record for --meeting") .option("--format ", "Output format: summary or markdown", "summary") .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) @@ -1094,6 +1100,7 @@ export function registerGoogleMeetCli(params: { meeting: resolved.meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, }); if (options.json) { await writeCliOutput( diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index ffebce9c0fc..bea42bd33f0 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -532,6 +532,7 @@ async function resolveConferenceRecordQuery(params: { meeting?: string; conferenceRecord?: string; pageSize?: number; + allConferenceRecords?: boolean; }): Promise<{ input?: string; space?: GoogleMeetSpace; @@ -557,7 +558,8 @@ async function resolveConferenceRecordQuery(params: { const conferenceRecords = await listGoogleMeetConferenceRecords({ accessToken: params.accessToken, meeting: space.name, - pageSize: params.pageSize, + pageSize: params.allConferenceRecords ? params.pageSize : 1, + maxItems: params.allConferenceRecords ? undefined : 1, }); return { input: params.meeting, @@ -572,6 +574,7 @@ export async function fetchGoogleMeetArtifacts(params: { conferenceRecord?: string; pageSize?: number; includeTranscriptEntries?: boolean; + allConferenceRecords?: boolean; }): Promise { const resolved = await resolveConferenceRecordQuery(params); const artifacts = await Promise.all( @@ -652,6 +655,7 @@ export async function fetchGoogleMeetAttendance(params: { meeting?: string; conferenceRecord?: string; pageSize?: number; + allConferenceRecords?: boolean; }): Promise { const resolved = await resolveConferenceRecordQuery(params); const nestedRows = await Promise.all( From 9895ecead3e5fad179c11a7a741f720fd59bea10 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 00:09:12 -0700 Subject: [PATCH 87/93] fix(memory): keep llama runtime optional (#71425) * fix(memory): keep llama runtime optional * fix(memory): harden optional llama runtime guard --- CHANGELOG.md | 1 + docs/concepts/memory-builtin.md | 7 +- docs/concepts/memory-qmd.md | 3 +- docs/concepts/memory-search.md | 4 +- .../src/memory/provider-adapters.ts | 10 +- package.json | 8 - pnpm-lock.yaml | 714 ------------------ scripts/lib/dependency-ownership.json | 5 - scripts/openclaw-npm-release-check.ts | 25 +- test/openclaw-npm-release-check.test.ts | 38 +- 10 files changed, 69 insertions(+), 746 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3bca10e58..7f5c933caa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz. - Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. +- Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc. - Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. - WebChat/sessions: keep runtime-only prompt context out of visible transcript history and scrub legacy wrappers from session history surfaces. Thanks @91wan. - Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare. diff --git a/docs/concepts/memory-builtin.md b/docs/concepts/memory-builtin.md index 602f1f8b74d..7b73f1a9253 100644 --- a/docs/concepts/memory-builtin.md +++ b/docs/concepts/memory-builtin.md @@ -38,8 +38,9 @@ To set a provider explicitly: Without an embedding provider, only keyword search is available. -To force the built-in local embedding provider, point `local.modelPath` at a -GGUF file: +To force the built-in local embedding provider, install the optional +`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath` +at a GGUF file: ```json5 { @@ -66,7 +67,7 @@ GGUF file: | Voyage | `voyage` | Yes | | | Mistral | `mistral` | Yes | | | Ollama | `ollama` | No | Local, set explicitly | -| Local | `local` | Yes (first) | GGUF model, ~0.6 GB download | +| Local | `local` | Yes (first) | Optional `node-llama-cpp` runtime | Auto-detection picks the first provider whose API key can be resolved, in the order shown. Set `memorySearch.provider` to override. diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index d550bc5b586..7fef71b0585 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -15,7 +15,8 @@ binary, and can index content beyond your workspace memory files. - **Reranking and query expansion** for better recall. - **Index extra directories** -- project docs, team notes, anything on disk. - **Index session transcripts** -- recall earlier conversations. -- **Fully local** -- runs via Bun + node-llama-cpp, auto-downloads GGUF models. +- **Fully local** -- runs with the optional node-llama-cpp runtime package and + auto-downloads GGUF models. - **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the builtin engine seamlessly. diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index 4cd0a889ce2..8defdff4400 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -29,8 +29,8 @@ explicitly: } ``` -For local embeddings with no API key, use `provider: "local"` (requires -node-llama-cpp). +For local embeddings with no API key, install the optional `node-llama-cpp` +runtime package next to OpenClaw and use `provider: "local"`. ## Supported providers diff --git a/extensions/memory-core/src/memory/provider-adapters.ts b/extensions/memory-core/src/memory/provider-adapters.ts index 3cdc631676d..61fda052df0 100644 --- a/extensions/memory-core/src/memory/provider-adapters.ts +++ b/extensions/memory-core/src/memory/provider-adapters.ts @@ -11,6 +11,10 @@ import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars"; import { formatErrorMessage } from "../dreaming-shared.js"; import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js"; +const NODE_LLAMA_CPP_RUNTIME_PACKAGE = "node-llama-cpp"; +const NODE_LLAMA_CPP_RUNTIME_VERSION = "3.18.1"; +const NODE_LLAMA_CPP_INSTALL_SPEC = `${NODE_LLAMA_CPP_RUNTIME_PACKAGE}@${NODE_LLAMA_CPP_RUNTIME_VERSION}`; + export type BuiltinMemoryEmbeddingProviderDoctorMetadata = { providerId: string; authProviderId: string; @@ -24,7 +28,7 @@ function isNodeLlamaCppMissing(err: unknown): boolean { return false; } const code = (err as Error & { code?: unknown }).code; - return code === "ERR_MODULE_NOT_FOUND" && err.message.includes("node-llama-cpp"); + return code === "ERR_MODULE_NOT_FOUND" && err.message.includes(NODE_LLAMA_CPP_RUNTIME_PACKAGE); } function listRemoteEmbeddingSetupHints(): string[] { @@ -55,9 +59,9 @@ function formatLocalSetupError(err: unknown): string { "To enable local embeddings:", "1) Use Node 24 (recommended for installs/updates; Node 22 LTS, currently 22.14+, remains supported)", missing - ? "2) Reinstall OpenClaw (this should install node-llama-cpp): npm i -g openclaw@latest" + ? `2) Install optional local embedding runtime next to OpenClaw: npm i -g ${NODE_LLAMA_CPP_INSTALL_SPEC}` : null, - "3) If you use pnpm: pnpm approve-builds (select node-llama-cpp), then pnpm rebuild node-llama-cpp", + `3) If you use pnpm: pnpm approve-builds (select ${NODE_LLAMA_CPP_RUNTIME_PACKAGE}), then pnpm rebuild ${NODE_LLAMA_CPP_RUNTIME_PACKAGE}`, ...listRemoteEmbeddingSetupHints(), ] .filter(Boolean) diff --git a/package.json b/package.json index 072695ffaac..bb990ce2917 100644 --- a/package.json +++ b/package.json @@ -1658,14 +1658,6 @@ "typescript": "^6.0.3", "vitest": "^4.1.5" }, - "peerDependencies": { - "node-llama-cpp": "3.18.1" - }, - "peerDependenciesMeta": { - "node-llama-cpp": { - "optional": true - } - }, "overrides": { "axios": "1.15.0", "follow-redirects": "1.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d82fceca9b5..470f34fb625 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,9 +108,6 @@ importers: markdown-it: specifier: 14.1.1 version: 14.1.1 - node-llama-cpp: - specifier: 3.18.1 - version: 3.18.1(typescript@6.0.3) openai: specifier: ^6.34.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) @@ -2218,10 +2215,6 @@ packages: peerDependencies: hono: 4.12.14 - '@huggingface/jinja@0.5.6': - resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} - engines: {node: '>=18'} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2531,12 +2524,6 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@kwsites/file-exists@1.1.1': - resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} - - '@kwsites/promise-deferred@1.1.1': - resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@lancedb/lancedb-darwin-arm64@0.27.2': resolution: {integrity: sha512-+XM68V/Rou8kKWDnUeKvg9ChKS0zGeQC2sKAop+06Ty4LwIjEGkeYBYrK0vMhZkBN5EFaOjTOp8E8hGQxdFwXA==} engines: {node: '>= 18'} @@ -2880,90 +2867,6 @@ packages: '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@node-llama-cpp/linux-arm64@3.18.1': - resolution: {integrity: sha512-rXMgZxUay78FOJV/fJ67apYP9eElH5jd4df5YRKPlLhLHHchuOSyDn+qtyW/L/EnPzpogoLkmULqCkdXU39XsQ==} - engines: {node: '>=20.0.0'} - cpu: [arm64, x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/linux-armv7l@3.18.1': - resolution: {integrity: sha512-BrJL2cGo0pN5xd5nw+CzTn2rFMpz9MJyZZPUY81ptGkF2uIuXT2hdCVh56i9ImQrTwBfq1YcZL/l/Qe/1+HR/Q==} - engines: {node: '>=20.0.0'} - cpu: [arm, x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/linux-x64-cuda-ext@3.18.1': - resolution: {integrity: sha512-VqyKhAVHPCpFzh0f1koCBgpThL+04QOXwv0oDQ8s8YcpfMMOXQlBhTB0plgTh0HrPExoObfTS4ohkrbyGgmztQ==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/linux-x64-cuda@3.18.1': - resolution: {integrity: sha512-qOaYP4uwsUoBHQ/7xSOvyJIuXapS57Al+Sudgi00f96ldNZLKe1vuSGptAi5LTM2lIj66PKm6h8PlRWctwsZ2g==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/linux-x64-vulkan@3.18.1': - resolution: {integrity: sha512-SIaNTK5pUPhwJD0gmiQfHa8OrRctVMmnqu+slJrz2Mzgg/XrwFndJlS9hvc+jSjTXCouwf7sYeQaaJWvQgBh/A==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/linux-x64@3.18.1': - resolution: {integrity: sha512-tRmWcsyvAcqJHQHXHsaOkx6muGbcirA9nRdNgH6n7bjGUw4VuoBD3dChyNF3/Ktt7ohB9kz+XhhyZjbDHpXyMA==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-llama-cpp/mac-arm64-metal@3.18.1': - resolution: {integrity: sha512-cyZTdsUMlvuRlGmkkoBbN3v/DT6NuruEqoQYd9CqIrPyLa1xLNBTSKIZ9SgRnw23iCOj4URfITvRP+2pu63LuQ==} - engines: {node: '>=20.0.0'} - cpu: [arm64, x64] - os: [darwin] - - '@node-llama-cpp/mac-x64@3.18.1': - resolution: {integrity: sha512-GfCPgdltaIpBhEnQ7WfsrRXrZO9r9pBtDUAQMXRuJwOPP5q7xKrQZUXI6J6mpc8tAG0//CTIuGn4hTKoD/8V8w==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [darwin] - - '@node-llama-cpp/win-arm64@3.18.1': - resolution: {integrity: sha512-S05YUzBMVSRS5KNbOS26cDYugeQHqogI3uewtTUBVC0tPbTHRSKjsdicmgWru1eNAry399LWWhzOf/3St/qsAw==} - engines: {node: '>=20.0.0'} - cpu: [arm64, x64] - os: [win32] - - '@node-llama-cpp/win-x64-cuda-ext@3.18.1': - resolution: {integrity: sha512-u0FzJBQsJA355ksKERxwPJhlcWl3ZJSNkU2ZUwDEiKNOCbv3ybvSCIEyDvB63wdtkfVUuCRJWijZnpDZxrCGqg==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - - '@node-llama-cpp/win-x64-cuda@3.18.1': - resolution: {integrity: sha512-drgJmBhnxGQtB/SLo4sf4PPSuxRv3MdNP0FF6rKPY9TtzEOV293bRQyYEu/JYwvXfVApAIsRaJUTGvCkA9Qobw==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - - '@node-llama-cpp/win-x64-vulkan@3.18.1': - resolution: {integrity: sha512-PjmxrnPToi7y0zlP7l+hRIhvOmuEv94P6xZ11vjqICEJu8XdAJpvTfPKgDW4W0p0v4+So8ZiZYLUuwIHcsseyQ==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - - '@node-llama-cpp/win-x64@3.18.1': - resolution: {integrity: sha512-QLDVphPl+YDI+x/VYYgIV1N9g0GMXk3PqcoopOUG3cBRUtce7FO+YX903YdRJezs4oKbIp8YaO+xYBgeUSqhpA==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3481,62 +3384,6 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} - '@rolldown/binding-android-arm64@1.0.0-rc.16': resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4119,10 +3966,6 @@ packages: resolution: {integrity: sha512-npKV69U8JYpMLZiqhUWf9dmd9Esjy36o7CxxGUgoLRS4ZmTLuIKqKfFnZuLrx6D5Mmb+D9ARCDR7qXO1QJV8DQ==} engines: {node: '>=18'} - '@tinyhttp/content-disposition@2.2.4': - resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} - engines: {node: '>=12.17.0'} - '@tloncorp/tlon-skill-darwin-arm64@0.3.5': resolution: {integrity: sha512-GZQyV0KswArmGU/XLbDTPEXKvs7w3iLXMzxSlh19LXUbQVDViJs35gSPh/ZTmDkBXGGf6hPrBLXRKvc20NuWNg==} cpu: [arm64] @@ -4506,10 +4349,6 @@ packages: another-json@0.2.0: resolution: {integrity: sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==} - ansi-escapes@6.2.1: - resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} - engines: {node: '>=14.16'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4522,10 +4361,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -4587,9 +4422,6 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -4809,9 +4641,6 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chmodrp@1.0.2: - resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4820,30 +4649,14 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-spinners@3.4.0: - resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} - engines: {node: '>=18.20'} - cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -4858,11 +4671,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cmake-js@8.0.0: - resolution: {integrity: sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - codec-parser@2.5.0: resolution: {integrity: sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ==} @@ -4899,10 +4707,6 @@ packages: resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} engines: {node: '>=12.20.0'} - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -5006,10 +4810,6 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -5120,9 +4920,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5149,10 +4946,6 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - env-var@7.5.0: - resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==} - engines: {node: '>=10'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -5340,14 +5133,6 @@ packages: resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} engines: {node: '>=22'} - filename-reserved-regex@3.0.0: - resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - filenamify@6.0.0: - resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} - engines: {node: '>=16'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -5646,9 +5431,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5664,11 +5446,6 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - ipull@3.9.5: - resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==} - engines: {node: '>=18.0.0'} - hasBin: true - ircv3@0.33.1: resolution: {integrity: sha512-FPUj/q6zsLgIX6QDdLMjPRBObw0xK+k6eiI62dcTRwdl5aezYV0nuMhpmafyHOD6ZDqfw8DW4ayrvDfmYO65JQ==} @@ -5704,10 +5481,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -5720,10 +5493,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - is-network-error@1.3.1: resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} engines: {node: '>=16'} @@ -5753,10 +5522,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -5767,10 +5532,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@4.0.0: - resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} - engines: {node: '>=20'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5893,12 +5654,6 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} - lifecycle-utils@2.1.0: - resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==} - - lifecycle-utils@3.1.1: - resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -6007,9 +5762,6 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.identity@3.0.0: resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} @@ -6040,10 +5792,6 @@ packages: lodash.pickby@4.6.0: resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} - log-symbols@7.0.1: - resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} - engines: {node: '>=18'} - loglevel@1.9.2: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} @@ -6054,10 +5802,6 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lowdb@7.0.1: - resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} - engines: {node: '>=18'} - lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -6292,17 +6036,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -6340,11 +6077,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} - engines: {node: ^18 || >=20} - hasBin: true - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6353,17 +6085,10 @@ packages: resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} engines: {node: '>= 0.4.0'} - node-addon-api@8.6.0: - resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} - engines: {node: ^18 || ^20 || >= 21} - node-addon-api@8.7.0: resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} engines: {node: ^18 || ^20 || >= 21} - node-api-headers@1.8.0: - resolution: {integrity: sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ==} - node-downloader-helper@2.1.11: resolution: {integrity: sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==} engines: {node: '>=14.18'} @@ -6386,16 +6111,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-llama-cpp@3.18.1: - resolution: {integrity: sha512-w0zfuy/IKS2fhrbed5SylZDXJHTVz4HnkwZ4UrFPgSNwJab3QIPwIl4lyCKHHy9flLrtxsAuV5kXfH3HZ6bb8w==} - engines: {node: '>=20.0.0'} - hasBin: true - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - node-readable-to-web-readable-stream@0.4.2: resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} @@ -6470,10 +6185,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -6519,10 +6230,6 @@ packages: opusscript@0.1.1: resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} - ora@9.3.0: - resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} - engines: {node: '>=20'} - osc-progress@0.3.0: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} @@ -6626,14 +6333,6 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-ms@3.0.0: - resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==} - engines: {node: '>=12'} - - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -6744,18 +6443,6 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} - pretty-bytes@6.1.1: - resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} - engines: {node: ^14.13.1 || >=16.0.0} - - pretty-ms@8.0.0: - resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} - engines: {node: '>=14.16'} - - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} - engines: {node: '>=18'} - prism-media@1.3.5: resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==} peerDependencies: @@ -6904,10 +6591,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -7003,10 +6686,6 @@ packages: engines: {node: '>= 0.4'} hasBin: true - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -7162,10 +6841,6 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - signal-polyfill@0.2.2: resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} @@ -7178,9 +6853,6 @@ packages: resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==} engines: {node: '>=16.11.0'} - simple-git@3.33.0: - resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} - simple-xml-to-json@1.2.7: resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} engines: {node: '>=20.12.2'} @@ -7200,17 +6872,6 @@ packages: engines: {node: '>=18'} hasBin: true - sleep-promise@9.1.0: - resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -7296,18 +6957,6 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - stdin-discarder@0.3.1: - resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} - engines: {node: '>=18'} - - stdout-update@4.0.1: - resolution: {integrity: sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==} - engines: {node: '>=16.0.0'} - - steno@4.0.2: - resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} - engines: {node: '>=18'} - streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -7315,14 +6964,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} - engines: {node: '>=20'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -7344,10 +6985,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -7614,9 +7251,6 @@ packages: synckit: optional: true - url-join@4.0.1: - resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} - url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} @@ -7630,10 +7264,6 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true - validate-npm-package-name@7.0.2: - resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} - engines: {node: ^20.17.0 || >=22.9.0} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -7766,11 +7396,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@6.0.1: - resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -9120,8 +8745,6 @@ snapshots: dependencies: hono: 4.12.14 - '@huggingface/jinja@0.5.6': {} - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -9504,14 +9127,6 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@kwsites/promise-deferred@1.1.1': {} - '@lancedb/lancedb-darwin-arm64@0.27.2': optional: true @@ -9919,45 +9534,6 @@ snapshots: '@nodable/entities@2.1.0': {} - '@node-llama-cpp/linux-arm64@3.18.1': - optional: true - - '@node-llama-cpp/linux-armv7l@3.18.1': - optional: true - - '@node-llama-cpp/linux-x64-cuda-ext@3.18.1': - optional: true - - '@node-llama-cpp/linux-x64-cuda@3.18.1': - optional: true - - '@node-llama-cpp/linux-x64-vulkan@3.18.1': - optional: true - - '@node-llama-cpp/linux-x64@3.18.1': - optional: true - - '@node-llama-cpp/mac-arm64-metal@3.18.1': - optional: true - - '@node-llama-cpp/mac-x64@3.18.1': - optional: true - - '@node-llama-cpp/win-arm64@3.18.1': - optional: true - - '@node-llama-cpp/win-x64-cuda-ext@3.18.1': - optional: true - - '@node-llama-cpp/win-x64-cuda@3.18.1': - optional: true - - '@node-llama-cpp/win-x64-vulkan@3.18.1': - optional: true - - '@node-llama-cpp/win-x64@3.18.1': - optional: true - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10392,42 +9968,6 @@ snapshots: dependencies: quansync: 1.0.0 - '@reflink/reflink-darwin-arm64@0.1.19': - optional: true - - '@reflink/reflink-darwin-x64@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-musl@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-musl@0.1.19': - optional: true - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - optional: true - - '@reflink/reflink-win32-x64-msvc@0.1.19': - optional: true - - '@reflink/reflink@0.1.19': - optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 - optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.16': optional: true @@ -11068,8 +10608,6 @@ snapshots: '@thi.ng/errors@2.6.8': optional: true - '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/tlon-skill-darwin-arm64@0.3.5': optional: true @@ -11526,8 +11064,6 @@ snapshots: another-json@0.2.0: {} - ansi-escapes@6.2.1: {} - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -11536,8 +11072,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.3: {} - ansis@4.2.0: {} any-base@1.1.0: {} @@ -11599,10 +11133,6 @@ snapshots: dependencies: tslib: 2.8.1 - async-retry@1.3.3: - dependencies: - retry: 0.13.1 - asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -11807,22 +11337,14 @@ snapshots: character-reference-invalid@2.0.1: {} - chmodrp@1.0.2: {} - chokidar@5.0.0: dependencies: readdirp: 5.0.0 chownr@3.0.0: {} - ci-info@4.4.0: {} - cjs-module-lexer@2.2.0: {} - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -11832,10 +11354,6 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 - cli-spinners@2.9.2: {} - - cli-spinners@3.4.0: {} - cli-table3@0.6.5: dependencies: string-width: 4.2.3 @@ -11860,20 +11378,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cmake-js@8.0.0: - dependencies: - debug: 4.4.3 - fs-extra: 11.3.4 - node-api-headers: 1.8.0 - rc: 1.2.8 - semver: 7.7.4 - tar: 7.5.13 - url-join: 4.0.1 - which: 6.0.1 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - codec-parser@2.5.0: optional: true @@ -11910,8 +11414,6 @@ snapshots: table-layout: 4.1.1 typical: 7.3.0 - commander@10.0.1: {} - commander@14.0.3: {} commander@5.1.0: {} @@ -11995,8 +11497,6 @@ snapshots: dependencies: character-entities: 2.0.2 - deep-extend@0.6.0: {} - default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -12088,8 +11588,6 @@ snapshots: ee-first@1.1.1: {} - emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} empathic@2.0.0: {} @@ -12106,8 +11604,6 @@ snapshots: entities@8.0.0: {} - env-var@7.5.0: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -12366,12 +11862,6 @@ snapshots: transitivePeerDependencies: - supports-color - filename-reserved-regex@3.0.0: {} - - filenamify@6.0.0: - dependencies: - filename-reserved-regex: 3.0.0 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -12793,8 +12283,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - inline-style-parser@0.2.7: {} ip-address@10.1.0: {} @@ -12803,30 +12291,6 @@ snapshots: ipaddr.js@2.3.0: {} - ipull@3.9.5: - dependencies: - '@tinyhttp/content-disposition': 2.2.4 - async-retry: 1.3.3 - chalk: 5.6.2 - ci-info: 4.4.0 - cli-spinners: 2.9.2 - commander: 10.0.1 - eventemitter3: 5.0.4 - filenamify: 6.0.0 - fs-extra: 11.3.4 - is-unicode-supported: 2.1.0 - lifecycle-utils: 2.1.0 - lodash.debounce: 4.0.8 - lowdb: 7.0.1 - pretty-bytes: 6.1.1 - pretty-ms: 8.0.0 - sleep-promise: 9.1.0 - slice-ansi: 7.1.2 - stdout-update: 4.0.1 - strip-ansi: 7.2.0 - optionalDependencies: - '@reflink/reflink': 0.1.19 - ircv3@0.33.1: dependencies: '@d-fischer/connection': 10.0.1 @@ -12866,10 +12330,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.5.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -12880,8 +12340,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-interactive@2.0.0: {} - is-network-error@1.3.1: {} is-number@7.0.0: {} @@ -12903,8 +12361,6 @@ snapshots: is-stream@2.0.1: {} - is-unicode-supported@2.1.0: {} - is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -12913,8 +12369,6 @@ snapshots: isexe@2.0.0: {} - isexe@4.0.0: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -13112,10 +12566,6 @@ snapshots: dependencies: immediate: 3.0.6 - lifecycle-utils@2.1.0: {} - - lifecycle-utils@3.1.1: {} - lightningcss-android-arm64@1.32.0: optional: true @@ -13203,8 +12653,6 @@ snapshots: lodash.clonedeep@4.5.0: {} - lodash.debounce@4.0.8: {} - lodash.identity@3.0.0: {} lodash.includes@4.3.0: {} @@ -13225,21 +12673,12 @@ snapshots: lodash.pickby@4.6.0: {} - log-symbols@7.0.1: - dependencies: - is-unicode-supported: 2.1.0 - yoctocolors: 2.1.2 - loglevel@1.9.2: {} long@5.3.2: {} longest-streak@3.1.0: {} - lowdb@7.0.1: - dependencies: - steno: 4.0.2 - lru-cache@11.3.5: {} lru-cache@6.0.0: @@ -13664,14 +13103,10 @@ snapshots: mimic-fn@2.1.0: {} - mimic-function@5.0.1: {} - minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 - minimist@1.2.8: {} - minipass@7.1.3: {} minizlib@3.1.0: @@ -13713,19 +13148,13 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} - negotiator@1.0.0: {} netmask@2.1.1: {} - node-addon-api@8.6.0: {} - node-addon-api@8.7.0: optional: true - node-api-headers@1.8.0: {} - node-downloader-helper@2.1.11: {} node-edge-tts@1.2.10: @@ -13748,54 +13177,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-llama-cpp@3.18.1(typescript@6.0.3): - dependencies: - '@huggingface/jinja': 0.5.6 - async-retry: 1.3.3 - bytes: 3.1.2 - chalk: 5.6.2 - chmodrp: 1.0.2 - cmake-js: 8.0.0 - cross-spawn: 7.0.6 - env-var: 7.5.0 - filenamify: 6.0.0 - fs-extra: 11.3.4 - ignore: 7.0.5 - ipull: 3.9.5 - is-unicode-supported: 2.1.0 - lifecycle-utils: 3.1.1 - log-symbols: 7.0.1 - nanoid: 5.1.6 - node-addon-api: 8.6.0 - ora: 9.3.0 - pretty-ms: 9.3.0 - proper-lockfile: 4.1.2 - semver: 7.7.4 - simple-git: 3.33.0 - slice-ansi: 8.0.0 - stdout-update: 4.0.1 - strip-ansi: 7.2.0 - validate-npm-package-name: 7.0.2 - which: 6.0.1 - yargs: 17.7.2 - optionalDependencies: - '@node-llama-cpp/linux-arm64': 3.18.1 - '@node-llama-cpp/linux-armv7l': 3.18.1 - '@node-llama-cpp/linux-x64': 3.18.1 - '@node-llama-cpp/linux-x64-cuda': 3.18.1 - '@node-llama-cpp/linux-x64-cuda-ext': 3.18.1 - '@node-llama-cpp/linux-x64-vulkan': 3.18.1 - '@node-llama-cpp/mac-arm64-metal': 3.18.1 - '@node-llama-cpp/mac-x64': 3.18.1 - '@node-llama-cpp/win-arm64': 3.18.1 - '@node-llama-cpp/win-x64': 3.18.1 - '@node-llama-cpp/win-x64-cuda': 3.18.1 - '@node-llama-cpp/win-x64-cuda-ext': 3.18.1 - '@node-llama-cpp/win-x64-vulkan': 3.18.1 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - node-readable-to-web-readable-stream@0.4.2: optional: true @@ -13876,10 +13257,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -13920,17 +13297,6 @@ snapshots: opusscript@0.1.1: {} - ora@9.3.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 3.4.0 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 7.0.1 - stdin-discarder: 0.3.1 - string-width: 8.2.0 - osc-progress@0.3.0: {} oxfmt@0.46.0: @@ -14088,10 +13454,6 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-ms@3.0.0: {} - - parse-ms@4.0.0: {} - parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -14187,16 +13549,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - pretty-bytes@6.1.1: {} - - pretty-ms@8.0.0: - dependencies: - parse-ms: 3.0.0 - - pretty-ms@9.3.0: - dependencies: - parse-ms: 4.0.0 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): optionalDependencies: '@discordjs/opus': 0.10.0 @@ -14388,13 +13740,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -14524,11 +13869,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - retry@0.12.0: {} retry@0.13.1: {} @@ -14757,8 +14097,6 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - signal-polyfill@0.2.2: {} signal-utils@0.21.1(signal-polyfill@0.2.2): @@ -14767,14 +14105,6 @@ snapshots: silk-wasm@3.7.1: {} - simple-git@3.33.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - simple-xml-to-json@1.2.7: {} simple-yenc@1.0.4: {} @@ -14796,18 +14126,6 @@ snapshots: - bare-buffer - react-native-b4a - sleep-promise@9.1.0: {} - - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: {} socks-proxy-agent@10.0.0: @@ -14883,17 +14201,6 @@ snapshots: std-env@4.1.0: {} - stdin-discarder@0.3.1: {} - - stdout-update@4.0.1: - dependencies: - ansi-escapes: 6.2.1 - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - - steno@4.0.2: {} - streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -14909,17 +14216,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - - string-width@8.2.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -14944,8 +14240,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-json-comments@2.0.1: {} - strnum@2.2.3: {} strtok3@10.3.5: @@ -15209,8 +14503,6 @@ snapshots: dependencies: rolldown: 1.0.0-rc.17 - url-join@4.0.1: {} - url-parse@1.5.10: dependencies: querystringify: 2.2.0 @@ -15224,8 +14516,6 @@ snapshots: uuid@14.0.0: {} - validate-npm-package-name@7.0.2: {} - vary@1.1.2: {} vfile-message@4.0.3: @@ -15317,10 +14607,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@6.0.1: - dependencies: - isexe: 4.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/scripts/lib/dependency-ownership.json b/scripts/lib/dependency-ownership.json index 71ef2924ccc..fa64d161ed8 100644 --- a/scripts/lib/dependency-ownership.json +++ b/scripts/lib/dependency-ownership.json @@ -137,11 +137,6 @@ "class": "core-runtime", "risk": ["parser", "markdown"] }, - "node-llama-cpp": { - "owner": "capability:memory-local-embeddings", - "class": "optional-peer-runtime", - "risk": ["native", "local-model-runtime", "large-transitive-cone"] - }, "openai": { "owner": "provider:openai", "class": "default-runtime-initially", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 1196875f25c..328e8bbc167 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -22,6 +22,8 @@ type PackageJson = { license?: string; repository?: { url?: string } | string; bin?: Record; + dependencies?: Record; + optionalDependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; }; @@ -58,6 +60,7 @@ export type NpmDistTagMirrorAuth = { source: "node-auth-token" | "npm-token" | "none"; }; const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; +const OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE = "node-llama-cpp"; const MAX_CALVER_DISTANCE_DAYS = 2; const REQUIRED_PACKED_PATHS = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, @@ -266,15 +269,25 @@ export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] `package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`, ); } - if (pkg.peerDependencies?.["node-llama-cpp"] !== "3.18.1") { + if (pkg.dependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) { errors.push( - `package.json peerDependencies["node-llama-cpp"] must be "3.18.1"; found "${ - pkg.peerDependencies?.["node-llama-cpp"] ?? "" - }".`, + `package.json dependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`, ); } - if (pkg.peerDependenciesMeta?.["node-llama-cpp"]?.optional !== true) { - errors.push('package.json peerDependenciesMeta["node-llama-cpp"].optional must be true.'); + if (pkg.optionalDependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) { + errors.push( + `package.json optionalDependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it operator-installed.`, + ); + } + if (pkg.peerDependencies?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) { + errors.push( + `package.json peerDependencies["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`, + ); + } + if (pkg.peerDependenciesMeta?.[OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE]) { + errors.push( + `package.json peerDependenciesMeta["${OPTIONAL_LOCAL_EMBEDDING_RUNTIME_PACKAGE}"] must be omitted; keep it optional.`, + ); } return errors; diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 45c8bfc3672..6e4b49a1928 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -519,13 +519,11 @@ describe("collectReleasePackageMetadataErrors", () => { license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, - peerDependencies: { "node-llama-cpp": "3.18.1" }, - peerDependenciesMeta: { "node-llama-cpp": { optional: true } }, }), ).toEqual([]); }); - it("requires node-llama-cpp to stay an optional peer", () => { + it("rejects node-llama-cpp as a peer dependency", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", @@ -534,7 +532,39 @@ describe("collectReleasePackageMetadataErrors", () => { repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, peerDependencies: { "node-llama-cpp": "3.18.1" }, + peerDependenciesMeta: { "node-llama-cpp": { optional: true } }, }), - ).toContain('package.json peerDependenciesMeta["node-llama-cpp"].optional must be true.'); + ).toEqual([ + 'package.json peerDependencies["node-llama-cpp"] must be omitted; keep it optional.', + 'package.json peerDependenciesMeta["node-llama-cpp"] must be omitted; keep it optional.', + ]); + }); + + it("rejects node-llama-cpp as a direct runtime dependency", () => { + expect( + collectReleasePackageMetadataErrors({ + name: "openclaw", + description: "Multi-channel AI gateway with extensible messaging integrations", + license: "MIT", + repository: { url: "git+https://github.com/openclaw/openclaw.git" }, + bin: { openclaw: "openclaw.mjs" }, + dependencies: { "node-llama-cpp": "3.18.1" }, + }), + ).toContain('package.json dependencies["node-llama-cpp"] must be omitted; keep it optional.'); + }); + + it("rejects node-llama-cpp as an optional dependency", () => { + expect( + collectReleasePackageMetadataErrors({ + name: "openclaw", + description: "Multi-channel AI gateway with extensible messaging integrations", + license: "MIT", + repository: { url: "git+https://github.com/openclaw/openclaw.git" }, + bin: { openclaw: "openclaw.mjs" }, + optionalDependencies: { "node-llama-cpp": "3.18.1" }, + }), + ).toContain( + 'package.json optionalDependencies["node-llama-cpp"] must be omitted; keep it operator-installed.', + ); }); }); From 712f7b218c092c9d0fae1f583f7c06e617920115 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:10:30 +0100 Subject: [PATCH 88/93] test: cover bundled MCP runtime cleanup gates --- src/agents/pi-bundle-mcp-runtime.test.ts | 19 ++++++++++++ .../pi-embedded-runner/run/attempt.test.ts | 29 +++++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index dafaedc1062..798d8d144bf 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -162,6 +162,25 @@ describe("session MCP runtime", () => { expect(activeLeases).toBe(0); }); + it("releases a runtime lease when catalog materialization fails", async () => { + let activeLeases = 0; + const runtime = { + ...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]), + acquireLease: () => { + activeLeases += 1; + return () => { + activeLeases -= 1; + }; + }, + getCatalog: async () => { + throw new Error("catalog failed"); + }, + }; + + await expect(materializeBundleMcpToolsForRun({ runtime })).rejects.toThrow("catalog failed"); + expect(activeLeases).toBe(0); + }); + it("reuses repeated materialization and recreates after explicit disposal", async () => { const created: SessionMcpRuntime[] = []; const disposed: string[] = []; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 2befcd7602e..a7ff53b0c6a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -20,6 +20,7 @@ import { resolveAttemptFsWorkspaceOnly, resolveEmbeddedAgentStreamFn, resolveUnknownToolGuardThreshold, + shouldCreateBundleMcpRuntimeForAttempt, resolvePromptBuildHookResult, resolvePromptModeForSession, shouldStripBootstrapFromEmbeddedContext, @@ -72,6 +73,34 @@ describe("applyEmbeddedAttemptToolsAllow", () => { }); }); +describe("shouldCreateBundleMcpRuntimeForAttempt", () => { + it("skips bundle MCP when tools are disabled or unavailable", () => { + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: false })).toBe(false); + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, disableTools: true })).toBe( + false, + ); + }); + + it("creates bundle MCP only when the allowlist can reach bundle MCP tool names", () => { + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })).toBe(true); + expect(shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true, toolsAllow: [] })).toBe( + true, + ); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["memory_search", "memory_get"], + }), + ).toBe(false); + expect( + shouldCreateBundleMcpRuntimeForAttempt({ + toolsEnabled: true, + toolsAllow: ["strict__strict_probe"], + }), + ).toBe(true); + }); +}); + describe("resolvePromptBuildHookResult", () => { function createLegacyOnlyHookRunner() { return { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 15005d1604e..1b9cdcc5fee 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -466,7 +466,7 @@ export function applyEmbeddedAttemptToolsAllow( return tools.filter((tool) => allowSet.has(tool.name)); } -function shouldCreateBundleMcpRuntimeForAttempt(params: { +export function shouldCreateBundleMcpRuntimeForAttempt(params: { toolsEnabled: boolean; disableTools?: boolean; toolsAllow?: string[]; From 5376a4a5d6dd6332904481cf39be9537132b5c8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:10:54 +0100 Subject: [PATCH 89/93] fix(browser): default act timeout budget Co-authored-by: Andy Lin --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- docs/cli/browser.md | 2 + docs/tools/browser.md | 2 + .../browser/src/browser-tool.actions.ts | 104 +++++++++++++++++- extensions/browser/src/browser-tool.test.ts | 84 ++++++++++++++ extensions/browser/src/browser/chrome.test.ts | 1 + .../src/browser/client-actions-core.ts | 30 ++++- extensions/browser/src/browser/client.test.ts | 26 +++++ extensions/browser/src/browser/config.test.ts | 3 + extensions/browser/src/browser/config.ts | 7 ++ extensions/browser/src/browser/constants.ts | 1 + .../server-context.existing-session.test.ts | 1 + .../server-context.remote-tab-ops.harness.ts | 1 + .../browser/server-context.test-harness.ts | 1 + src/agents/sandbox/browser.ts | 2 + src/config/schema.base.generated.ts | 13 +++ src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.browser.ts | 2 + src/config/zod-schema.ts | 1 + src/plugin-sdk/browser-config.ts | 1 + src/plugin-sdk/browser-profiles.ts | 2 + 23 files changed, 283 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5c933caa5..f60159c2db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar. - Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz. +- Browser: add `browser.actionTimeoutMs` and use a 60s default action budget so healthy long browser waits do not fail at the client transport boundary. (#62589) Thanks @andyylin. - Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. - Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 1b448c49e4a..2175ddeed37 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -13b68287fec00108ca66032120909a0eac797ed541e026357e175e3fce5bacdd config-baseline.json -77ee66fb3b2cde94b393712bc03a132b096cf601c193bde1fe42902eecb0b66b config-baseline.core.json +f1fd4557473391980caf6d6b32f78e4de25f8504b29dfe083f7f9e325d0b204c config-baseline.json +68e0784ca0f9279d49b40ce4493e1cb2c416e1fb70a137a853a10a8c078c97ca config-baseline.core.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json -0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json +0504c4f38d4c753fffeb465c93540d829df6b0fcef921eb0e2226ac16bdbbe07 config-baseline.plugin.json diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 38ab18d4a99..8f47e892a10 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -242,6 +242,8 @@ This path is host-only. For Docker, headless servers, Browserless, or other remo Current existing-session limits: - snapshot-driven actions use refs, not CSS selectors +- `browser.actionTimeoutMs` defaults supported `act` requests to 60000 ms when + callers omit `timeoutMs`; per-call `timeoutMs` still wins. - `click` is left-click only - `type` does not support `slowly=true` - `press` does not support `delayMs` diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 50962d93fb4..cdf370e725e 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -129,6 +129,7 @@ Browser settings live in `~/.openclaw/openclaw.json`. // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) + actionTimeoutMs: 60000, // default browser act timeout (ms) tabCleanup: { enabled: true, // default: true idleMinutes: 120, // set 0 to disable idle cleanup @@ -173,6 +174,7 @@ Browser settings live in `~/.openclaw/openclaw.json`. - Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP HTTP reachability checks; `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket handshakes. +- `actionTimeoutMs` is the default budget for browser `act` requests when the caller does not pass `timeoutMs`. The client transport adds a small slack window so long waits can finish instead of timing out at the HTTP boundary. - `tabCleanup` is best-effort cleanup for tabs opened by primary-agent browser sessions. Subagent, cron, and ACP lifecycle cleanup still closes their explicit tracked tabs at session end; primary sessions keep active tabs reusable, then close idle or excess tracked tabs in the background.
diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index c3227beb7b0..1bc1565158b 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -15,6 +15,7 @@ import { resolveProfile, wrapExternalContent, } from "./browser-tool.runtime.js"; +import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js"; const browserToolActionDeps = { browserAct, @@ -25,6 +26,94 @@ const browserToolActionDeps = { loadConfig, }; +const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000; + +type BrowserActRequest = Parameters[1]; +type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number }; + +function normalizePositiveTimeoutMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function supportsBrowserActTimeout(request: BrowserActRequest): boolean { + switch (request.kind) { + case "click": + case "type": + case "hover": + case "scrollIntoView": + case "drag": + case "select": + case "fill": + case "evaluate": + case "wait": + return true; + default: + return false; + } +} + +function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean { + switch (request.kind) { + case "type": + case "hover": + case "scrollIntoView": + case "drag": + case "select": + case "fill": + case "evaluate": + return true; + default: + return false; + } +} + +function usesExistingSessionProfile(profileName: string | undefined): boolean { + const cfg = browserToolActionDeps.loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile); + return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false; +} + +function withConfiguredActTimeout( + request: BrowserActRequest, + profileName: string | undefined, +): BrowserActRequest { + const typedRequest = request as BrowserActRequestWithTimeout; + if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== undefined) { + return request; + } + if (!supportsBrowserActTimeout(request)) { + return request; + } + if (existingSessionRejectsActTimeout(request) && usesExistingSessionProfile(profileName)) { + return request; + } + + const cfg = browserToolActionDeps.loadConfig(); + const configuredTimeout = + normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS; + return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest; +} + +function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined { + const candidateTimeouts: number[] = []; + const explicitTimeout = normalizePositiveTimeoutMs( + (request as BrowserActRequestWithTimeout).timeoutMs, + ); + if (explicitTimeout !== undefined) { + candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + if (request.kind === "wait") { + const waitDuration = normalizePositiveTimeoutMs(request.timeMs); + if (waitDuration !== undefined) { + candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + } + return candidateTimeouts.length ? Math.max(...candidateTimeouts) : undefined; +} + export const __testing = { setDepsForTest( overrides: Partial<{ @@ -408,32 +497,34 @@ export async function executeConsoleAction(params: { } export async function executeActAction(params: { - request: Parameters[1]; + request: BrowserActRequest; baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { request, baseUrl, profile, proxyRequest } = params; + const effectiveRequest = withConfiguredActTimeout(request, profile); try { const result = proxyRequest ? await proxyRequest({ method: "POST", path: "/act", profile, - body: request, + body: effectiveRequest, + timeoutMs: resolveActProxyTimeoutMs(effectiveRequest), }) - : await browserToolActionDeps.browserAct(baseUrl, request, { + : await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, { profile, }); params.onTabActivity?.( readStringValue((result as { targetId?: unknown }).targetId) ?? - readStringValue(request.targetId), + readStringValue(effectiveRequest.targetId), ); return jsonResult(result); } catch (err) { if (isChromeStaleTargetError(profile, err)) { - const retryRequest = stripTargetIdFromActRequest(request); + const retryRequest = stripTargetIdFromActRequest(effectiveRequest); const tabs = proxyRequest ? (( (await proxyRequest({ @@ -445,7 +536,7 @@ export async function executeActAction(params: { : await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []); // Some user-browser targetIds can go stale between snapshots and actions. // Only retry safe read-only actions, and only when exactly one tab remains attached. - if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) { + if (retryRequest && canRetryChromeActWithoutTargetId(effectiveRequest) && tabs.length === 1) { try { const retryResult = proxyRequest ? await proxyRequest({ @@ -453,6 +544,7 @@ export async function executeActAction(params: { path: "/act", profile, body: retryRequest, + timeoutMs: resolveActProxyTimeoutMs(retryRequest), }) : await browserToolActionDeps.browserAct(baseUrl, retryRequest, { profile, diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 08cdf66fc7f..723323d275f 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -69,6 +69,7 @@ const browserConfigMocks = vi.hoisted(() => ({ controlPort: 18791, profiles: {}, defaultProfile: "openclaw", + actionTimeoutMs: 60_000, })), resolveProfile: vi.fn((resolved: Record, name: string) => { const profile = (resolved.profiles as Record> | undefined)?.[ @@ -249,6 +250,7 @@ function resetBrowserToolMocks() { controlPort: 18791, profiles: {}, defaultProfile: "openclaw", + actionTimeoutMs: 60_000, }); nodesUtilsMocks.listNodes.mockResolvedValue([]); browserToolTesting.setDepsForTest({ @@ -292,6 +294,7 @@ function setResolvedBrowserProfiles( controlPort: 18791, profiles, defaultProfile, + actionTimeoutMs: 60_000, }); } @@ -1078,6 +1081,87 @@ describe("browser tool act compatibility", () => { expect.objectContaining({ profile: undefined }), ); }); + + it("applies configured browser action timeout when act timeout is omitted", async () => { + configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + request: { + kind: "wait", + timeMs: 20_000, + }, + }); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledWith( + undefined, + { + kind: "wait", + timeMs: 20_000, + timeoutMs: 45_000, + }, + expect.objectContaining({ profile: undefined }), + ); + }); + + it("does not inject unsupported action timeout for existing-session type actions", async () => { + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + profile: "user", + target: "host", + request: { + kind: "type", + ref: "f1e3", + text: "Test Title", + }, + }); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledWith( + undefined, + { + kind: "type", + ref: "f1e3", + text: "Test Title", + }, + expect.objectContaining({ profile: "user" }), + ); + }); + + it("passes configured act timeout through node proxy with transport slack", async () => { + mockSingleBrowserProxyNode(); + configMocks.loadConfig.mockReturnValue({ + browser: { + actionTimeoutMs: 45_000, + }, + gateway: { nodes: { browser: { node: "node-1" } } }, + }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + target: "node", + request: { kind: "wait", timeMs: 20_000 }, + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 55_000 }, + expect.objectContaining({ + params: expect.objectContaining({ + path: "/act", + body: { kind: "wait", timeMs: 20_000, timeoutMs: 45_000 }, + timeoutMs: 45_000 + 5_000, + }), + }), + ); + }); }); describe("browser tool snapshot labels", () => { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index c9b2cebed09..dde16b2e39e 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -681,6 +681,7 @@ describe("browser chrome launch args", () => { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, extraArgs: [], color: "#FF4500", headless: false, diff --git a/extensions/browser/src/browser/client-actions-core.ts b/extensions/browser/src/browser/client-actions-core.ts index f4ebf0fee95..47d3aa97e0f 100644 --- a/extensions/browser/src/browser/client-actions-core.ts +++ b/extensions/browser/src/browser/client-actions-core.ts @@ -6,7 +6,10 @@ import type { import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; import type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js"; import { fetchBrowserJson } from "./client-fetch.js"; -import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./constants.js"; +import { + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS, +} from "./constants.js"; export type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js"; @@ -26,6 +29,29 @@ export type BrowserDownloadPayload = { type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload }; +const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000; + +function normalizePositiveTimeoutMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function resolveBrowserActRequestTimeoutMs(req: BrowserActRequest): number { + const explicitTimeout = normalizePositiveTimeoutMs((req as { timeoutMs?: unknown }).timeoutMs); + const candidateTimeouts = + explicitTimeout === undefined + ? [DEFAULT_BROWSER_ACTION_TIMEOUT_MS] + : [explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS]; + if (req.kind === "wait") { + const waitDuration = normalizePositiveTimeoutMs(req.timeMs); + if (waitDuration !== undefined) { + candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + } + return Math.max(...candidateTimeouts); +} + async function postDownloadRequest( baseUrl: string | undefined, route: "/wait/download" | "/download", @@ -167,7 +193,7 @@ export async function browserAct( timeoutMs: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.floor(opts.timeoutMs)) - : 20000, + : resolveBrowserActRequestTimeoutMs(req), }); } diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index 649eb4fa078..cdb608d9df6 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -334,4 +334,30 @@ describe("browser client", () => { timeoutMs: 20_000, }); }); + + it("gives browser act requests enough client timeout for long waits", async () => { + const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => { + calls.push({ url, init }); + return { + ok: true, + json: async () => ({ ok: true, targetId: "t1" }), + } as unknown as Response; + }), + ); + + await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }); + await browserAct("http://127.0.0.1:18791", { + kind: "wait", + timeMs: 70_000, + }); + await browserAct("http://127.0.0.1:18791", { + kind: "wait", + timeoutMs: 45_000, + }); + + expect(calls.map((call) => call.init?.timeoutMs)).toEqual([60_000, 75_000, 50_000]); + }); }); diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index ec56f032766..084b506aa7b 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -60,6 +60,7 @@ describe("browser config", () => { expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000); + expect(resolved.actionTimeoutMs).toBe(60_000); expect(resolved.tabCleanup).toEqual({ enabled: true, idleMinutes: 120, @@ -119,9 +120,11 @@ describe("browser config", () => { const resolved = resolveBrowserConfig({ remoteCdpTimeoutMs: 2200, remoteCdpHandshakeTimeoutMs: 5000, + actionTimeoutMs: 45_000, }); expect(resolved.remoteCdpTimeoutMs).toBe(2200); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000); + expect(resolved.actionTimeoutMs).toBe(45_000); }); it("supports custom browser tab cleanup policy", () => { diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index ae4b68c7c53..f1099117bb5 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -20,6 +20,7 @@ import { resolveUserPath } from "../utils.js"; import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES, @@ -66,6 +67,7 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + actionTimeoutMs: number; color: string; executablePath?: string; headless: boolean; @@ -263,6 +265,10 @@ export function resolveBrowserConfig( cfg?.remoteCdpHandshakeTimeoutMs, Math.max(2000, remoteCdpTimeoutMs * 2), ); + const actionTimeoutMs = normalizeTimeoutMs( + cfg?.actionTimeoutMs, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + ); const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; @@ -343,6 +349,7 @@ export function resolveBrowserConfig( cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), remoteCdpTimeoutMs, remoteCdpHandshakeTimeoutMs, + actionTimeoutMs, color: defaultColor, executablePath, headless, diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index fb229b6ec3d..9aac58a140b 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -3,6 +3,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; +export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000; export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000; export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120; export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8; diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 619ec502909..f1e1eaa0d44 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -44,6 +44,7 @@ function makeState(): BrowserServerState { cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, color: "#FF4500", headless: false, noSandbox: false, diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index 3ba02cf8065..776e85ac1ad 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -24,6 +24,7 @@ export function makeState( cdpIsLoopback: profile !== "remote", remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, evaluateEnabled: false, extraArgs: [], color: "#FF4500", diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index e91ba2dcfc9..1b95d4b42ac 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -37,6 +37,7 @@ export function makeBrowserServerState(params?: { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, extraArgs: [], color: profile.color, headless: true, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 4d6b851acc8..10ce5ef7c64 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -6,6 +6,7 @@ import { stopBrowserBridgeServer, } from "../../plugin-sdk/browser-bridge.js"; import { + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -96,6 +97,7 @@ function buildSandboxBrowserResolvedConfig(params: { cdpPortRangeEnd: cdpPortRange.end, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS, color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: undefined, headless: params.headless, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 2926220475c..723bfa2cd64 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -603,6 +603,14 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.", }, + actionTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + title: "Browser Action Timeout (ms)", + description: + "Default timeout in milliseconds for browser act requests before the client gives up waiting. Raise this when healthy waits or UI interactions exceed the default request budget.", + }, color: { type: "string", title: "Browser Accent Color", @@ -23933,6 +23941,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", tags: ["advanced", "url-secret"], }, + "browser.actionTimeoutMs": { + label: "Browser Action Timeout (ms)", + help: "Default timeout in milliseconds for browser act requests before the client gives up waiting. Raise this when healthy waits or UI interactions exceed the default request budget.", + tags: ["performance"], + }, "browser.color": { label: "Browser Accent Color", help: "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 02d61ddeaa8..f144255a01f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -258,6 +258,8 @@ export const FIELD_HELP: Record = { "Enables browser capability wiring in the gateway so browser tools and CDP-driven workflows can run. Disable when browser automation is not needed to reduce surface area and startup work.", "browser.cdpUrl": "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", + "browser.actionTimeoutMs": + "Default timeout in milliseconds for browser act requests before the client gives up waiting. Raise this when healthy waits or UI interactions exceed the default request budget.", "browser.color": "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "browser.executablePath": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index b27b439e2ac..60148eed508 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -140,6 +140,7 @@ export const FIELD_LABELS: Record = { browser: "Browser", "browser.enabled": "Browser Enabled", "browser.cdpUrl": "Browser CDP URL", + "browser.actionTimeoutMs": "Browser Action Timeout (ms)", "browser.color": "Browser Accent Color", "browser.executablePath": "Browser Executable Path", "browser.headless": "Browser Headless Mode", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 562ab7108c7..9d9c159317a 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -54,6 +54,8 @@ export type BrowserConfig = { remoteCdpTimeoutMs?: number; /** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */ remoteCdpHandshakeTimeoutMs?: number; + /** Default browser act timeout (ms). Default: 60000. */ + actionTimeoutMs?: number; /** Accent color for the openclaw browser profile (hex). Default: #FF4500 */ color?: string; /** Override the browser executable path (all platforms). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 013d919d122..456b4c00931 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -381,6 +381,7 @@ export const OpenClawSchema = z cdpUrl: z.string().optional(), remoteCdpTimeoutMs: z.number().int().nonnegative().optional(), remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(), + actionTimeoutMs: z.number().int().positive().optional(), color: z.string().optional(), executablePath: z.string().optional(), headless: z.boolean().optional(), diff --git a/src/plugin-sdk/browser-config.ts b/src/plugin-sdk/browser-config.ts index 6aa687729fd..bdc4a7b232a 100644 --- a/src/plugin-sdk/browser-config.ts +++ b/src/plugin-sdk/browser-config.ts @@ -1,5 +1,6 @@ export { DEFAULT_AI_SNAPSHOT_MAX_CHARS, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index fc1d82c1c25..ec452668232 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -9,6 +9,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; +export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000; export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); @@ -30,6 +31,7 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + actionTimeoutMs: number; color: string; executablePath?: string; headless: boolean; From 845040214e134a278cde160d999c0bf88fc14371 Mon Sep 17 00:00:00 2001 From: wzp <49344346+ZiPengWei@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:12:20 +0800 Subject: [PATCH 90/93] fix: recover subagent waits after transport drops Fix subagent recovery and session state reconciliation. Thanks @ZiPengWei. --- CHANGELOG.md | 1 + extensions/feishu/src/channel.test.ts | 27 ++ extensions/feishu/src/channel.ts | 14 + src/agents/run-wait.test.ts | 13 + src/agents/run-wait.ts | 22 ++ src/agents/subagent-orphan-recovery.test.ts | 95 +++++- src/agents/subagent-orphan-recovery.ts | 182 ++++++++++- src/agents/subagent-registry-read.ts | 10 + src/agents/subagent-registry-run-manager.ts | 24 +- src/agents/subagent-registry-steer-runtime.ts | 18 ++ src/agents/subagent-registry.test.ts | 248 ++++++++++++++ src/agents/subagent-registry.ts | 303 +++++++++++++++++- src/gateway/server-methods/agent-job.ts | 109 ++++++- .../server-methods/server-methods.test.ts | 135 +++++++- src/gateway/server-session-events.ts | 2 + src/gateway/session-utils.search.test.ts | 8 + src/gateway/session-utils.subagent.test.ts | 62 +++- src/gateway/session-utils.ts | 52 ++- src/gateway/session-utils.types.ts | 4 + ui/src/ui/types.ts | 3 + 20 files changed, 1283 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60159c2db9..563d3039271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. - Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920. +- Subagents: recover child sessions after recoverable wait transport failures without exposing an extra wait state, and keep terminal lifecycle timer ordering deterministic. (#71423) Thanks @ZiPengWei. - Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys. - Gateway/tools: allow `POST /tools/invoke` to reach plugin-backed catalog tools such as `browser` when no core implementation exists, while still preferring built-in tools for real core names. Thanks @chat2way. - Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao. diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 05ecaf65b71..2811b7fcfa4 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -892,6 +892,33 @@ describe("normalizeFeishuTarget", () => { }); }); +describe("feishuPlugin.messaging.resolveDeliveryTarget", () => { + it("routes direct conversations to user targets", () => { + expect( + feishuPlugin.messaging?.resolveDeliveryTarget?.({ + conversationId: "ou_123", + }), + ).toEqual({ to: "user:ou_123" }); + }); + + it("routes group conversations to chat targets", () => { + expect( + feishuPlugin.messaging?.resolveDeliveryTarget?.({ + conversationId: "oc_123", + }), + ).toEqual({ to: "chat:oc_123" }); + }); + + it("routes topic conversations to parent chat plus thread id", () => { + expect( + feishuPlugin.messaging?.resolveDeliveryTarget?.({ + conversationId: "oc_123:topic:omt_456", + parentConversationId: "oc_123", + }), + ).toEqual({ to: "chat:oc_123", threadId: "omt_456" }); + }); +}); + describe("looksLikeFeishuId", () => { it("accepts provider-prefixed user targets", () => { expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 1c2de22d542..55ef53d8e42 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1134,6 +1134,20 @@ export const feishuPlugin: ChannelPlugin normalizeFeishuTarget(raw) ?? undefined, + resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { + const directId = parseFeishuDirectConversationId(conversationId); + if (directId) { + return { to: `user:${directId}` }; + } + const parsed = parseFeishuConversationId({ conversationId, parentConversationId }); + if (parsed?.topicId) { + return { + to: `chat:${parentConversationId?.trim() || parsed.chatId}`, + threadId: parsed.topicId, + }; + } + return { to: `chat:${parsed?.chatId ?? conversationId.trim()}` }; + }, resolveSessionConversation: ({ kind, rawId }) => resolveFeishuSessionConversation({ kind, rawId }), resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params), diff --git a/src/agents/run-wait.test.ts b/src/agents/run-wait.test.ts index eca0385915a..128be707b04 100644 --- a/src/agents/run-wait.test.ts +++ b/src/agents/run-wait.test.ts @@ -7,6 +7,7 @@ vi.mock("../gateway/call.js", () => ({ import { __testing, + isRecoverableAgentWaitError, readLatestAssistantReply, readLatestAssistantReplySnapshot, waitForAgentRun, @@ -151,6 +152,18 @@ describe("waitForAgentRun", () => { }); }); + it("keeps transport-close wait failures as errors for generic callers", async () => { + callGatewayMock.mockRejectedValue(new Error("gateway closed (1006): transport close")); + + const result = await waitForAgentRun({ runId: "run-interrupted", timeoutMs: 500 }); + + expect(result).toEqual({ + status: "error", + error: "gateway closed (1006): transport close", + }); + expect(isRecoverableAgentWaitError(result.error)).toBe(true); + }); + it("preserves pending agent.wait status", async () => { callGatewayMock.mockResolvedValue({ status: "pending" }); diff --git a/src/agents/run-wait.ts b/src/agents/run-wait.ts index fe4cd1e486c..3964e8e6318 100644 --- a/src/agents/run-wait.ts +++ b/src/agents/run-wait.ts @@ -49,6 +49,28 @@ function normalizeAgentWaitResult( }; } +const RECOVERABLE_AGENT_WAIT_ERROR_PATTERNS: readonly RegExp[] = [ + /gateway closed \(1006/i, + /transport close/i, + /connection loss/i, + /connection closed/i, + /gateway not connected/i, + /no active .* listener/i, + /socket hang up/i, + /\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH)\b/i, +]; + +export function isRecoverableAgentWaitError(error: string | undefined): boolean { + const message = error?.trim(); + if (!message) { + return false; + } + if (message.includes("gateway timeout")) { + return false; + } + return RECOVERABLE_AGENT_WAIT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + function normalizePendingRunIds(runIds: Iterable): string[] { const seen = new Set(); for (const runId of runIds) { diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 500f6d16148..664d84cbb3f 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as sessions from "../config/sessions.js"; import * as gateway from "../gateway/call.js"; import * as sessionUtils from "../gateway/session-utils.fs.js"; -import { recoverOrphanedSubagentSessions } from "./subagent-orphan-recovery.js"; +import * as announceDelivery from "./subagent-announce-delivery.js"; +import { + recoverOrphanedSubagentSessions, + scheduleOrphanRecovery, +} from "./subagent-orphan-recovery.js"; import * as subagentRegistrySteerRuntime from "./subagent-registry-steer-runtime.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; @@ -28,8 +32,19 @@ vi.mock("../gateway/session-utils.fs.js", () => ({ readSessionMessages: vi.fn(() => []), })); +vi.mock("./subagent-announce-delivery.js", () => ({ + deliverSubagentAnnouncement: vi.fn(async () => ({ delivered: true, path: "direct" })), + isInternalAnnounceRequesterSession: vi.fn(() => false), + loadRequesterSessionEntry: vi.fn(() => ({ entry: {} })), +})); + +vi.mock("./subagent-announce-origin.js", () => ({ + resolveAnnounceOrigin: vi.fn((entry, requesterOrigin) => requesterOrigin), +})); + vi.mock("./subagent-registry-steer-runtime.js", () => ({ replaceSubagentRunAfterSteer: vi.fn(() => true), + finalizeInterruptedSubagentRun: vi.fn(async () => 1), })); function createTestRunRecord(overrides: Partial = {}): SubagentRunRecord { @@ -84,10 +99,12 @@ function getResumeMessage() { describe("subagent-orphan-recovery", () => { beforeEach(() => { + vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -262,6 +279,13 @@ describe("subagent-orphan-recovery", () => { expect(result.recovered).toBe(0); expect(result.failed).toBe(1); + expect(result.failedRuns).toEqual([ + expect.objectContaining({ + runId: "run-1", + childSessionKey: "agent:main:subagent:test-session-1", + error: "gateway unavailable", + }), + ]); // abortedLastRun flag should NOT be cleared on failure, // so the next restart can retry the recovery @@ -369,6 +393,38 @@ describe("subagent-orphan-recovery", () => { expect(message).toContain("config changes from your previous run were already applied"); }); + it("announces recovery-in-progress once when a later retry is attempting resume", async () => { + mockSingleAbortedSession(); + + const activeRuns = createActiveRuns(createTestRunRecord()); + const notifiedRecoverySessionKeys = new Set(); + + await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + attemptNumber: 2, + maxAttempts: 4, + notifiedRecoverySessionKeys, + }); + + expect(announceDelivery.deliverSubagentAnnouncement).toHaveBeenCalledOnce(); + expect(announceDelivery.deliverSubagentAnnouncement).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSessionKey: "agent:main:quietchat:direct:+1234567890", + triggerMessage: expect.stringContaining("Automatic recovery is already in progress"), + }), + ); + expect(notifiedRecoverySessionKeys).toEqual(new Set(["agent:main:subagent:test-session-1"])); + + await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + attemptNumber: 3, + maxAttempts: 4, + notifiedRecoverySessionKeys, + }); + + expect(announceDelivery.deliverSubagentAnnouncement).toHaveBeenCalledOnce(); + }); + it("prevents duplicate resume when updateSessionStore fails", async () => { vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "new-run" } as never); vi.mocked(sessions.updateSessionStore).mockRejectedValue(new Error("write failed")); @@ -429,4 +485,41 @@ describe("subagent-orphan-recovery", () => { expect(gateway.callGateway).toHaveBeenCalledOnce(); expect(sessions.updateSessionStore).toHaveBeenCalledOnce(); }); + + it("finalizes interrupted runs with a readable failure after recovery retries are exhausted", async () => { + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + vi.mocked(gateway.callGateway).mockRejectedValue(new Error("service restart")); + + const activeRuns = createActiveRuns(createTestRunRecord()); + + scheduleOrphanRecovery({ + getActiveRuns: () => activeRuns, + delayMs: 1, + maxRetries: 1, + }); + + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2); + await Promise.resolve(); + + expect(subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-1", + childSessionKey: "agent:main:subagent:test-session-1", + error: expect.stringContaining("Automatic recovery failed after 2 attempts"), + }), + ); + expect(subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining("service restart"), + }), + ); + }); }); diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index a1120615fe4..aab66f6e30f 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -20,8 +20,19 @@ import { } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { readSessionMessages } from "../gateway/session-utils.fs.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { replaceSubagentRunAfterSteer } from "./subagent-registry-steer-runtime.js"; +import { buildAnnounceIdempotencyKey } from "./announce-idempotency.js"; +import { + deliverSubagentAnnouncement, + isInternalAnnounceRequesterSession, + loadRequesterSessionEntry, +} from "./subagent-announce-delivery.js"; +import { resolveAnnounceOrigin } from "./subagent-announce-origin.js"; +import { + finalizeInterruptedSubagentRun, + replaceSubagentRunAfterSteer, +} from "./subagent-registry-steer-runtime.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; const log = createSubsystemLogger("subagent-orphan-recovery"); @@ -60,6 +71,75 @@ function buildResumeMessage(task: string, lastHumanMessage?: string): string { return message; } +function buildRecoveryProgressPrompt(params: { + task: string; + attemptNumber: number; + maxAttempts: number; +}): string { + const maxTaskLen = 160; + const taskLabel = + params.task.length > maxTaskLen ? `${params.task.slice(0, maxTaskLen)}...` : params.task; + return ( + `A spawned subagent task was interrupted by a gateway restart or connection loss. ` + + `Automatic recovery is already in progress for "${taskLabel}" ` + + `(retry ${params.attemptNumber}/${params.maxAttempts}). ` + + `Send one brief update now in your normal voice: say the task was interrupted, ` + + `you are automatically resuming/retrying it, and you will report back when it either continues or truly fails. ` + + `Do not say the task has failed.` + ); +} + +async function announceRecoveryInProgress(params: { + runRecord: SubagentRunRecord; + attemptNumber: number; + maxAttempts: number; +}): Promise { + const requesterSessionKey = params.runRecord.requesterSessionKey?.trim(); + if (!requesterSessionKey) { + return false; + } + + const requesterOrigin = params.runRecord.requesterOrigin; + const requesterIsSubagent = isInternalAnnounceRequesterSession(requesterSessionKey); + let directOrigin = requesterOrigin; + if (!requesterIsSubagent) { + const { entry } = loadRequesterSessionEntry(requesterSessionKey); + directOrigin = resolveAnnounceOrigin(entry, requesterOrigin); + } + + const prompt = buildRecoveryProgressPrompt({ + task: params.runRecord.label || params.runRecord.task, + attemptNumber: params.attemptNumber, + maxAttempts: params.maxAttempts, + }); + + try { + const delivery = await deliverSubagentAnnouncement({ + requesterSessionKey, + announceId: `${params.runRecord.runId}:recovery-progress`, + triggerMessage: prompt, + steerMessage: prompt, + summaryLine: params.runRecord.label || params.runRecord.task, + requesterSessionOrigin: requesterOrigin, + requesterOrigin, + completionDirectOrigin: requesterOrigin, + directOrigin, + sourceSessionKey: params.runRecord.childSessionKey, + sourceTool: "subagent_orphan_recovery", + targetRequesterSessionKey: requesterSessionKey, + requesterIsSubagent, + expectsCompletionMessage: false, + bestEffortDeliver: true, + directIdempotencyKey: buildAnnounceIdempotencyKey( + `${params.runRecord.runId}:recovery-progress`, + ), + }); + return delivery.delivered; + } catch { + return false; + } +} + function extractMessageText(msg: unknown): string | undefined { if (!msg || typeof msg !== "object") { return undefined; @@ -95,7 +175,7 @@ async function resumeOrphanedSession(params: { configChangeHint?: string; originalRunId: string; originalRun: SubagentRunRecord; -}): Promise { +}): Promise<{ resumed: boolean; error?: string }> { let resumeMessage = buildResumeMessage(params.task, params.lastHumanMessage); if (params.configChangeHint) { resumeMessage += params.configChangeHint; @@ -122,13 +202,14 @@ async function resumeOrphanedSession(params: { log.warn( `resumed orphaned session ${params.sessionKey} but remap failed (old run already removed); treating resume as accepted to avoid duplicate restarts`, ); - return true; + return { resumed: true }; } log.info(`resumed orphaned session: ${params.sessionKey}`); - return true; + return { resumed: true }; } catch (err) { - log.warn(`failed to resume orphaned session ${params.sessionKey}: ${String(err)}`); - return false; + const error = formatErrorMessage(err); + log.warn(`failed to resume orphaned session ${params.sessionKey}: ${error}`); + return { resumed: false, error }; } } @@ -147,9 +228,28 @@ export async function recoverOrphanedSubagentSessions(params: { getActiveRuns: () => Map; /** Persisted across retries so already-resumed sessions are not resumed again. */ resumedSessionKeys?: Set; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - const result = { recovered: 0, failed: 0, skipped: 0 }; + /** Human-visible attempt number for this recovery pass. */ + attemptNumber?: number; + /** Total recovery attempts before giving up. */ + maxAttempts?: number; + /** Persisted across retries so recovery-in-progress notices stay deduped. */ + notifiedRecoverySessionKeys?: Set; +}): Promise<{ + recovered: number; + failed: number; + skipped: number; + failedRuns: Array<{ runId: string; childSessionKey: string; error?: string }>; +}> { + const result = { + recovered: 0, + failed: 0, + skipped: 0, + failedRuns: [] as Array<{ runId: string; childSessionKey: string; error?: string }>, + }; const resumedSessionKeys = params.resumedSessionKeys ?? new Set(); + const attemptNumber = Math.max(1, params.attemptNumber ?? 1); + const maxAttempts = Math.max(attemptNumber, params.maxAttempts ?? attemptNumber); + const notifiedRecoverySessionKeys = params.notifiedRecoverySessionKeys ?? new Set(); const configChangePattern = /openclaw\.json|openclaw gateway restart|config\.patch/i; try { @@ -218,11 +318,22 @@ export async function recoverOrphanedSubagentSessions(params: { return typeof text === "string" && configChangePattern.test(text); }); + if (attemptNumber > 1 && !notifiedRecoverySessionKeys.has(childSessionKey)) { + const notified = await announceRecoveryInProgress({ + runRecord, + attemptNumber, + maxAttempts, + }); + if (notified) { + notifiedRecoverySessionKeys.add(childSessionKey); + } + } + // Resume the session with the original task context. // We intentionally do NOT clear abortedLastRun before attempting // the resume — if callGateway fails (e.g. gateway still booting), // the flag stays true so the next restart can retry. - const resumed = await resumeOrphanedSession({ + const resumeResult = await resumeOrphanedSession({ sessionKey: childSessionKey, task: runRecord.task, lastHumanMessage: extractMessageText(lastHumanMessage), @@ -233,7 +344,7 @@ export async function recoverOrphanedSubagentSessions(params: { originalRun: runRecord, }); - if (resumed) { + if (resumeResult.resumed) { resumedSessionKeys.add(childSessionKey); // Only clear the aborted flag after confirmed successful resume. try { @@ -257,10 +368,21 @@ export async function recoverOrphanedSubagentSessions(params: { `resume failed for ${childSessionKey}; abortedLastRun flag preserved for retry on next restart`, ); result.failed++; + result.failedRuns.push({ + runId, + childSessionKey, + error: resumeResult.error, + }); } } catch (err) { - log.warn(`error processing orphaned session ${childSessionKey}: ${String(err)}`); + const error = formatErrorMessage(err); + log.warn(`error processing orphaned session ${childSessionKey}: ${error}`); result.failed++; + result.failedRuns.push({ + runId, + childSessionKey, + error, + }); } } } catch (err) { @@ -285,6 +407,18 @@ const MAX_RECOVERY_RETRIES = 3; /** Backoff multiplier between retries (exponential). */ const RETRY_BACKOFF_MULTIPLIER = 2; +function buildRecoveryFailureMessage(params: { attempts: number; error?: string }): string { + const base = + `Subagent run was interrupted by a gateway restart or connection loss. ` + + `Automatic recovery failed after ${params.attempts} attempt${params.attempts === 1 ? "" : "s"}. ` + + `Please retry.`; + const detail = params.error?.trim(); + if (!detail) { + return base; + } + return `${base} (${detail})`; +} + /** * Schedule orphan recovery after a delay, with retry logic. * The delay gives the gateway time to fully bootstrap after restart. @@ -299,10 +433,17 @@ export function scheduleOrphanRecovery(params: { const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; const resumedSessionKeys = new Set(); + const notifiedRecoverySessionKeys = new Set(); const attemptRecovery = (attempt: number, delay: number) => { setTimeout(() => { - void recoverOrphanedSubagentSessions({ ...params, resumedSessionKeys }) + void recoverOrphanedSubagentSessions({ + ...params, + resumedSessionKeys, + attemptNumber: attempt + 1, + maxAttempts: maxRetries + 1, + notifiedRecoverySessionKeys, + }) .then((result) => { if (result.failed > 0 && attempt < maxRetries) { const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER; @@ -310,7 +451,24 @@ export function scheduleOrphanRecovery(params: { `orphan recovery had ${result.failed} failure(s); retrying in ${nextDelay}ms (attempt ${attempt + 1}/${maxRetries})`, ); attemptRecovery(attempt + 1, nextDelay); + return; } + if (result.failedRuns.length === 0) { + return; + } + const attempts = attempt + 1; + void Promise.allSettled( + result.failedRuns.map((run) => + finalizeInterruptedSubagentRun({ + runId: run.runId, + childSessionKey: run.childSessionKey, + error: buildRecoveryFailureMessage({ + attempts, + error: run.error, + }), + }), + ), + ); }) .catch((err) => { if (attempt < maxRetries) { diff --git a/src/agents/subagent-registry-read.ts b/src/agents/subagent-registry-read.ts index 95f16c1d283..5a839e3c034 100644 --- a/src/agents/subagent-registry-read.ts +++ b/src/agents/subagent-registry-read.ts @@ -1,3 +1,4 @@ +import { getAgentRunContext } from "../infra/agent-events.js"; import { subagentRuns } from "./subagent-registry-memory.js"; import { countActiveDescendantRunsFromRuns, @@ -47,6 +48,15 @@ export function getSubagentRunByChildSessionKey(childSessionKey: string): Subage ); } +export function isSubagentRunLive( + entry: Pick | null | undefined, +): boolean { + if (!entry || typeof entry.endedAt === "number") { + return false; + } + return Boolean(getAgentRunContext(entry.runId)); +} + export function getSessionDisplaySubagentRunByChildSessionKey( childSessionKey: string, ): SubagentRunRecord | null { diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index b93a20f2910..25fb267f689 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -6,7 +6,7 @@ import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { createRunningTaskRun } from "../tasks/detached-task-runtime.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; -import { waitForAgentRun } from "./run-wait.js"; +import { isRecoverableAgentWaitError, waitForAgentRun } from "./run-wait.js"; import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js"; import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js"; import { @@ -30,6 +30,7 @@ import { import type { SubagentRunRecord } from "./subagent-registry.types.js"; const log = createSubsystemLogger("agents/subagent-registry"); +const RECOVERABLE_WAIT_RETRY_DELAY_MS = process.env.OPENCLAW_TEST_FAST === "1" ? 25 : 5_000; function shouldDeleteAttachments(entry: SubagentRunRecord) { return entry.cleanup === "delete" || !entry.retainAttachmentsOnKeep; @@ -75,6 +76,7 @@ export function createSubagentRunManager(params: { resumeSubagentRun(runId: string): void; clearPendingLifecycleError(runId: string): void; resolveSubagentWaitTimeoutMs(cfg: OpenClawConfig, runTimeoutSeconds?: number): number; + scheduleOrphanRecovery(args?: { delayMs?: number; maxRetries?: number }): void; notifyContextEngineSubagentEnded(args: { childSessionKey: string; reason: "completed" | "deleted" | "released"; @@ -114,6 +116,26 @@ export function createSubagentRunManager(params: { if (wait.status === "pending") { return; } + if (wait.status === "error" && isRecoverableAgentWaitError(wait.error)) { + log.info("subagent wait interrupted; scheduling recovery", { + runId, + childSessionKey: expectedEntry?.childSessionKey ?? entry?.childSessionKey, + error: wait.error, + }); + params.scheduleOrphanRecovery({ delayMs: 1_000 }); + const scheduledEntry = entry; + setTimeout(() => { + if (!scheduledEntry) { + return; + } + const current = params.runs.get(runId); + if (!current || current !== scheduledEntry || typeof current.endedAt === "number") { + return; + } + void waitForSubagentCompletion(runId, waitTimeoutMs, scheduledEntry); + }, RECOVERABLE_WAIT_RETRY_DELAY_MS).unref?.(); + return; + } let mutated = false; if (typeof wait.startedAt === "number") { entry.startedAt = wait.startedAt; diff --git a/src/agents/subagent-registry-steer-runtime.ts b/src/agents/subagent-registry-steer-runtime.ts index 8bd78f58b56..fdf82e86dbd 100644 --- a/src/agents/subagent-registry-steer-runtime.ts +++ b/src/agents/subagent-registry-steer-runtime.ts @@ -10,14 +10,32 @@ export type ReplaceSubagentRunAfterSteerParams = { type ReplaceSubagentRunAfterSteerFn = (params: ReplaceSubagentRunAfterSteerParams) => boolean; +type FinalizeInterruptedSubagentRunParams = { + runId?: string; + childSessionKey?: string; + error: string; + endedAt?: number; +}; + +type FinalizeInterruptedSubagentRunFn = ( + params: FinalizeInterruptedSubagentRunParams, +) => Promise; + let replaceSubagentRunAfterSteerImpl: ReplaceSubagentRunAfterSteerFn | null = null; +let finalizeInterruptedSubagentRunImpl: FinalizeInterruptedSubagentRunFn | null = null; export function configureSubagentRegistrySteerRuntime(params: { replaceSubagentRunAfterSteer: ReplaceSubagentRunAfterSteerFn; + finalizeInterruptedSubagentRun?: FinalizeInterruptedSubagentRunFn; }) { replaceSubagentRunAfterSteerImpl = params.replaceSubagentRunAfterSteer; + finalizeInterruptedSubagentRunImpl = params.finalizeInterruptedSubagentRun ?? null; } export function replaceSubagentRunAfterSteer(params: ReplaceSubagentRunAfterSteerParams) { return replaceSubagentRunAfterSteerImpl?.(params) ?? false; } + +export async function finalizeInterruptedSubagentRun(params: FinalizeInterruptedSubagentRunParams) { + return (await finalizeInterruptedSubagentRunImpl?.(params)) ?? 0; +} diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 38834f1640f..4f83a2458ff 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -10,6 +10,7 @@ const waitForFast = (callback: () => T | Promise) => const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), onAgentEvent: vi.fn(() => noop), + getAgentRunContext: vi.fn(() => undefined), loadConfig: vi.fn(() => ({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, session: { mainKey: "main", scope: "per-sender" as const }, @@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => ({ onSubagentEnded: vi.fn(async () => {}), runSubagentEnded: vi.fn(async () => {}), resolveAgentTimeoutMs: vi.fn(() => 1_000), + scheduleOrphanRecovery: vi.fn(), })); vi.mock("../gateway/call.js", () => ({ @@ -43,6 +45,7 @@ vi.mock("../gateway/call.js", () => ({ })); vi.mock("../infra/agent-events.js", () => ({ + getAgentRunContext: mocks.getAgentRunContext, onAgentEvent: mocks.onAgentEvent, })); @@ -98,6 +101,10 @@ vi.mock("./timeout.js", () => ({ resolveAgentTimeoutMs: mocks.resolveAgentTimeoutMs, })); +vi.mock("./subagent-orphan-recovery.js", () => ({ + scheduleOrphanRecovery: mocks.scheduleOrphanRecovery, +})); + describe("subagent registry seam flow", () => { let mod: typeof import("./subagent-registry.js"); @@ -110,6 +117,7 @@ describe("subagent registry seam flow", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); mocks.onAgentEvent.mockReturnValue(noop); + mocks.getAgentRunContext.mockReturnValue(undefined); mocks.loadConfig.mockReturnValue({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, session: { mainKey: "main", scope: "per-sender" as const }, @@ -128,6 +136,7 @@ describe("subagent registry seam flow", () => { mocks.resolveContextEngine.mockResolvedValue({ onSubagentEnded: mocks.onSubagentEnded, }); + mocks.scheduleOrphanRecovery.mockReset(); mocks.callGateway.mockImplementation(async (request: { method?: string }) => { if (request.method === "agent.wait") { return { @@ -160,6 +169,129 @@ describe("subagent registry seam flow", () => { vi.useRealTimers(); }); + it("schedules orphan recovery instead of terminally failing on recoverable wait transport errors", async () => { + mocks.callGateway.mockImplementation(async (request: { method?: string }) => { + if (request.method === "agent.wait") { + throw new Error("gateway closed (1006): transport close"); + } + return {}; + }); + + mod.registerSubagentRun({ + runId: "run-interrupted-wait", + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "resume after transport close", + cleanup: "keep", + }); + + await waitForFast(() => { + expect(mocks.scheduleOrphanRecovery).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 1_000 }), + ); + }); + expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); + const run = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-interrupted-wait"); + expect(run?.endedAt).toBeUndefined(); + expect(run?.outcome).toBeUndefined(); + }); + + it("reconciles stale active runs from persisted terminal session state during sweep", async () => { + mocks.callGateway.mockImplementation(async (request: { method?: string }) => { + if (request.method === "agent.wait") { + return { status: "pending" }; + } + return {}; + }); + const persistedStartedAt = Date.parse("2026-03-24T11:58:00Z"); + const persistedEndedAt = persistedStartedAt + 111; + mocks.loadSessionStore.mockReturnValue({ + "agent:main:subagent:child": { + sessionId: "sess-child", + updatedAt: persistedEndedAt, + status: "done", + startedAt: persistedStartedAt, + endedAt: persistedEndedAt, + runtimeMs: 111, + }, + }); + + mod.registerSubagentRun({ + runId: "run-stale-terminal", + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "settle from persisted terminal state", + cleanup: "keep", + }); + + vi.setSystemTime(new Date("2026-03-24T12:02:00Z")); + await mod.__testing.sweepOnceForTests(); + + await waitForFast(() => { + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + childRunId: "run-stale-terminal", + outcome: expect.objectContaining({ status: "ok", endedAt: persistedEndedAt }), + }), + ); + }); + + const run = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-stale-terminal"); + expect(run?.endedAt).toBe(persistedEndedAt); + expect(run?.outcome).toMatchObject({ + status: "ok", + endedAt: persistedEndedAt, + }); + expect(run?.cleanupCompletedAt).toBeTypeOf("number"); + }); + + it("requeues orphan recovery instead of keeping restart-aborted stale runs stuck as running", async () => { + mocks.callGateway.mockImplementation(async (request: { method?: string }) => { + if (request.method === "agent.wait") { + return { status: "pending" }; + } + return {}; + }); + mocks.loadSessionStore.mockReturnValue({ + "agent:main:subagent:child": { + sessionId: "sess-child", + updatedAt: 333, + status: "running", + abortedLastRun: true, + }, + }); + + mod.registerSubagentRun({ + runId: "run-stale-aborted", + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "resume after restart", + cleanup: "keep", + }); + + vi.setSystemTime(new Date("2026-03-24T12:02:00Z")); + await mod.__testing.sweepOnceForTests(); + + await waitForFast(() => { + expect(mocks.scheduleOrphanRecovery).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 1_000 }), + ); + }); + expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); + const run = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-stale-aborted"); + expect(run?.endedAt).toBeUndefined(); + expect(run?.outcome).toBeUndefined(); + }); + it("completes a registered run across timing persistence, lifecycle status, and announce cleanup", async () => { mod.registerSubagentRun({ runId: "run-1", @@ -226,6 +358,68 @@ describe("subagent registry seam flow", () => { expect(mocks.persistSubagentRunsToDisk).toHaveBeenCalled(); }); + it("suppresses stale timeout announces when the same child run later finishes successfully", async () => { + mocks.callGateway.mockImplementation(async (request: { method?: string }) => { + if (request.method === "agent.wait") { + return { status: "pending" }; + } + return {}; + }); + + mod.registerSubagentRun({ + runId: "run-timeout-then-ok", + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "timeout retry", + cleanup: "keep", + expectsCompletionMessage: true, + }); + + const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[ + mocks.onAgentEvent.mock.calls.length - 1 + ] as unknown as + | [(evt: { runId: string; stream: string; data: Record }) => void] + | undefined; + const lifecycleHandler = lastOnAgentEventCall?.[0]; + expect(lifecycleHandler).toBeTypeOf("function"); + + lifecycleHandler?.({ + runId: "run-timeout-then-ok", + stream: "lifecycle", + data: { phase: "end", endedAt: 1_000, aborted: true }, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(14_999); + expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); + + lifecycleHandler?.({ + runId: "run-timeout-then-ok", + stream: "lifecycle", + data: { phase: "end", endedAt: 1_250 }, + }); + + await waitForFast(() => { + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + }); + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + childRunId: "run-timeout-then-ok", + outcome: expect.objectContaining({ + status: "ok", + endedAt: 1_250, + }), + }), + ); + + await vi.advanceTimersByTimeAsync(20_000); + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + }); + it("deletes delete-mode completion runs when announce cleanup gives up after retry limit", async () => { mocks.runSubagentAnnounceFlow.mockResolvedValue(false); const endedAt = Date.parse("2026-03-24T12:00:00Z"); @@ -501,6 +695,60 @@ describe("subagent registry seam flow", () => { }); }); + it("announces readable failure when an interrupted run is finalized", async () => { + mod.addSubagentRunForTests({ + runId: "run-interrupted", + childSessionKey: "agent:main:subagent:interrupted", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "quietchat", accountId: "acct-interrupted" }, + requesterDisplayKey: "main", + task: "recover interrupted subagent", + cleanup: "keep", + expectsCompletionMessage: true, + spawnMode: "run", + createdAt: 1, + startedAt: 1, + sessionStartedAt: 1, + accumulatedRuntimeMs: 0, + cleanupHandled: false, + }); + + const updated = await mod.finalizeInterruptedSubagentRun({ + runId: "run-interrupted", + error: + "Subagent run was interrupted by a gateway restart or connection loss. Automatic recovery failed after 2 attempts. Please retry.", + endedAt: 2, + }); + + expect(updated).toBe(1); + await waitForFast(() => { + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + childRunId: "run-interrupted", + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "quietchat", accountId: "acct-interrupted" }, + outcome: expect.objectContaining({ + status: "error", + error: expect.stringContaining("Automatic recovery failed after 2 attempts"), + }), + }), + ); + }); + const run = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-interrupted"); + expect(run?.outcome).toEqual({ + status: "error", + error: + "Subagent run was interrupted by a gateway restart or connection loss. Automatic recovery failed after 2 attempts. Please retry.", + startedAt: 1, + endedAt: 2, + elapsedMs: 1, + }); + expect(run?.cleanupCompletedAt).toBeTypeOf("number"); + }); + it("removes attachments for released delete-mode runs", async () => { const attachmentsRootDir = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-release-attachments-"), diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index cf6047bb567..cbd5044a288 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,9 +1,15 @@ import type { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngine, SubagentEndReason } from "../context-engine/types.js"; import { callGateway } from "../gateway/call.js"; -import { onAgentEvent } from "../infra/agent-events.js"; +import { getAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { registerPendingSpawnedChildrenQuery } from "../infra/outbound/pending-spawn-query.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { importRuntimeModule } from "../shared/runtime-import.js"; @@ -166,10 +172,103 @@ const SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000; * subsequent lifecycle `start` / `end` can cancel premature failure announces. */ const LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000; +/** + * Embedded runs can also surface an intermediate lifecycle `end` with + * `aborted=true` just before the runtime automatically retries the same run. + * Give that timeout a short grace window so the parent does not get a stale + * `timed out` completion right before the eventual success. + */ +const LIFECYCLE_TIMEOUT_RETRY_GRACE_MS = 15_000; /** Absolute TTL for session-mode runs after cleanup completes (no archiveAtMs). */ const SESSION_RUN_TTL_MS = 5 * 60_000; // 5 minutes -/** Absolute TTL for orphaned pendingLifecycleError entries. */ -const PENDING_ERROR_TTL_MS = 5 * 60_000; // 5 minutes +/** Absolute TTL for orphaned pendingLifecycleError / pendingLifecycleTimeout entries. */ +const PENDING_LIFECYCLE_TERMINAL_TTL_MS = 5 * 60_000; // 5 minutes +/** Grace period before treating a "running" subagent without a live run context as stale. */ +const STALE_ACTIVE_SUBAGENT_GRACE_MS = process.env.OPENCLAW_TEST_FAST === "1" ? 1_000 : 60_000; + +function findSessionEntryByKey(store: Record, sessionKey: string) { + const direct = store[sessionKey]; + if (direct) { + return direct; + } + const normalized = sessionKey.trim().toLowerCase(); + for (const [key, entry] of Object.entries(store)) { + if (key.trim().toLowerCase() === normalized) { + return entry; + } + } + return undefined; +} + +function loadSubagentSessionEntry( + childSessionKey: string, + storeCache: Map>, +): SessionEntry | undefined { + const key = childSessionKey.trim(); + if (!key) { + return undefined; + } + const agentId = resolveAgentIdFromSessionKey(key); + const storePath = resolveStorePath(loadConfig().session?.store, { agentId }); + let store = storeCache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + storeCache.set(storePath, store); + } + return findSessionEntryByKey(store, key); +} + +function resolveCompletionFromSessionEntry( + sessionEntry: SessionEntry | undefined, + fallbackEndedAt: number, +): { + endedAt: number; + outcome: SubagentRunOutcome; + reason: SubagentLifecycleEndedReason; +} | null { + const status = sessionEntry?.status; + const endedAt = + typeof sessionEntry?.endedAt === "number" && Number.isFinite(sessionEntry.endedAt) + ? sessionEntry.endedAt + : fallbackEndedAt; + + if (status === "done") { + return { + endedAt, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + }; + } + if (status === "timeout") { + return { + endedAt, + outcome: { status: "timeout" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + }; + } + if (status === "failed") { + return { + endedAt, + outcome: { status: "error", error: "session completed before registry settled" }, + reason: SUBAGENT_ENDED_REASON_ERROR, + }; + } + if (status === "killed") { + return { + endedAt, + outcome: { status: "error", error: "subagent run terminated" }, + reason: SUBAGENT_ENDED_REASON_KILLED, + }; + } + if (status !== "running" && typeof sessionEntry?.endedAt === "number") { + return { + endedAt, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + }; + } + return null; +} function loadContextEngineInitModule(): Promise { contextEngineInitPromise ??= importRuntimeModule( @@ -254,6 +353,13 @@ const pendingLifecycleErrorByRunId = new Map< error?: string; } >(); +const pendingLifecycleTimeoutByRunId = new Map< + string, + { + timer: NodeJS.Timeout; + endedAt: number; + } +>(); function clearPendingLifecycleError(runId: string) { const pending = pendingLifecycleErrorByRunId.get(runId); @@ -271,7 +377,24 @@ function clearAllPendingLifecycleErrors() { pendingLifecycleErrorByRunId.clear(); } +function clearPendingLifecycleTimeout(runId: string) { + const pending = pendingLifecycleTimeoutByRunId.get(runId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + pendingLifecycleTimeoutByRunId.delete(runId); +} + +function clearAllPendingLifecycleTimeouts() { + for (const pending of pendingLifecycleTimeoutByRunId.values()) { + clearTimeout(pending.timer); + } + pendingLifecycleTimeoutByRunId.clear(); +} + function schedulePendingLifecycleError(params: { runId: string; endedAt: number; error?: string }) { + clearPendingLifecycleTimeout(params.runId); clearPendingLifecycleError(params.runId); const timer = setTimeout(() => { const pending = pendingLifecycleErrorByRunId.get(params.runId); @@ -307,6 +430,41 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number; }); } +function schedulePendingLifecycleTimeout(params: { runId: string; endedAt: number }) { + clearPendingLifecycleError(params.runId); + clearPendingLifecycleTimeout(params.runId); + const timer = setTimeout(() => { + const pending = pendingLifecycleTimeoutByRunId.get(params.runId); + if (!pending || pending.timer !== timer) { + return; + } + pendingLifecycleTimeoutByRunId.delete(params.runId); + const entry = subagentRuns.get(params.runId); + if (!entry) { + return; + } + if (entry.outcome?.status === "ok") { + return; + } + void completeSubagentRun({ + runId: params.runId, + endedAt: pending.endedAt, + outcome: { + status: "timeout", + }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + }, LIFECYCLE_TIMEOUT_RETRY_GRACE_MS); + timer.unref?.(); + pendingLifecycleTimeoutByRunId.set(params.runId, { + timer, + endedAt: params.endedAt, + }); +} + async function notifyContextEngineSubagentEnded(params: { childSessionKey: string; reason: SubagentEndReason; @@ -576,8 +734,69 @@ async function sweepSubagentRuns() { sweepInProgress = true; try { const now = Date.now(); + const storeCache = new Map>(); let mutated = false; for (const [runId, entry] of subagentRuns.entries()) { + if (typeof entry.endedAt !== "number") { + const hasLiveRunContext = Boolean(getAgentRunContext(runId)); + const activeAgeMs = now - (entry.startedAt ?? entry.createdAt); + if (!hasLiveRunContext && activeAgeMs >= STALE_ACTIVE_SUBAGENT_GRACE_MS) { + const orphanReason = resolveSubagentRunOrphanReason({ + entry, + storeCache, + }); + if (orphanReason) { + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "resume", + runs: subagentRuns, + resumedRuns, + }) + ) { + mutated = true; + } + continue; + } + + const sessionEntry = loadSubagentSessionEntry(entry.childSessionKey, storeCache); + const completion = resolveCompletionFromSessionEntry(sessionEntry, now); + if (completion) { + await completeSubagentRun({ + runId, + endedAt: completion.endedAt, + outcome: completion.outcome, + reason: completion.reason, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + continue; + } + + if (sessionEntry?.abortedLastRun === true) { + scheduleSubagentOrphanRecovery({ delayMs: 1_000 }); + continue; + } + + await completeSubagentRun({ + runId, + endedAt: now, + outcome: { + status: "error", + error: "subagent run lost active execution context", + }, + reason: SUBAGENT_ENDED_REASON_ERROR, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + continue; + } + } + // Session-mode runs have no archiveAtMs — apply absolute TTL after cleanup completes. // Use cleanupCompletedAt (not endedAt) to avoid interrupting deferred cleanup flows. if (!entry.archiveAtMs) { @@ -633,10 +852,15 @@ async function sweepSubagentRuns() { } // Sweep orphaned pendingLifecycleError entries (absolute TTL). for (const [runId, pending] of pendingLifecycleErrorByRunId.entries()) { - if (now - pending.endedAt > PENDING_ERROR_TTL_MS) { + if (now - pending.endedAt > PENDING_LIFECYCLE_TERMINAL_TTL_MS) { clearPendingLifecycleError(runId); } } + for (const [runId, pending] of pendingLifecycleTimeoutByRunId.entries()) { + if (now - pending.endedAt > PENDING_LIFECYCLE_TERMINAL_TTL_MS) { + clearPendingLifecycleTimeout(runId); + } + } if (mutated) { persistSubagentRuns(); @@ -669,6 +893,7 @@ function ensureListener() { } if (phase === "start") { clearPendingLifecycleError(evt.runId); + clearPendingLifecycleTimeout(evt.runId); const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined; if (startedAt) { entry.startedAt = startedAt; @@ -692,14 +917,19 @@ function ensureListener() { }); return; } + if (evt.data?.aborted) { + schedulePendingLifecycleTimeout({ + runId: evt.runId, + endedAt, + }); + return; + } clearPendingLifecycleError(evt.runId); - const outcome: SubagentRunOutcome = evt.data?.aborted - ? { status: "timeout" } - : { status: "ok" }; + clearPendingLifecycleTimeout(evt.runId); await completeSubagentRun({ runId: evt.runId, endedAt, - outcome, + outcome: { status: "ok" }, reason: SUBAGENT_ENDED_REASON_COMPLETE, sendFarewell: true, accountId: entry.requesterOrigin?.accountId, @@ -727,6 +957,7 @@ const subagentRunManager = createSubagentRunManager({ resumeSubagentRun, clearPendingLifecycleError, resolveSubagentWaitTimeoutMs, + scheduleOrphanRecovery: (args) => scheduleSubagentOrphanRecovery(args), notifyContextEngineSubagentEnded, completeCleanupBookkeeping, completeSubagentRun, @@ -734,6 +965,7 @@ const subagentRunManager = createSubagentRunManager({ configureSubagentRegistrySteerRuntime({ replaceSubagentRunAfterSteer: (params) => subagentRunManager.replaceSubagentRunAfterSteer(params), + finalizeInterruptedSubagentRun: async (params) => await finalizeInterruptedSubagentRun(params), }); export function markSubagentRunForSteerRestart(runId: string) { @@ -768,6 +1000,7 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { resumedRuns.clear(); endedHookInFlightRunIds.clear(); clearAllPendingLifecycleErrors(); + clearAllPendingLifecycleTimeouts(); contextEngineInitPromise = null; contextEngineRegistryPromise = null; runtimePluginsPromise = null; @@ -788,6 +1021,9 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { } export const __testing = { + async sweepOnceForTests() { + await sweepSubagentRuns(); + }, setDepsForTest(overrides?: Partial) { subagentRegistryDeps = overrides ? { @@ -806,6 +1042,57 @@ export function releaseSubagentRun(runId: string) { subagentRunManager.releaseSubagentRun(runId); } +export async function finalizeInterruptedSubagentRun(params: { + runId?: string; + childSessionKey?: string; + error: string; + endedAt?: number; +}): Promise { + const runIds = new Set(); + if (typeof params.runId === "string" && params.runId.trim()) { + runIds.add(params.runId.trim()); + } + if (typeof params.childSessionKey === "string" && params.childSessionKey.trim()) { + const childSessionKey = params.childSessionKey.trim(); + for (const [runId, entry] of subagentRuns.entries()) { + if (entry.childSessionKey === childSessionKey) { + runIds.add(runId); + } + } + } + if (runIds.size === 0) { + return 0; + } + + const endedAt = + typeof params.endedAt === "number" && Number.isFinite(params.endedAt) + ? params.endedAt + : Date.now(); + let updated = 0; + for (const runId of runIds) { + clearPendingLifecycleError(runId); + clearPendingLifecycleTimeout(runId); + const entry = subagentRuns.get(runId); + if (!entry || typeof entry.cleanupCompletedAt === "number") { + continue; + } + await completeSubagentRun({ + runId, + endedAt, + outcome: { + status: "error", + error: params.error, + }, + reason: SUBAGENT_ENDED_REASON_ERROR, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + updated += 1; + } + return updated; +} + export function resolveRequesterForChildSession(childSessionKey: string): { requesterSessionKey: string; requesterOrigin?: DeliveryContext; diff --git a/src/gateway/server-methods/agent-job.ts b/src/gateway/server-methods/agent-job.ts index 2c7e7a6aeba..360e445f697 100644 --- a/src/gateway/server-methods/agent-job.ts +++ b/src/gateway/server-methods/agent-job.ts @@ -7,10 +7,18 @@ const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000; * subsequent `start` event can cancel premature terminal snapshots. */ const AGENT_RUN_ERROR_RETRY_GRACE_MS = 15_000; +/** + * Some embedded runtimes emit an intermediate lifecycle `end` with + * `aborted=true` immediately before retrying the same run. Hold timeout + * snapshots briefly so `agent.wait` does not resolve to a stale timeout when a + * final success is about to arrive. + */ +const AGENT_RUN_TIMEOUT_RETRY_GRACE_MS = 15_000; const agentRunCache = new Map(); const agentRunStarts = new Map(); const pendingAgentRunErrors = new Map(); +const pendingAgentRunTimeouts = new Map(); let agentRunListenerStarted = false; type AgentRunSnapshot = { @@ -22,12 +30,14 @@ type AgentRunSnapshot = { ts: number; }; -type PendingAgentRunError = { +type PendingAgentRunTerminal = { snapshot: AgentRunSnapshot; dueAt: number; timer: NodeJS.Timeout; }; +type PendingAgentRunError = PendingAgentRunTerminal; + function pruneAgentRunCache(now = Date.now()) { for (const [runId, entry] of agentRunCache) { if (now - entry.ts > AGENT_RUN_CACHE_TTL_MS) { @@ -50,7 +60,17 @@ function clearPendingAgentRunError(runId: string) { pendingAgentRunErrors.delete(runId); } +function clearPendingAgentRunTimeout(runId: string) { + const pending = pendingAgentRunTimeouts.get(runId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + pendingAgentRunTimeouts.delete(runId); +} + function schedulePendingAgentRunError(snapshot: AgentRunSnapshot) { + clearPendingAgentRunTimeout(snapshot.runId); clearPendingAgentRunError(snapshot.runId); const dueAt = Date.now() + AGENT_RUN_ERROR_RETRY_GRACE_MS; const timer = setTimeout(() => { @@ -65,6 +85,22 @@ function schedulePendingAgentRunError(snapshot: AgentRunSnapshot) { pendingAgentRunErrors.set(snapshot.runId, { snapshot, dueAt, timer }); } +function schedulePendingAgentRunTimeout(snapshot: AgentRunSnapshot) { + clearPendingAgentRunError(snapshot.runId); + clearPendingAgentRunTimeout(snapshot.runId); + const dueAt = Date.now() + AGENT_RUN_TIMEOUT_RETRY_GRACE_MS; + const timer = setTimeout(() => { + const pending = pendingAgentRunTimeouts.get(snapshot.runId); + if (!pending) { + return; + } + pendingAgentRunTimeouts.delete(snapshot.runId); + recordAgentRunSnapshot(pending.snapshot); + }, AGENT_RUN_TIMEOUT_RETRY_GRACE_MS); + timer.unref?.(); + pendingAgentRunTimeouts.set(snapshot.runId, { snapshot, dueAt, timer }); +} + function getPendingAgentRunError(runId: string) { const pending = pendingAgentRunErrors.get(runId); if (!pending) { @@ -76,6 +112,17 @@ function getPendingAgentRunError(runId: string) { }; } +function getPendingAgentRunTimeout(runId: string) { + const pending = pendingAgentRunTimeouts.get(runId); + if (!pending) { + return undefined; + } + return { + snapshot: pending.snapshot, + dueAt: pending.dueAt, + }; +} + function createSnapshotFromLifecycleEvent(params: { runId: string; phase: "end" | "error"; @@ -113,6 +160,7 @@ function ensureAgentRunListener() { const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined; agentRunStarts.set(evt.runId, startedAt ?? Date.now()); clearPendingAgentRunError(evt.runId); + clearPendingAgentRunTimeout(evt.runId); // A new start means this run is active again (or retried). Drop stale // terminal snapshots so waiters don't resolve from old state. agentRunCache.delete(evt.runId); @@ -131,7 +179,12 @@ function ensureAgentRunListener() { schedulePendingAgentRunError(snapshot); return; } + if (snapshot.status === "timeout") { + schedulePendingAgentRunTimeout(snapshot); + return; + } clearPendingAgentRunError(evt.runId); + clearPendingAgentRunTimeout(evt.runId); recordAgentRunSnapshot(snapshot); }); } @@ -160,6 +213,7 @@ export async function waitForAgentJob(params: { return await new Promise((resolve) => { let settled = false; let pendingErrorTimer: NodeJS.Timeout | undefined; + let pendingTimeoutTimer: NodeJS.Timeout | undefined; let onAbort: (() => void) | undefined; const clearPendingErrorTimer = () => { @@ -170,6 +224,14 @@ export async function waitForAgentJob(params: { pendingErrorTimer = undefined; }; + const clearPendingTimeoutTimer = () => { + if (!pendingTimeoutTimer) { + return; + } + clearTimeout(pendingTimeoutTimer); + pendingTimeoutTimer = undefined; + }; + const finish = (entry: AgentRunSnapshot | null) => { if (settled) { return; @@ -177,6 +239,7 @@ export async function waitForAgentJob(params: { settled = true; clearTimeout(timer); clearPendingErrorTimer(); + clearPendingTimeoutTimer(); unsubscribe(); if (onAbort) { signal?.removeEventListener("abort", onAbort); @@ -184,13 +247,15 @@ export async function waitForAgentJob(params: { resolve(entry); }; - const scheduleErrorFinish = ( + const scheduleTerminalFinish = ( + kind: "error" | "timeout", snapshot: AgentRunSnapshot, - delayMs = AGENT_RUN_ERROR_RETRY_GRACE_MS, + delayMs: number, ) => { clearPendingErrorTimer(); + clearPendingTimeoutTimer(); const effectiveDelay = Math.max(1, Math.min(Math.floor(delayMs), 2_147_483_647)); - pendingErrorTimer = setTimeout(() => { + const timerRef = setTimeout(() => { const latest = ignoreCachedSnapshot ? undefined : getCachedAgentRun(runId); if (latest) { finish(latest); @@ -199,13 +264,36 @@ export async function waitForAgentJob(params: { recordAgentRunSnapshot(snapshot); finish(snapshot); }, effectiveDelay); - pendingErrorTimer.unref?.(); + timerRef.unref?.(); + if (kind === "error") { + pendingErrorTimer = timerRef; + } else { + pendingTimeoutTimer = timerRef; + } + }; + + const scheduleErrorFinish = ( + snapshot: AgentRunSnapshot, + delayMs = AGENT_RUN_ERROR_RETRY_GRACE_MS, + ) => { + scheduleTerminalFinish("error", snapshot, delayMs); + }; + + const scheduleTimeoutFinish = ( + snapshot: AgentRunSnapshot, + delayMs = AGENT_RUN_TIMEOUT_RETRY_GRACE_MS, + ) => { + scheduleTerminalFinish("timeout", snapshot, delayMs); }; if (!ignoreCachedSnapshot) { - const pending = getPendingAgentRunError(runId); - if (pending) { - scheduleErrorFinish(pending.snapshot, pending.dueAt - Date.now()); + const pendingError = getPendingAgentRunError(runId); + if (pendingError) { + scheduleErrorFinish(pendingError.snapshot, pendingError.dueAt - Date.now()); + } + const pendingTimeout = getPendingAgentRunTimeout(runId); + if (pendingTimeout) { + scheduleTimeoutFinish(pendingTimeout.snapshot, pendingTimeout.dueAt - Date.now()); } } @@ -219,6 +307,7 @@ export async function waitForAgentJob(params: { const phase = evt.data?.phase; if (phase === "start") { clearPendingErrorTimer(); + clearPendingTimeoutTimer(); return; } if (phase !== "end" && phase !== "error") { @@ -238,6 +327,10 @@ export async function waitForAgentJob(params: { scheduleErrorFinish(snapshot); return; } + if (snapshot.status === "timeout") { + scheduleTimeoutFinish(snapshot); + return; + } recordAgentRunSnapshot(snapshot); finish(snapshot); }); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index aa3323cee83..fb3e146457b 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -54,17 +54,32 @@ describe("waitForAgentJob", () => { return waitPromise; } - it("maps lifecycle end events with aborted=true to timeout", async () => { - const snapshot = await runLifecycleScenario({ - runIdPrefix: "run-timeout", - startedAt: 100, - endedAt: 200, - aborted: true, - }); - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(100); - expect(snapshot?.endedAt).toBe(200); + it("maps lifecycle end events with aborted=true to timeout after the retry grace window", async () => { + vi.useFakeTimers(); + try { + const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const snapshotPromise = waitForAgentJob({ runId, timeoutMs: 20_000 }); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt: 100 }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", endedAt: 200, aborted: true }, + }); + + 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); + } finally { + vi.useRealTimers(); + } }); it("keeps non-aborted lifecycle end events as ok", async () => { @@ -79,6 +94,104 @@ describe("waitForAgentJob", () => { expect(snapshot?.endedAt).toBe(400); }); + it("ignores transient aborted end events when the same run later succeeds", async () => { + const runId = `run-timeout-retry-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt: 500 }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt: 500, endedAt: 600, aborted: true }, + }); + + queueMicrotask(() => { + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt: 500, endedAt: 700 }, + }); + }); + + const snapshot = await waitPromise; + expect(snapshot).not.toBeNull(); + expect(snapshot?.status).toBe("ok"); + expect(snapshot?.startedAt).toBe(500); + expect(snapshot?.endedAt).toBe(700); + }); + + it("lets a later aborted timeout replace a pending lifecycle error", async () => { + vi.useFakeTimers(); + try { + const runId = `run-error-then-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const waitPromise = waitForAgentJob({ runId, timeoutMs: 20_000 }); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt: 800 }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "error", startedAt: 800, endedAt: 900, error: "transient error" }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt: 800, endedAt: 1_000, aborted: true }, + }); + + 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?.error).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("lets a later lifecycle error replace a pending aborted timeout", async () => { + vi.useFakeTimers(); + try { + const runId = `run-timeout-then-error-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const waitPromise = waitForAgentJob({ runId, timeoutMs: 20_000 }); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt: 1_100 }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt: 1_100, endedAt: 1_200, aborted: true }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "error", startedAt: 1_100, endedAt: 1_300, error: "final error" }, + }); + + 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"); + } finally { + vi.useRealTimers(); + } + }); + it("can ignore cached snapshots and wait for fresh lifecycle events", async () => { const runId = `run-ignore-cache-${Date.now()}-${Math.random().toString(36).slice(2)}`; emitAgentEvent({ diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 8812076c04e..7d67d1a7e0b 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -72,6 +72,8 @@ function buildGatewaySessionSnapshot(params: { modelProvider: sessionRow.modelProvider, model: sessionRow.model, status: sessionRow.status, + subagentRunState: sessionRow.subagentRunState, + hasActiveSubagentRun: sessionRow.hasActiveSubagentRun, startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index 28bc2883588..9a8af74397c 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -8,6 +8,7 @@ import { } from "../agents/subagent-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; +import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; import { listSessionsFromStore } from "./session-utils.js"; function createModelDefaultsConfig(params: { @@ -118,6 +119,7 @@ function listSingleSession(params: { describe("listSessionsFromStore search", () => { afterEach(() => { resetSubagentRegistryForTests({ persist: false }); + resetAgentRunContextForTest(); }); const baseCfg = { @@ -494,6 +496,9 @@ describe("listSessionsFromStore search", () => { startedAt: now - 4_000, model: "anthropic/claude-sonnet-4-6", }); + registerAgentRunContext("run-child-live", { + sessionKey: "agent:main:subagent:child-live", + }); const result = listSingleSession({ cfg: createAnthropicContext1mConfig(), @@ -545,6 +550,9 @@ describe("listSessionsFromStore search", () => { startedAt: now - 4_000, model: "openai/gpt-5.4", }); + registerAgentRunContext("run-child-live-new-model", { + sessionKey: "agent:main:subagent:child-live-stale-transcript", + }); const result = listSingleSession({ cfg: createAnthropicContext1mConfig(), diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index feba73bb0db..d0627f07d29 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -8,6 +8,7 @@ import { } from "../agents/subagent-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; +import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { withEnv } from "../test-utils/env.js"; import { @@ -19,9 +20,11 @@ import { describe("listSessionsFromStore subagent metadata", () => { afterEach(() => { resetSubagentRegistryForTests({ persist: false }); + resetAgentRunContextForTest(); }); beforeEach(() => { resetSubagentRegistryForTests({ persist: false }); + resetAgentRunContextForTest(); }); const cfg = { @@ -70,6 +73,9 @@ describe("listSessionsFromStore subagent metadata", () => { startedAt: now - 9_000, model: "openai/gpt-5.4", }); + registerAgentRunContext("run-parent", { + sessionKey: "agent:main:subagent:parent", + }); addSubagentRunForTests({ runId: "run-child", childSessionKey: "agent:main:subagent:child", @@ -137,6 +143,49 @@ describe("listSessionsFromStore subagent metadata", () => { expect(failed?.runtimeMs).toBe(5_000); }); + test("does not show stale registry-only subagent runs as actively running", () => { + const now = Date.now(); + const childSessionKey = "agent:main:subagent:stale-display"; + const store: Record = { + [childSessionKey]: { + sessionId: "sess-stale-display", + updatedAt: now - 250, + spawnedBy: "agent:main:main", + status: "done", + startedAt: now - 4_000, + endedAt: now - 500, + runtimeMs: 3_500, + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-stale-display", + childSessionKey, + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "stale display task", + cleanup: "keep", + createdAt: now - 5_000, + startedAt: now - 4_000, + model: "openai/gpt-5.4", + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + const row = result.sessions.find((session) => session.key === childSessionKey); + expect(row?.status).toBe("done"); + expect(row?.subagentRunState).toBe("historical"); + expect(row?.hasActiveSubagentRun).toBe(false); + expect(row?.endedAt).toBe(now - 500); + expect(row?.runtimeMs).toBe(3_500); + }); + test("does not keep childSessions attached to a stale older controller row", () => { const now = Date.now(); const store: Record = { @@ -522,6 +571,9 @@ describe("listSessionsFromStore subagent metadata", () => { accumulatedRuntimeMs: 120_000, model: "openai/gpt-5.4", }); + registerAgentRunContext("run-followup-new", { + sessionKey: "agent:main:subagent:followup", + }); const result = listSessionsFromStore({ cfg, @@ -592,7 +644,7 @@ describe("listSessionsFromStore subagent metadata", () => { }); }); - test("uses persisted active subagent runs when the local worker only has terminal snapshots", async () => { + test("prefers persisted terminal session state when only stale active subagent snapshots remain", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-subagent-")); const stateDir = path.join(tempRoot, "state"); fs.mkdirSync(stateDir, { recursive: true }); @@ -662,10 +714,12 @@ describe("listSessionsFromStore subagent metadata", () => { }, ); - expect(row?.status).toBe("running"); + expect(row?.status).toBe("done"); + expect(row?.subagentRunState).toBe("historical"); + expect(row?.hasActiveSubagentRun).toBe(false); expect(row?.startedAt).toBe(now - 9_000); - expect(row?.endedAt).toBeUndefined(); - expect(row?.runtimeMs).toBeGreaterThanOrEqual(9_000); + expect(row?.endedAt).toBe(now - 1_800); + expect(row?.runtimeMs).toBe(100); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 33b2198b014..53c5ab1d6cc 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -23,6 +23,7 @@ import { getSessionDisplaySubagentRunByChildSessionKey, getSubagentSessionRuntimeMs, getSubagentSessionStartedAt, + isSubagentRunLive, listSubagentRunsForController, resolveSubagentSessionStatus, } from "../agents/subagent-registry-read.js"; @@ -1256,10 +1257,51 @@ export function buildGatewaySessionRow(params: { const subagentOwner = normalizeOptionalString(subagentRun?.controllerSessionKey) || normalizeOptionalString(subagentRun?.requesterSessionKey); - const subagentStatus = subagentRun ? resolveSubagentSessionStatus(subagentRun) : undefined; - const subagentStartedAt = subagentRun ? getSubagentSessionStartedAt(subagentRun) : undefined; - const subagentEndedAt = subagentRun ? subagentRun.endedAt : undefined; - const subagentRuntimeMs = subagentRun ? resolveSessionRuntimeMs(subagentRun, now) : undefined; + const liveSubagentRunActive = isSubagentRunLive(subagentRun); + const persistedSessionStatus = entry?.status; + const persistedSessionEndedAt = entry?.endedAt; + const persistedSessionStartedAt = entry?.startedAt; + const persistedSessionRuntimeMs = entry?.runtimeMs; + const subagentRunState = subagentRun + ? liveSubagentRunActive + ? "active" + : typeof subagentRun.endedAt === "number" || + persistedSessionStatus === "done" || + persistedSessionStatus === "failed" || + persistedSessionStatus === "killed" || + persistedSessionStatus === "timeout" || + typeof persistedSessionEndedAt === "number" + ? "historical" + : "interrupted" + : undefined; + const subagentStatus = subagentRun + ? liveSubagentRunActive + ? resolveSubagentSessionStatus(subagentRun) + : persistedSessionStatus === "running" + ? undefined + : (persistedSessionStatus ?? + (typeof subagentRun.endedAt === "number" + ? resolveSubagentSessionStatus(subagentRun) + : undefined)) + : undefined; + const subagentStartedAt = subagentRun + ? liveSubagentRunActive + ? getSubagentSessionStartedAt(subagentRun) + : (persistedSessionStartedAt ?? getSubagentSessionStartedAt(subagentRun)) + : undefined; + const subagentEndedAt = subagentRun + ? liveSubagentRunActive + ? subagentRun.endedAt + : (persistedSessionEndedAt ?? subagentRun.endedAt) + : undefined; + const subagentRuntimeMs = subagentRun + ? liveSubagentRunActive + ? resolveSessionRuntimeMs(subagentRun, now) + : (persistedSessionRuntimeMs ?? + (typeof subagentRun.endedAt === "number" + ? resolveSessionRuntimeMs(subagentRun, now) + : undefined)) + : undefined; const selectedModel = entry?.modelOverride?.trim() ? resolveSessionModelRef(cfg, entry, sessionAgentId) : null; @@ -1403,6 +1445,8 @@ export function buildGatewaySessionRow(params: { totalTokensFresh, estimatedCostUsd, status: subagentRun ? subagentStatus : entry?.status, + subagentRunState, + hasActiveSubagentRun: subagentRun ? liveSubagentRunActive : undefined, startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, endedAt: subagentRun ? subagentEndedAt : entry?.endedAt, runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index cefd89e9714..125b4820cdd 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -23,6 +23,8 @@ export type GatewayThinkingLevelOption = { export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout"; +export type SubagentRunState = "active" | "interrupted" | "historical"; + export type GatewaySessionRow = { key: string; spawnedBy?: string; @@ -62,6 +64,8 @@ export type GatewaySessionRow = { totalTokensFresh?: boolean; estimatedCostUsd?: number; status?: SessionRunStatus; + subagentRunState?: SubagentRunState; + hasActiveSubagentRun?: boolean; startedAt?: number; endedAt?: number; runtimeMs?: number; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index a592f0e90e9..503421043c5 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -376,6 +376,7 @@ export type AgentsFilesSetResult = { }; export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout"; +export type SubagentRunState = "active" | "interrupted" | "historical"; export type SessionCompactionCheckpointReason = | "manual" @@ -431,6 +432,8 @@ export type GatewaySessionRow = { totalTokens?: number; totalTokensFresh?: boolean; status?: SessionRunStatus; + subagentRunState?: SubagentRunState; + hasActiveSubagentRun?: boolean; startedAt?: number; endedAt?: number; runtimeMs?: number; From 188bce424ba5531d6777044a73e5d03d5066b710 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:09:54 +0100 Subject: [PATCH 91/93] perf: speed up google meet tests --- extensions/google-meet/index.test.ts | 527 +-------------------- extensions/google-meet/index.ts | 37 +- extensions/google-meet/src/cli.test.ts | 555 +++++++++++++++++++++++ extensions/google-meet/src/oauth.test.ts | 69 +++ scripts/test-projects.test-support.mjs | 4 + test/scripts/test-projects.test.ts | 22 + 6 files changed, 679 insertions(+), 535 deletions(-) create mode 100644 extensions/google-meet/src/cli.test.ts create mode 100644 extensions/google-meet/src/oauth.test.ts diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 0ac206e21c5..3d054839787 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1,13 +1,8 @@ import { EventEmitter } from "node:events"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { PassThrough, Writable } from "node:stream"; -import { Command } from "commander"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; -import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js"; import { buildGoogleMeetPreflightReport, @@ -18,20 +13,10 @@ import { fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; -import { - buildGoogleMeetAuthUrl, - refreshGoogleMeetAccessToken, - resolveGoogleMeetAccessToken, -} from "./src/oauth.js"; import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js"; import { startCommandRealtimeAudioBridge } from "./src/realtime.js"; import { normalizeMeetUrl } from "./src/runtime.js"; -import type { GoogleMeetRuntime } from "./src/runtime.js"; -import { - captureStdout, - noopLogger, - setupGoogleMeetPlugin, -} from "./src/test-support/plugin-harness.js"; +import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js"; const voiceCallMocks = vi.hoisted(() => ({ @@ -595,63 +580,6 @@ describe("google-meet plugin", () => { }); }); - it("builds Meet OAuth URLs and prefers fresh cached access tokens", async () => { - const url = new URL( - buildGoogleMeetAuthUrl({ - clientId: "client-id", - challenge: "challenge", - state: "state", - }), - ); - expect(url.hostname).toBe("accounts.google.com"); - expect(url.searchParams.get("client_id")).toBe("client-id"); - expect(url.searchParams.get("code_challenge")).toBe("challenge"); - expect(url.searchParams.get("access_type")).toBe("offline"); - expect(url.searchParams.get("scope")).toContain("meetings.space.created"); - expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); - - await expect( - resolveGoogleMeetAccessToken({ - accessToken: "cached-token", - expiresAt: Date.now() + 120_000, - }), - ).resolves.toEqual({ - accessToken: "cached-token", - expiresAt: expect.any(Number), - refreshed: false, - }); - }); - - it("refreshes Google Meet access tokens with a refresh-token grant", async () => { - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - vi.stubGlobal("fetch", fetchMock); - - await expect( - refreshGoogleMeetAccessToken({ - clientId: "client-id", - clientSecret: "client-secret", - refreshToken: "refresh-token", - }), - ).resolves.toMatchObject({ - accessToken: "new-access-token", - tokenType: "Bearer", - }); - const body = fetchMock.mock.calls[0]?.[1]?.body; - expect(body).toBeInstanceOf(URLSearchParams); - const params = body as URLSearchParams; - expect(params.get("grant_type")).toBe("refresh_token"); - expect(params.get("refresh_token")).toBe("refresh-token"); - }); - it("builds Twilio dial plans from a PIN", () => { expect(normalizeDialInNumber("+1 (555) 123-4567")).toBe("+15551234567"); expect(buildMeetDtmfSequence({ pin: "123 456" })).toBe("123456#"); @@ -882,459 +810,6 @@ describe("google-meet plugin", () => { ); }); - it("CLI setup prints human-readable checks by default", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - setupStatus: async () => ({ - ok: true, - checks: [ - { - id: "audio-bridge", - ok: true, - message: "Chrome command-pair realtime audio bridge configured", - }, - ], - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "setup"], { from: "user" }); - expect(stdout.output()).toContain("Google Meet setup: OK"); - expect(stdout.output()).toContain( - "[ok] audio-bridge: Chrome command-pair realtime audio bridge configured", - ); - expect(stdout.output()).not.toContain('"checks"'); - } finally { - stdout.restore(); - } - }); - - it("CLI setup preserves JSON output with --json", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - setupStatus: async () => ({ - ok: false, - checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); - expect(JSON.parse(stdout.output())).toMatchObject({ - ok: false, - checks: [{ id: "twilio-voice-call-plugin", ok: false }], - }); - } finally { - stdout.restore(); - } - }); - - it("CLI artifacts prints JSON output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--json", - ], - { from: "user" }, - ); - expect(JSON.parse(stdout.output())).toMatchObject({ - conferenceRecords: [{ name: "conferenceRecords/rec-1" }], - artifacts: [ - { - recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], - transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], - transcriptEntries: [ - { - transcript: "conferenceRecords/rec-1/transcripts/t1", - entries: [{ text: "Hello from the transcript." }], - }, - ], - smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], - }, - ], - tokenSource: "cached-access-token", - }); - } finally { - stdout.restore(); - } - }); - - it("CLI latest prints the latest conference record", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "latest", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--meeting", - "abc-defg-hij", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); - expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); - } finally { - stdout.restore(); - } - }); - - it("CLI artifacts writes markdown output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); - const outputPath = path.join(tempDir, "artifacts.md"); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "markdown", - "--output", - outputPath, - ], - { from: "user" }, - ); - const markdown = readFileSync(outputPath, "utf8"); - expect(stdout.output()).toContain(`wrote: ${outputPath}`); - expect(markdown).toContain("# Google Meet Artifacts"); - expect(markdown).toContain("## conferenceRecords/rec-1"); - expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); - expect(markdown).toContain("Hello from the transcript."); - } finally { - stdout.restore(); - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("CLI attendance prints participant sessions by default", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("attendance rows: 1"); - expect(stdout.output()).toContain("participant: Alice"); - expect(stdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - stdout.restore(); - } - }); - - it("CLI attendance prints markdown output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "markdown", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("# Google Meet Attendance"); - expect(stdout.output()).toContain("## Alice"); - expect(stdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor prints human-readable session health", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - status: () => ({ - found: true, - session: { - id: "meet_1", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "chrome-node", - mode: "realtime", - participantIdentity: "signed-in Google Chrome profile on a paired node", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, - chrome: { - audioBackend: "blackhole-2ch", - launched: true, - nodeId: "node-1", - audioBridge: { type: "node-command-pair", provider: "openai" }, - health: { - inCall: true, - providerConnected: true, - realtimeReady: true, - audioInputActive: true, - audioOutputActive: false, - lastInputAt: "2026-04-25T00:00:02.000Z", - lastInputBytes: 160, - lastOutputBytes: 0, - }, - }, - notes: [], - }, - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "meet_1"], { from: "user" }); - expect(stdout.output()).toContain("session: meet_1"); - expect(stdout.output()).toContain("node: node-1"); - expect(stdout.output()).toContain("provider connected: yes"); - expect(stdout.output()).toContain("audio input active: yes"); - expect(stdout.output()).toContain("audio output active: no"); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => { - const program = new Command(); - const stdout = captureStdout(); - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const ensureRuntime = vi.fn(async () => { - throw new Error("runtime should not be loaded for OAuth doctor"); - }); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ - oauth: { - clientId: "client-id", - clientSecret: "client-secret", - refreshToken: "rt-secret", - }, - }), - ensureRuntime: ensureRuntime as unknown as () => Promise, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); - const output = stdout.output(); - expect(output).not.toContain("new-access-token"); - expect(output).not.toContain("rt-secret"); - expect(output).not.toContain("client-secret"); - expect(JSON.parse(output)).toMatchObject({ - ok: true, - configured: true, - tokenSource: "refresh-token", - checks: [ - { id: "oauth-config", ok: true }, - { id: "oauth-token", ok: true }, - ], - }); - expect(ensureRuntime).not.toHaveBeenCalled(); - const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; - expect(body.get("grant_type")).toBe("refresh_token"); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor can prove Google Meet API create access", async () => { - const program = new Command(); - const stdout = captureStdout(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url === "https://oauth2.googleapis.com/token") { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === "https://meet.googleapis.com/v2/spaces") { - return new Response( - JSON.stringify({ - name: "spaces/new-space", - meetingUri: "https://meet.google.com/new-abcd-xyz", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - return new Response("not found", { status: 404 }); - }), - ); - - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ - oauth: { - clientId: "client-id", - refreshToken: "refresh-token", - }, - }), - ensureRuntime: async () => ({}) as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { - from: "user", - }); - expect(JSON.parse(stdout.output())).toMatchObject({ - ok: true, - tokenSource: "refresh-token", - createdSpace: "spaces/new-space", - meetingUri: "https://meet.google.com/new-abcd-xyz", - checks: [ - { id: "oauth-config", ok: true }, - { id: "oauth-token", ok: true }, - { id: "meet-spaces-create", ok: true }, - ], - }); - } finally { - stdout.restore(); - } - }); - - it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ defaultTransport: "chrome-node" }), - ensureRuntime: async () => - ({ - recoverCurrentTab: async () => ({ - nodeId: "node-1", - found: true, - targetId: "tab-1", - tab: { targetId: "tab-1", url: "https://meet.google.com/abc-defg-hij" }, - browser: { - inCall: false, - manualActionRequired: true, - manualActionReason: "meet-admission-required", - manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", - browserUrl: "https://meet.google.com/abc-defg-hij", - }, - message: "Admit the OpenClaw browser participant in Google Meet.", - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "recover-tab"], { from: "user" }); - expect(stdout.output()).toContain("Google Meet current tab: found"); - expect(stdout.output()).toContain("target: tab-1"); - expect(stdout.output()).toContain("manual reason: meet-admission-required"); - } finally { - stdout.restore(); - } - }); - it("launches Chrome after the BlackHole check", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin" }); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 01b2b57961c..c5e3db94e57 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -3,18 +3,12 @@ import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-r import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; -import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, type GoogleMeetConfig, type GoogleMeetMode, type GoogleMeetTransport, } from "./src/config.js"; -import { - createAndJoinMeetFromParams, - createMeetFromParams, - shouldJoinCreatedMeet, -} from "./src/create.js"; import { buildGoogleMeetPreflightReport, fetchGoogleMeetArtifacts, @@ -23,7 +17,6 @@ import { fetchGoogleMeetSpace, } from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; -import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js"; @@ -244,10 +237,34 @@ function resolveOptionalPositiveInteger(value: unknown): number | undefined { return parsed; } +function shouldJoinCreatedMeet(raw: Record): boolean { + return raw.join !== false && raw.join !== "false"; +} + +async function createMeetFromParams(params: { + config: GoogleMeetConfig; + runtime: OpenClawPluginApi["runtime"]; + raw: Record; +}) { + const create = await import("./src/create.js"); + return create.createMeetFromParams(params); +} + +async function createAndJoinMeetFromParams(params: { + config: GoogleMeetConfig; + runtime: OpenClawPluginApi["runtime"]; + raw: Record; + ensureRuntime: () => Promise; +}) { + const create = await import("./src/create.js"); + return create.createAndJoinMeetFromParams(params); +} + async function resolveGoogleMeetTokenFromParams( config: GoogleMeetConfig, raw: Record, ) { + const { resolveGoogleMeetAccessToken } = await import("./src/oauth.js"); return resolveGoogleMeetAccessToken({ clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret, @@ -661,12 +678,14 @@ export default definePluginEntry({ }); api.registerCli( - ({ program }) => + async ({ program }) => { + const { registerGoogleMeetCli } = await import("./src/cli.js"); registerGoogleMeetCli({ program, config, ensureRuntime, - }), + }); + }, { commands: ["googlemeet"], descriptors: [ diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts new file mode 100644 index 00000000000..bf4b2390570 --- /dev/null +++ b/extensions/google-meet/src/cli.test.ts @@ -0,0 +1,555 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerGoogleMeetCli } from "./cli.js"; +import { resolveGoogleMeetConfig } from "./config.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +const fetchGuardMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn( + async (params: { + url: string; + init?: RequestInit; + }): Promise<{ + response: Response; + release: () => Promise; + }> => ({ + response: await fetch(params.url, params.init), + release: vi.fn(async () => {}), + }), + ), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, +})); + +function captureStdout() { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + return { + output: () => output, + restore: () => writeSpy.mockRestore(), + }; +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function requestUrl(input: RequestInfo | URL): URL { + if (typeof input === "string") { + return new URL(input); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} + +function stubMeetArtifactsApi() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/v2/conferenceRecords") { + return jsonResponse({ + conferenceRecords: [ + { + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + signedinUser: { displayName: "Alice" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:10:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { + return jsonResponse({ + recordings: [ + { + name: "conferenceRecords/rec-1/recordings/r1", + state: "FILE_GENERATED", + driveDestination: { file: "drive-file-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { + return jsonResponse({ + transcripts: [ + { + name: "conferenceRecords/rec-1/transcripts/t1", + state: "FILE_GENERATED", + docsDestination: { document: "doc-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") { + return jsonResponse({ + transcriptEntries: [ + { + name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", + text: "Hello from the transcript.", + startTime: "2026-04-25T10:01:00Z", + participant: "conferenceRecords/rec-1/participants/p1", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { + return jsonResponse({ + smartNotes: [ + { + name: "conferenceRecords/rec-1/smartNotes/sn1", + state: "FILE_GENERATED", + docsDestination: { document: "notes-1" }, + }, + ], + }); + } + return new Response("not found", { status: 404 }); + }), + ); +} + +function setupCli(params: { + config?: Parameters[0]; + runtime?: Partial; + ensureRuntime?: () => Promise; +}) { + const program = new Command(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig(params.config ?? {}), + ensureRuntime: + params.ensureRuntime ?? (async () => (params.runtime ?? {}) as unknown as GoogleMeetRuntime), + }); + return program; +} + +describe("google-meet CLI", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("prints setup checks as text and JSON", async () => { + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: true, + checks: [ + { + id: "audio-bridge", + ok: true, + message: "Chrome command-pair realtime audio bridge configured", + }, + ], + }), + }, + }).parseAsync(["googlemeet", "setup"], { from: "user" }); + expect(stdout.output()).toContain("Google Meet setup: OK"); + expect(stdout.output()).toContain( + "[ok] audio-bridge: Chrome command-pair realtime audio bridge configured", + ); + expect(stdout.output()).not.toContain('"checks"'); + } finally { + stdout.restore(); + } + } + + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: false, + checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], + }), + }, + }).parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: false, + checks: [{ id: "twilio-voice-call-plugin", ok: false }], + }); + } finally { + stdout.restore(); + } + } + }); + + it("prints artifacts and attendance output", async () => { + stubMeetArtifactsApi(); + + const artifactsStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--json", + ], + { from: "user" }, + ); + expect(JSON.parse(artifactsStdout.output())).toMatchObject({ + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + transcriptEntries: [ + { + transcript: "conferenceRecords/rec-1/transcripts/t1", + entries: [{ text: "Hello from the transcript." }], + }, + ], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + tokenSource: "cached-access-token", + }); + } finally { + artifactsStdout.restore(); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("attendance rows: 1"); + expect(attendanceStdout.output()).toContain("participant: Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("prints the latest conference record", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--meeting", + "abc-defg-hij", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + + it("prints markdown artifact and attendance output", async () => { + stubMeetArtifactsApi(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); + const outputPath = path.join(tempDir, "artifacts.md"); + const artifactsStdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + "--output", + outputPath, + ], + { from: "user" }, + ); + const markdown = readFileSync(outputPath, "utf8"); + expect(artifactsStdout.output()).toContain(`wrote: ${outputPath}`); + expect(markdown).toContain("# Google Meet Artifacts"); + expect(markdown).toContain("## conferenceRecords/rec-1"); + expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); + expect(markdown).toContain("Hello from the transcript."); + } finally { + artifactsStdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("# Google Meet Attendance"); + expect(attendanceStdout.output()).toContain("## Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("prints human-readable session doctor output", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + status: () => ({ + found: true, + session: { + id: "meet_1", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "realtime", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + chrome: { + audioBackend: "blackhole-2ch", + launched: true, + nodeId: "node-1", + audioBridge: { type: "node-command-pair", provider: "openai" }, + health: { + inCall: true, + providerConnected: true, + realtimeReady: true, + audioInputActive: true, + audioOutputActive: false, + lastInputAt: "2026-04-25T00:00:02.000Z", + lastInputBytes: 160, + lastOutputBytes: 0, + }, + }, + notes: [], + }, + }), + }, + }).parseAsync(["googlemeet", "doctor", "meet_1"], { from: "user" }); + expect(stdout.output()).toContain("session: meet_1"); + expect(stdout.output()).toContain("node: node-1"); + expect(stdout.output()).toContain("provider connected: yes"); + expect(stdout.output()).toContain("audio input active: yes"); + expect(stdout.output()).toContain("audio output active: no"); + } finally { + stdout.restore(); + } + }); + + it("verifies OAuth refresh without printing secrets", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + ); + vi.stubGlobal("fetch", fetchMock); + const ensureRuntime = vi.fn(async () => { + throw new Error("runtime should not be loaded for OAuth doctor"); + }); + const stdout = captureStdout(); + + try { + await setupCli({ + config: { + oauth: { + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "rt-secret", + }, + }, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); + const output = stdout.output(); + expect(output).not.toContain("new-access-token"); + expect(output).not.toContain("rt-secret"); + expect(output).not.toContain("client-secret"); + expect(JSON.parse(output)).toMatchObject({ + ok: true, + configured: true, + tokenSource: "refresh-token", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + ], + }); + expect(ensureRuntime).not.toHaveBeenCalled(); + const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + } finally { + stdout.restore(); + } + }); + + it("can prove Google Meet API create access", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input).href; + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }); + } + if (url === "https://meet.googleapis.com/v2/spaces") { + return jsonResponse({ + name: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }); + } + return new Response("not found", { status: 404 }); + }), + ); + const stdout = captureStdout(); + + try { + await setupCli({ + config: { + oauth: { + clientId: "client-id", + refreshToken: "refresh-token", + }, + }, + }).parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { + from: "user", + }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: true, + tokenSource: "refresh-token", + createdSpace: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + { id: "meet-spaces-create", ok: true }, + ], + }); + } finally { + stdout.restore(); + } + }); + + it("recovers and summarizes an existing Meet tab", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + config: { defaultTransport: "chrome-node" }, + runtime: { + recoverCurrentTab: async () => ({ + nodeId: "node-1", + found: true, + targetId: "tab-1", + tab: { targetId: "tab-1", url: "https://meet.google.com/abc-defg-hij" }, + browser: { + inCall: false, + manualActionRequired: true, + manualActionReason: "meet-admission-required", + manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", + browserUrl: "https://meet.google.com/abc-defg-hij", + }, + message: "Admit the OpenClaw browser participant in Google Meet.", + }), + }, + }).parseAsync(["googlemeet", "recover-tab"], { from: "user" }); + expect(stdout.output()).toContain("Google Meet current tab: found"); + expect(stdout.output()).toContain("target: tab-1"); + expect(stdout.output()).toContain("manual reason: meet-admission-required"); + } finally { + stdout.restore(); + } + }); +}); diff --git a/extensions/google-meet/src/oauth.test.ts b/extensions/google-meet/src/oauth.test.ts new file mode 100644 index 00000000000..debb989ef4c --- /dev/null +++ b/extensions/google-meet/src/oauth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildGoogleMeetAuthUrl, + refreshGoogleMeetAccessToken, + resolveGoogleMeetAccessToken, +} from "./oauth.js"; + +describe("Google Meet OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("builds auth URLs and prefers fresh cached access tokens", async () => { + const url = new URL( + buildGoogleMeetAuthUrl({ + clientId: "client-id", + challenge: "challenge", + state: "state", + }), + ); + expect(url.hostname).toBe("accounts.google.com"); + expect(url.searchParams.get("client_id")).toBe("client-id"); + expect(url.searchParams.get("code_challenge")).toBe("challenge"); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("scope")).toContain("meetings.space.created"); + expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); + + await expect( + resolveGoogleMeetAccessToken({ + accessToken: "cached-token", + expiresAt: Date.now() + 120_000, + }), + ).resolves.toEqual({ + accessToken: "cached-token", + expiresAt: expect.any(Number), + refreshed: false, + }); + }); + + it("refreshes access tokens with a refresh-token grant", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + refreshGoogleMeetAccessToken({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }), + ).resolves.toMatchObject({ + accessToken: "new-access-token", + tokenType: "Bearer", + }); + const body = fetchMock.mock.calls[0]?.[1]?.body; + expect(body).toBeInstanceOf(URLSearchParams); + const params = body as URLSearchParams; + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBe("refresh-token"); + }); +}); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index c309e2b1e6e..7ac4a15e94e 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -249,6 +249,10 @@ const TOOLING_TEST_TARGETS = new Map([ ]); const SOURCE_TEST_TARGETS = new Map([ ...PRECISE_SOURCE_TEST_TARGETS, + ["extensions/google-meet/index.ts", ["extensions/google-meet/index.test.ts"]], + ["extensions/google-meet/src/cli.ts", ["extensions/google-meet/src/cli.test.ts"]], + ["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]], + ["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]], ["src/agents/live-model-turn-probes.ts", ["src/agents/live-model-turn-probes.test.ts"]], [ "src/auto-reply/reply/dispatch-from-config.ts", diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7f00db5ff7a..4d8cdd8dea3 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -301,6 +301,28 @@ describe("scripts/test-projects changed-target routing", () => { ], }); }); + + it("routes Google Meet CLI edits to the lightweight CLI tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/cli.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/src/cli.test.ts"], + }); + }); + + it("routes Google Meet OAuth edits to the lightweight OAuth tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/oauth.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/src/oauth.test.ts"], + }); + }); + + it("routes Google Meet entry edits to the plugin entry tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/index.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/index.test.ts"], + }); + }); + it("routes changed utils and shared files to their light scoped lanes", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ "src/shared/string-normalization.ts", From 3e3bba4f305e0e421158c1167313e0caad664ba3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 00:12:58 -0700 Subject: [PATCH 92/93] feat(diagnostics): emit exec process telemetry (#71451) --- CHANGELOG.md | 1 + docs/logging.md | 15 ++++ .../diagnostics-otel/src/service.test.ts | 61 +++++++++++++++ extensions/diagnostics-otel/src/service.ts | 49 ++++++++++++ ...sh-tools.exec-runtime.pty-fallback.test.ts | 56 ++++++++++++++ src/agents/bash-tools.exec-runtime.ts | 75 ++++++++++++++++++- src/infra/diagnostic-events.ts | 23 ++++++ src/logging/diagnostic-stability.ts | 15 ++++ 8 files changed, 294 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563d3039271..779c4dbbd67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#70424) Thanks @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#70424) Thanks @jlapenna. - Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev. - Memory-core/hybrid search: expose raw `vectorScore` and `textScore` alongside the combined `score` on hybrid memory search results, so callers can inspect vector-versus-text retrieval contribution before temporal decay or MMR reordering. Fixes #68166. (#68286) Thanks @ajfonthemove. diff --git a/docs/logging.md b/docs/logging.md index 210b3054137..3680eb9c3ee 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -216,6 +216,12 @@ Queue + session: - `run.attempt`: run retry/attempt metadata. - `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session). +Exec: + +- `exec.process.completed`: terminal exec process outcome, duration, target, mode, + exit code, and failure kind. Command text and working directories are not + included. + ### Enable diagnostics (no exporter) Use this if you want diagnostics events available to plugins or custom sinks: @@ -352,6 +358,11 @@ Queues + sessions: - `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`) - `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`) +Exec: + +- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`, + `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`) + ### Exported spans (names + key attributes) - `openclaw.model.usage` @@ -367,6 +378,10 @@ Queues + sessions: - `openclaw.tool.execution` - `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.errorCategory`, `openclaw.tool.params.*` +- `openclaw.exec` + - `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, + `openclaw.failureKind`, `openclaw.exec.command_length`, + `openclaw.exec.exit_code`, `openclaw.exec.timed_out` - `openclaw.webhook.processed` - `openclaw.channel`, `openclaw.webhook`, `openclaw.chatId` - `openclaw.webhook.error` diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index a11f969ca0c..976f64b23bb 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -817,6 +817,67 @@ describe("diagnostics-otel service", () => { await service.stop?.(ctx); }); + test("exports exec process spans without command text", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "exec.process.completed", + target: "host", + mode: "child", + outcome: "failed", + durationMs: 30, + commandLength: 42, + exitCode: 1, + timedOut: false, + failureKind: "runtime-error", + }); + await flushDiagnosticEvents(); + + expect(telemetryState.histograms.get("openclaw.exec.duration_ms")?.record).toHaveBeenCalledWith( + 30, + expect.objectContaining({ + "openclaw.exec.target": "host", + "openclaw.exec.mode": "child", + "openclaw.outcome": "failed", + "openclaw.failureKind": "runtime-error", + }), + ); + + const execCall = telemetryState.tracer.startSpan.mock.calls.find( + (call) => call[0] === "openclaw.exec", + ); + expect(execCall?.[1]).toMatchObject({ + attributes: { + "openclaw.exec.target": "host", + "openclaw.exec.mode": "child", + "openclaw.outcome": "failed", + "openclaw.exec.command_length": 42, + "openclaw.exec.exit_code": 1, + "openclaw.exec.timed_out": false, + "openclaw.failureKind": "runtime-error", + }, + startTime: expect.any(Number), + }); + expect(execCall?.[1]).toEqual({ + attributes: expect.not.objectContaining({ + "openclaw.exec.command": expect.anything(), + "openclaw.exec.workdir": expect.anything(), + "openclaw.sessionKey": expect.anything(), + }), + startTime: expect.any(Number), + }); + + const execSpan = telemetryState.spans.find((span) => span.name === "openclaw.exec"); + expect(execSpan?.setStatus).toHaveBeenCalledWith({ + code: 2, + message: "runtime-error", + }); + expect(execSpan?.end).toHaveBeenCalledWith(expect.any(Number)); + await service.stop?.(ctx); + }); + test("does not export model or tool content unless capture is explicitly enabled", async () => { const service = createDiagnosticsOtelService(); const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 8f08472b07d..4a63637f1e2 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -557,6 +557,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { description: "Tool execution duration", }, ); + const execProcessDurationHistogram = meter.createHistogram("openclaw.exec.duration_ms", { + unit: "ms", + description: "Exec process duration", + }); let recordLogRecord: | ((evt: Extract) => void) @@ -1087,6 +1091,48 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { span.end(evt.ts); }; + const recordExecProcessCompleted = ( + evt: Extract, + ) => { + const attrs: Record = { + "openclaw.exec.target": evt.target, + "openclaw.exec.mode": evt.mode, + "openclaw.outcome": evt.outcome, + }; + if (evt.failureKind) { + attrs["openclaw.failureKind"] = evt.failureKind; + } + execProcessDurationHistogram.record(evt.durationMs, attrs); + if (!tracesEnabled) { + return; + } + + const spanAttrs: Record = { + ...attrs, + "openclaw.exec.command_length": evt.commandLength, + }; + if (typeof evt.exitCode === "number") { + spanAttrs["openclaw.exec.exit_code"] = evt.exitCode; + } + if (evt.exitSignal) { + spanAttrs["openclaw.exec.exit_signal"] = lowCardinalityAttr(evt.exitSignal, "other"); + } + if (evt.timedOut !== undefined) { + spanAttrs["openclaw.exec.timed_out"] = evt.timedOut; + } + + const span = spanWithDuration("openclaw.exec", spanAttrs, evt.durationMs, { + endTimeMs: evt.ts, + }); + if (evt.outcome === "failed") { + span.setStatus({ + code: SpanStatusCode.ERROR, + ...(evt.failureKind ? { message: evt.failureKind } : {}), + }); + } + span.end(evt.ts); + }; + const recordHeartbeat = ( evt: Extract, ) => { @@ -1147,6 +1193,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "tool.execution.error": recordToolExecutionError(evt); return; + case "exec.process.completed": + recordExecProcessCompleted(evt); + return; case "log.record": recordLogRecord?.(evt); return; diff --git a/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts b/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts index c59570f2f46..eef7d50b059 100644 --- a/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts +++ b/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest"; +import { + onInternalDiagnosticEvent, + resetDiagnosticEventsForTest, + type DiagnosticEventPayload, +} from "../infra/diagnostic-events.js"; import type { ManagedRun, SpawnInput } from "../process/supervisor/index.js"; let listRunningSessions: typeof import("./bash-process-registry.js").listRunningSessions; @@ -56,6 +61,7 @@ beforeEach(() => { afterEach(() => { resetProcessRegistryForTests(); + resetDiagnosticEventsForTest(); vi.clearAllMocks(); }); @@ -101,3 +107,53 @@ test("exec cleans session state when PTY fallback spawn also fails", async () => expect(listRunningSessions()).toHaveLength(0); }); + +function flushDiagnosticEvents() { + return new Promise((resolve) => setImmediate(resolve)); +} + +test("exec emits bounded process diagnostics without command text", async () => { + supervisorSpawnMock.mockImplementationOnce(async (input: SpawnInput) => + createSuccessfulRun(input), + ); + const events: DiagnosticEventPayload[] = []; + const unsubscribe = onInternalDiagnosticEvent((event) => { + events.push(event); + }); + try { + const command = "printf super-secret-value"; + const handle = await runExecProcess({ + command, + workdir: process.cwd(), + env: {}, + usePty: false, + warnings: [], + maxOutput: 20_000, + pendingMaxOutput: 20_000, + notifyOnExit: false, + sessionKey: "session-1", + timeoutSec: 5, + }); + + await handle.promise; + await flushDiagnosticEvents(); + + const event = events.find((item) => item.type === "exec.process.completed"); + expect(event).toMatchObject({ + type: "exec.process.completed", + target: "host", + mode: "child", + outcome: "completed", + durationMs: expect.any(Number), + commandLength: command.length, + exitCode: 0, + sessionKey: "session-1", + }); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain("printf"); + expect(serialized).not.toContain("super-secret-value"); + expect(serialized).not.toContain(process.cwd()); + } finally { + unsubscribe(); + } +}); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index bc9529a454a..deb20bced03 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { emitDiagnosticEvent } from "../infra/diagnostic-events.js"; import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, resolveExecApprovalAllowedDecisions, @@ -165,6 +166,40 @@ export type ExecProcessHandle = { disableUpdates: () => void; }; +function normalizeExecExitSignal(signal: NodeJS.Signals | number | null): string | undefined { + if (signal === null) { + return undefined; + } + return String(signal); +} + +function emitExecProcessCompleted(params: { + command: string; + mode: "child" | "pty"; + outcome: ExecProcessOutcome; + sessionKey?: string; + target: "host" | "sandbox"; +}): void { + const exitSignal = normalizeExecExitSignal(params.outcome.exitSignal); + emitDiagnosticEvent({ + type: "exec.process.completed", + target: params.target, + mode: params.mode, + outcome: params.outcome.status, + durationMs: params.outcome.durationMs, + commandLength: params.command.length, + ...(params.sessionKey?.trim() ? { sessionKey: params.sessionKey.trim() } : {}), + ...(typeof params.outcome.exitCode === "number" ? { exitCode: params.outcome.exitCode } : {}), + ...(exitSignal ? { exitSignal } : {}), + ...(params.outcome.status === "failed" + ? { + timedOut: params.outcome.timedOut, + failureKind: params.outcome.failureKind, + } + : {}), + }); +} + export function renderExecHostLabel(host: ExecHost) { return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; } @@ -523,6 +558,7 @@ export async function runExecProcess(opts: { const startedAt = Date.now(); const sessionId = createSessionSlug(); const execCommand = opts.execCommand ?? opts.command; + const diagnosticTarget = opts.sandbox ? "sandbox" : "host"; const supervisor = getProcessSupervisor(); const shellRuntimeEnv: Record = { ...opts.env, @@ -759,11 +795,33 @@ export async function runExecProcess(opts: { } catch (retryErr) { markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); + emitExecProcessCompleted({ + command: opts.command, + mode: "child", + outcome: buildExecRuntimeErrorOutcome({ + error: retryErr, + aggregated: session.aggregated.trim(), + durationMs: Date.now() - startedAt, + }), + sessionKey: opts.sessionKey, + target: diagnosticTarget, + }); throw retryErr; } } else { markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); + emitExecProcessCompleted({ + command: opts.command, + mode: spawnSpec.mode, + outcome: buildExecRuntimeErrorOutcome({ + error: err, + aggregated: session.aggregated.trim(), + durationMs: Date.now() - startedAt, + }), + sessionKey: opts.sessionKey, + target: diagnosticTarget, + }); throw err; } } @@ -799,17 +857,32 @@ export async function runExecProcess(opts: { token: sandboxFinalizeToken, }); } + emitExecProcessCompleted({ + command: opts.command, + mode: usingPty ? "pty" : "child", + outcome, + sessionKey: opts.sessionKey, + target: diagnosticTarget, + }); return outcome; }) .catch((err): ExecProcessOutcome => { updatesDisabled = true; markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); - return buildExecRuntimeErrorOutcome({ + const outcome = buildExecRuntimeErrorOutcome({ error: err, aggregated: session.aggregated.trim(), durationMs: Date.now() - startedAt, }); + emitExecProcessCompleted({ + command: opts.command, + mode: usingPty ? "pty" : "child", + outcome, + sessionKey: opts.sessionKey, + target: diagnosticTarget, + }); + return outcome; }); return { diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index d1a29993487..ba0635199ca 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -185,6 +185,27 @@ export type DiagnosticToolExecutionErrorEvent = DiagnosticToolExecutionBaseEvent errorCode?: string; }; +export type DiagnosticExecProcessCompletedEvent = DiagnosticBaseEvent & { + type: "exec.process.completed"; + sessionKey?: string; + target: "host" | "sandbox"; + mode: "child" | "pty"; + outcome: "completed" | "failed"; + durationMs: number; + commandLength: number; + exitCode?: number; + exitSignal?: string; + timedOut?: boolean; + failureKind?: + | "shell-command-not-found" + | "shell-not-executable" + | "overall-timeout" + | "no-output-timeout" + | "signal" + | "aborted" + | "runtime-error"; +}; + type DiagnosticRunBaseEvent = DiagnosticBaseEvent & { runId: string; sessionKey?: string; @@ -299,6 +320,7 @@ export type DiagnosticEventPayload = | DiagnosticToolExecutionStartedEvent | DiagnosticToolExecutionCompletedEvent | DiagnosticToolExecutionErrorEvent + | DiagnosticExecProcessCompletedEvent | DiagnosticRunStartedEvent | DiagnosticRunCompletedEvent | DiagnosticModelCallStartedEvent @@ -329,6 +351,7 @@ const ASYNC_DIAGNOSTIC_EVENT_TYPES = new Set([ "tool.execution.started", "tool.execution.completed", "tool.execution.error", + "exec.process.completed", "model.call.started", "model.call.completed", "model.call.error", diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index 40051a6ec9c..c3f3f1e840d 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -17,10 +17,12 @@ export type DiagnosticStabilityEventRecord = { channel?: string; pluginId?: string; source?: string; + target?: string; surface?: string; action?: string; reason?: string; outcome?: string; + mode?: string; level?: string; detector?: string; toolName?: string; @@ -28,6 +30,9 @@ export type DiagnosticStabilityEventRecord = { provider?: string; model?: string; durationMs?: number; + commandLength?: number; + exitCode?: number; + timedOut?: boolean; costUsd?: number; count?: number; bytes?: number; @@ -247,6 +252,16 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.durationMs = event.durationMs; assignReasonCode(record, event.errorCategory); break; + case "exec.process.completed": + record.target = event.target; + record.mode = event.mode; + record.outcome = event.outcome; + record.durationMs = event.durationMs; + record.commandLength = event.commandLength; + record.exitCode = event.exitCode; + record.timedOut = event.timedOut; + assignReasonCode(record, event.failureKind); + break; case "run.started": record.provider = event.provider; record.model = event.model; From 9577de2da7dfb6234cf30e3b879940067804e26e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:15:10 +0100 Subject: [PATCH 93/93] docs: reference fixed MCP lifecycle reports --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779c4dbbd67..9e69e57e488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106 and #71110. +- MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808. - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed `{ type: "text" }` blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek. - Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit.