From e4b9e5035197a557f62b338a410fcd842d68ea80 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 16:27:07 +0800 Subject: [PATCH 001/239] fix(media): stop auto-running Antigravity CLI --- src/media-understanding/apply.test.ts | 35 +----- src/media-understanding/runner.ts | 166 +------------------------- 2 files changed, 5 insertions(+), 196 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index f1ee1da6843c..a3e42155acda 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -208,7 +208,6 @@ async function withMediaAutoDetectEnv( GROQ_API_KEY: undefined, DEEPGRAM_API_KEY: undefined, GEMINI_API_KEY: undefined, - OPENCLAW_ANTIGRAVITY_CLI: undefined, OPENCLAW_AGENT_DIR: undefined, ...env, }, @@ -1029,7 +1028,7 @@ describe("applyMediaUnderstanding", () => { expect(mockedRunExec).not.toHaveBeenCalled(); }); - it("uses Antigravity CLI as the last auto image fallback", async () => { + it("does not auto-detect Antigravity CLI for images", async () => { clearMediaUnderstandingBinaryCacheForTests(); const binDir = await createTempMediaDir(); await createMockExecutable(binDir, "agy"); @@ -1046,40 +1045,14 @@ describe("applyMediaUnderstanding", () => { source: "none", mode: "api-key", }); - mockedRunExec.mockImplementation(async (_command, args) => { - if (Array.isArray(args) && args.includes("--help")) { - return { stdout: "--print\n--add-dir\n--sandbox\n", stderr: "" }; - } - return { stdout: "antigravity image description\n", stderr: "" }; - }); await withMediaAutoDetectEnv({ PATH: binDir }, async () => { const result = await applyMediaUnderstanding({ ctx, cfg }); - expect(result.appliedImage).toBe(true); + expect(result.appliedImage).toBe(false); }); - expect(ctx.Body).toBe("[Image]\nDescription:\nantigravity image description"); - expect(mockedRunExec).toHaveBeenCalledTimes(2); - const realImagePath = await fs.realpath(imagePath); - const [_probeCommand, _probeArgs, probeOptions] = getRunExecCall(0); - expect(probeOptions).toEqual({ - timeoutMs: 3000, - cwd: expect.stringContaining("openclaw-antigravity-probe-"), - }); - const [command, args, options] = getRunExecCall(1); - expect(command).toBe(path.join(binDir, "agy")); - expect(args).toEqual([ - "--sandbox", - "--add-dir", - path.dirname(realImagePath), - "--print", - expect.stringContaining(realImagePath), - ]); - expect(options).toEqual({ - timeoutMs: 60_000, - maxBuffer: CLI_OUTPUT_MAX_BUFFER, - cwd: path.dirname(realImagePath), - }); + expect(ctx.Body).toBe(""); + expect(mockedRunExec).not.toHaveBeenCalled(); }); it("uses CLI image understanding and preserves caption for commands", async () => { diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index d1c9caaa7fa2..df28188cf9b0 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -1,8 +1,5 @@ // Media-understanding runner resolves providers/models, local roots, auth, and // per-capability execution decisions for message attachments. -import { constants as fsConstants } from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { mergeInboundPathRoots } from "@openclaw/media-core/inbound-path-policy"; import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id"; @@ -11,10 +8,7 @@ import { normalizeNullableString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { ActiveMediaModel } from "../../packages/media-understanding-common/src/active-model.js"; import { isMediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js"; import { providerSupportsCapability } from "../../packages/media-understanding-common/src/provider-supports.js"; @@ -36,13 +30,10 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { logWarn } from "../logger.js"; import { resolveChannelInboundAttachmentRoots } from "../media/channel-inbound-roots.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { normalizeMediaFacts } from "../media/media-facts.js"; -import { runExec } from "../process/exec.js"; -import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js"; import { MediaAttachmentCache, selectAttachments } from "./attachments.js"; import { matchesMediaEntryCapability } from "./entry-capabilities.js"; @@ -357,12 +348,7 @@ export function resolveMediaAttachmentLocalRoots(params: { ); } -const binaryCache = new Map>(); -const antigravityCliCache = new Map>(); - function clearMediaUnderstandingBinaryCacheForTests(): void { - binaryCache.clear(); - antigravityCliCache.clear(); clearLocalAudioInspectionCacheForTests(); } @@ -372,152 +358,6 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { ] = { clearMediaUnderstandingBinaryCacheForTests }; } -function expandHomeDir(value: string): string { - if (!value.startsWith("~")) { - return value; - } - const home = os.homedir(); - if (value === "~") { - return home; - } - if (value.startsWith("~/")) { - return path.join(home, value.slice(2)); - } - return value; -} - -function hasPathSeparator(value: string): boolean { - return value.includes("/") || value.includes("\\"); -} - -function candidateBinaryNames(name: string): string[] { - if (process.platform !== "win32") { - return [name]; - } - const ext = path.extname(name); - if (ext) { - return [name]; - } - const pathext = normalizeStringEntries( - (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";"), - ).map((item) => (item.startsWith(".") ? item : `.${item}`)); - return [name, ...uniqueStrings(pathext).map((item) => `${name}${item}`)]; -} - -async function isExecutable(filePath: string): Promise { - try { - const stat = await fs.stat(filePath); - if (!stat.isFile()) { - return false; - } - if (process.platform === "win32") { - return true; - } - await fs.access(filePath, fsConstants.X_OK); - return true; - } catch { - return false; - } -} - -async function findBinary(name: string): Promise { - return await getOrCreatePromise(binaryCache, name, async () => { - const direct = expandHomeDir(name.trim()); - if (direct && hasPathSeparator(direct)) { - for (const candidate of candidateBinaryNames(direct)) { - if (await isExecutable(candidate)) { - return candidate; - } - } - } - - const searchName = name.trim(); - if (!searchName) { - return null; - } - const pathEntries = (process.env.PATH ?? "").split(path.delimiter); - const candidates = candidateBinaryNames(searchName); - for (const entryRaw of pathEntries) { - const entry = expandHomeDir(entryRaw.trim().replace(/^"(.*)"$/, "$1")); - if (!entry) { - continue; - } - for (const candidate of candidates) { - const fullPath = path.join(entry, candidate); - if (await isExecutable(fullPath)) { - return fullPath; - } - } - } - - return null; - }); -} - -async function probeAntigravityCliCandidate(command: string): Promise { - const resolved = await findBinary(command); - if (!resolved) { - return null; - } - const probeDir = await fs.mkdtemp( - path.join(resolvePreferredOpenClawTmpDir(), "openclaw-antigravity-probe-"), - ); - try { - const { stdout } = await runExec(resolved, ["--help"], { - timeoutMs: 3000, - cwd: probeDir, - }); - return stdout.includes("--print") && - stdout.includes("--add-dir") && - stdout.includes("--sandbox") - ? resolved - : null; - } catch { - return null; - } finally { - await fs.rm(probeDir, { recursive: true, force: true }).catch(() => {}); - } -} - -async function resolveAntigravityCliBinary(): Promise { - return await getOrCreatePromise(antigravityCliCache, "agy", async () => { - const configured = process.env.OPENCLAW_ANTIGRAVITY_CLI?.trim(); - const candidates = [configured, "agy", "antigravity"].filter((value): value is string => - Boolean(value), - ); - for (const candidate of candidates) { - const command = await probeAntigravityCliCandidate(candidate); - if (command) { - return command; - } - } - return null; - }); -} - -async function resolveAntigravityCliEntry( - capability: MediaUnderstandingCapability, -): Promise { - if (capability === "audio") { - return null; - } - const command = await resolveAntigravityCliBinary(); - if (!command) { - return null; - } - return { - type: "cli", - command, - args: [ - "--sandbox", - "--add-dir", - "{{AttachmentDir}}", - "--print", - "{{Prompt}} Inspect {{AttachmentPath}} and reply with only the requested media description.", - ], - }; -} - async function resolveKeyEntry(params: { cfg: OpenClawConfig; agentId?: string; @@ -762,10 +602,6 @@ async function resolveAutoEntries(params: { if (keys) { return [keys]; } - const antigravity = await resolveAntigravityCliEntry(params.capability); - if (antigravity) { - return [antigravity]; - } return []; } From c568f93b03697f42f2d73f29088aca3b0f7ed199 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 16:33:33 +0800 Subject: [PATCH 002/239] fix(google): retire Gemini CLI OAuth setup --- extensions/google/gemini-cli-provider.ts | 97 +------------------ extensions/google/index.test.ts | 15 +++ extensions/google/manifest.test.ts | 22 +++++ extensions/google/openclaw.plugin.json | 18 +--- .../google/provider-contract-api.test.ts | 29 ++++++ extensions/google/provider-contract-api.ts | 35 ++----- extensions/google/provider-registration.ts | 10 +- 7 files changed, 84 insertions(+), 142 deletions(-) create mode 100644 extensions/google/provider-contract-api.test.ts diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 534557a63644..f21d605e5243 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -2,10 +2,8 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Google provider module implements model/runtime integration. import type { OpenClawPluginApi, - ProviderAuthContext, ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { GOOGLE_GEMINI_CLI_PROVIDER_ID } from "./gemini-cli-auth-home.js"; @@ -14,14 +12,7 @@ import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; const PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID; -const PROVIDER_LABEL = "Gemini CLI OAuth"; -const DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; -const ENV_VARS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -] as const; +const PROVIDER_LABEL = "Gemini CLI runtime"; const loadOauthRuntimeModule = createLazyRuntimeModule(() => import("./oauth.runtime.js")); @@ -35,90 +26,8 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin { label: PROVIDER_LABEL, docsPath: "/providers/models", aliases: ["gemini-cli"], - envVars: [...ENV_VARS], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - await ctx.prompter.note( - [ - "This is an unofficial integration and is not endorsed by Google.", - "Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients.", - "Proceed only if you understand and accept this risk.", - ].join("\n"), - "Google Gemini CLI caution", - ); - - const proceed = await ctx.prompter.confirm({ - message: "Continue with Google Gemini CLI OAuth?", - initialValue: false, - }); - if (!proceed) { - await ctx.prompter.note("Skipped Google Gemini CLI OAuth setup.", "Setup skipped"); - return { profiles: [] }; - } - - const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); - try { - const { loginGeminiCliOAuth } = await loadOauthRuntimeModule(); - const result = await loginGeminiCliOAuth({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - log: (msg) => ctx.runtime.log(msg), - note: (message, title) => ctx.prompter.note(message, title), - prompt: async (message) => ctx.prompter.text({ message }), - progress: spin, - ...(ctx.signal ? { signal: ctx.signal } : {}), - }); - - spin.stop("Gemini CLI OAuth complete"); - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - configPatch: { - agents: { - defaults: { - models: { - [DEFAULT_MODEL]: { agentRuntime: { id: PROVIDER_ID } }, - }, - }, - }, - }, - ...(result.projectId ? { credentialExtra: { projectId: result.projectId } } : {}), - ...(result.projectId - ? { - notes: [ - "If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", - ], - } - : {}), - }); - } catch (err) { - spin.stop("Gemini CLI OAuth failed"); - await ctx.prompter.note( - "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", - "OAuth help", - ); - throw err; - } - }, - }, - ], - wizard: { - setup: { - choiceId: "google-gemini-cli", - choiceLabel: "Gemini CLI OAuth", - choiceHint: "Sign in with your Google account (opens a browser)", - methodId: "oauth", - }, - }, + envVars: [], + auth: [], resolveDynamicModel: (ctx) => resolveGoogleGeminiForwardCompatModel({ providerId: PROVIDER_ID, diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 42127b672bf2..88ff7d28b5e9 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -129,6 +129,21 @@ describe("google provider plugin hooks", () => { ).toBe("tagged"); }); + it("keeps the Gemini CLI runtime without offering new OAuth setup", async () => { + const { providers } = await registerProviderPlugin({ + plugin: googleProviderPlugin, + id: "google", + name: "Google Provider", + }); + const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli"); + + expect(cliProvider.label).toBe("Gemini CLI runtime"); + expect(cliProvider.auth).toEqual([]); + expect(cliProvider.envVars).toEqual([]); + expect(cliProvider.wizard).toBeUndefined(); + expect(cliProvider.refreshOAuth).toBeTypeOf("function"); + }); + it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index d19680a8dd91..66aefda656bd 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -3,6 +3,13 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; type GoogleManifest = { + providerAuthChoices?: Array<{ + provider?: string; + method?: string; + choiceLabel?: string; + choiceHint?: string; + groupHint?: string; + }>; modelIdNormalization?: { providers?: Record< string, @@ -67,6 +74,21 @@ function loadManifest(): GoogleManifest { } describe("google manifest model catalog", () => { + it("offers Google AI Studio API keys without consumer CLI OAuth", () => { + const choices = loadManifest().providerAuthChoices ?? []; + + expect(choices).toEqual([ + expect.objectContaining({ + provider: "google", + method: "api-key", + choiceLabel: "Google AI Studio API key", + choiceHint: "Supported API-key access from aistudio.google.com/apikey", + groupHint: "Supported API-key setup", + }), + ]); + expect(choices.some((choice) => choice.provider === "google-gemini-cli")).toBe(false); + }); + it("suppresses retired Gemini chat model identifiers for all Google chat providers", () => { const manifest = loadManifest(); const suppressionRefs = new Set( diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 2cb4016a34ba..9f89351d32db 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -692,28 +692,16 @@ "method": "api-key", "choiceId": "gemini-api-key", "appGuidedSecret": true, - "choiceLabel": "Google Gemini API key", - "choiceHint": "Free API key from aistudio.google.com/apikey", + "choiceLabel": "Google AI Studio API key", + "choiceHint": "Supported API-key access from aistudio.google.com/apikey", "groupId": "google", "groupLabel": "Google", - "groupHint": "Gemini API key + OAuth", + "groupHint": "Supported API-key setup", "onboardingFeatured": true, "optionKey": "geminiApiKey", "cliFlag": "--gemini-api-key", "cliOption": "--gemini-api-key ", "cliDescription": "Gemini API key" - }, - { - "provider": "google-gemini-cli", - "method": "oauth", - "choiceId": "google-gemini-cli", - "appGuidedAuth": "oauth", - "choiceLabel": "Gemini CLI OAuth", - "choiceHint": "Sign in with your Google account (opens a browser)", - "groupId": "google", - "groupLabel": "Google", - "groupHint": "Gemini API key + OAuth", - "onboardingFeatured": true } ], "uiHints": { diff --git a/extensions/google/provider-contract-api.test.ts b/extensions/google/provider-contract-api.test.ts new file mode 100644 index 000000000000..f65dfa0b7612 --- /dev/null +++ b/extensions/google/provider-contract-api.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { createGoogleGeminiCliProvider, createGoogleProvider } from "./provider-contract-api.js"; + +describe("google provider contract", () => { + it("exposes Google AI Studio API-key setup", () => { + const provider = createGoogleProvider(); + + expect(provider.auth).toEqual([ + expect.objectContaining({ + id: "api-key", + label: "Google AI Studio API key", + hint: "Supported API-key access from aistudio.google.com/apikey", + wizard: expect.objectContaining({ + choiceLabel: "Google AI Studio API key", + groupHint: "Supported API-key setup", + }), + }), + ]); + }); + + it("keeps Gemini CLI as a runtime-only compatibility provider", () => { + const provider = createGoogleGeminiCliProvider(); + + expect(provider.label).toBe("Gemini CLI runtime"); + expect(provider.auth).toEqual([]); + expect(provider.envVars).toEqual([]); + expect(provider.wizard).toBeUndefined(); + }); +}); diff --git a/extensions/google/provider-contract-api.ts b/extensions/google/provider-contract-api.ts index 9fc246ece0ed..e101e0bf79d2 100644 --- a/extensions/google/provider-contract-api.ts +++ b/extensions/google/provider-contract-api.ts @@ -14,15 +14,15 @@ export function createGoogleProvider(): ProviderPlugin { { id: "api-key", kind: "api_key", - label: "Google Gemini API key", - hint: "Free API key from aistudio.google.com/apikey", + label: "Google AI Studio API key", + hint: "Supported API-key access from aistudio.google.com/apikey", run: noopAuth, wizard: { choiceId: "gemini-api-key", - choiceLabel: "Google Gemini API key", + choiceLabel: "Google AI Studio API key", groupId: "google", groupLabel: "Google", - groupHint: "Gemini API key + OAuth", + groupHint: "Supported API-key setup", }, }, ], @@ -48,31 +48,10 @@ export function createGoogleVertexProvider(): ProviderPlugin { export function createGoogleGeminiCliProvider(): ProviderPlugin { return { id: "google-gemini-cli", - label: "Gemini CLI OAuth", + label: "Gemini CLI runtime", docsPath: "/providers/models", aliases: ["gemini-cli"], - envVars: [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", - ], - auth: [ - { - id: "oauth", - kind: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - run: noopAuth, - }, - ], - wizard: { - setup: { - choiceId: "google-gemini-cli", - choiceLabel: "Gemini CLI OAuth", - choiceHint: "Sign in with your Google account (opens a browser)", - methodId: "oauth", - }, - }, + envVars: [], + auth: [], }; } diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index cee00bfbf13b..de15ca332aa2 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -48,21 +48,21 @@ export function buildGoogleProvider(): ProviderPlugin { createProviderApiKeyAuthMethod({ providerId: "google", methodId: "api-key", - label: "Google Gemini API key", - hint: "Free API key from aistudio.google.com/apikey", + label: "Google AI Studio API key", + hint: "Supported API-key access from aistudio.google.com/apikey", optionKey: "geminiApiKey", flagName: "--gemini-api-key", envVar: "GEMINI_API_KEY", - promptMessage: "Enter Gemini API key", + promptMessage: "Enter Google AI Studio API key", defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, expectedProviders: ["google"], applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next, wizard: { choiceId: "gemini-api-key", - choiceLabel: "Google Gemini API key", + choiceLabel: "Google AI Studio API key", groupId: "google", groupLabel: "Google", - groupHint: "Gemini API key + OAuth", + groupHint: "Supported API-key setup", }, }), ], From 98df5a2287ea5532cc469dcbe354a87cadedaf3f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 22:36:33 +0800 Subject: [PATCH 003/239] fix(onboarding): hide unsupported Google CLI setup routes --- src/system-agent/setup-inference-detect.ts | 49 +--------------------- src/system-agent/setup-inference.test.ts | 30 +++---------- 2 files changed, 7 insertions(+), 72 deletions(-) diff --git a/src/system-agent/setup-inference-detect.ts b/src/system-agent/setup-inference-detect.ts index eab32f39daf2..63a4347e4c2d 100644 --- a/src/system-agent/setup-inference-detect.ts +++ b/src/system-agent/setup-inference-detect.ts @@ -109,20 +109,7 @@ export async function detectSetupInference( const unavailableCandidates: SetupInferenceUnavailableCandidate[] = []; const deferredUnavailableCandidates: SetupInferenceUnavailableCandidate[] = []; const probe = deps.probeLocalCommand ?? probeLocalCommand; - const [antigravity, pi, opencode] = await Promise.all([ - probe("agy"), - probe("pi"), - probe("opencode"), - ]); - if (antigravity.found && !antigravity.timedOut) { - deferredUnavailableCandidates.push({ - id: "antigravity-cli", - label: "Antigravity CLI", - detail: "installed", - reason: - "Can't be auto-tested safely here. Sign in with a provider or use an API key instead.", - }); - } + const [pi, opencode] = await Promise.all([probe("pi"), probe("opencode")]); if (pi.found && !pi.timedOut) { deferredUnavailableCandidates.push({ id: "pi-cli", @@ -170,40 +157,6 @@ export async function detectSetupInference( ); const manualProviders = listSetupInferenceManualProviders(authChoices); const authOptions = listSetupInferenceAuthOptions(authChoices); - const manualProviderIds = new Set(manualProviders.map((provider) => provider.id)); - const authOptionIds = new Set(authOptions.map((option) => option.id)); - // Gemini CLI has no hard tool-off mode: wildcard exclusions can be - // overridden by admin policy and do not stop discovery or MCP startup. - // Keep normal agent support, but route setup through provider-owned methods - // that OpenClaw can verify without inspecting Gemini's private auth store. - for (const candidate of detected.filter((entry) => entry.kind === "gemini-cli")) { - const providerId = parseRef(candidate.modelRef).provider; - const ownerChoice = authChoices.find( - (choice) => normalizeProviderId(choice.providerId) === normalizeProviderId(providerId), - ); - const ownerGroup = ownerChoice?.groupId ?? ownerChoice?.providerId ?? providerId; - const relatedChoices = authChoices.filter( - (choice) => (choice.groupId ?? choice.providerId) === ownerGroup, - ); - const authOptionId = relatedChoices.find((choice) => - authOptionIds.has(choice.choiceId), - )?.choiceId; - const manualProviderId = relatedChoices.find((choice) => - manualProviderIds.has(choice.choiceId), - )?.choiceId; - unavailableCandidates.push({ - id: candidate.kind, - brandId: providerId, - label: candidate.label, - detail: candidate.detail, - reason: - "OpenClaw cannot confirm whether this private Gemini CLI login works without starting a session that may expose tools. Sign in through OpenClaw or use a Gemini API key to create a connection it can verify.", - ...(authOptionId ? { authOptionId } : {}), - ...(manualProviderId ? { manualProviderId } : {}), - ...(ownerChoice?.icon ? { icon: ownerChoice.icon } : {}), - ...(ownerChoice?.website ? { website: ownerChoice.website } : {}), - }); - } unavailableCandidates.push(...deferredUnavailableCandidates); const candidates: SetupInferenceCandidate[] = raw.map((candidate) => // Released macOS clients require this field. Keep it false so the wire diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 9bcfc71767cc..430ed33bc966 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -480,7 +480,7 @@ describe("detectSetupInference", () => { ); }); - it("discovers provider-owned local inference and reports unsafe CLIs without running them", async () => { + it("discovers provider-owned local inference without surfacing unsupported Google CLIs", async () => { const prepare = vi.fn(); const detect = vi.fn(async () => ({ modelRef: "local/qwen-tool", @@ -519,25 +519,15 @@ describe("detectSetupInference", () => { ], probeLocalCommand: vi.fn(async (command) => ({ command, - found: command === "agy" || command === "pi" || command === "opencode", + found: command === "pi" || command === "opencode", })), resolveManifestProviderAuthChoices: () => [ - { - pluginId: "google", - providerId: "google-gemini-cli", - methodId: "oauth", - choiceId: "google-gemini-cli", - choiceLabel: "Gemini CLI OAuth", - groupId: "google", - groupLabel: "Google", - appGuidedAuth: "oauth", - }, { pluginId: "google", providerId: "google", methodId: "api-key", choiceId: "gemini-api-key", - choiceLabel: "Google Gemini API key", + choiceLabel: "Google AI Studio API key", groupId: "google", groupLabel: "Google", appGuidedSecret: true, @@ -572,13 +562,6 @@ describe("detectSetupInference", () => { }, ]); expect(detection.unavailableCandidates).toEqual([ - expect.objectContaining({ - id: "gemini-cli", - brandId: "google-gemini-cli", - authOptionId: "google-gemini-cli", - manualProviderId: "gemini-api-key", - }), - expect.objectContaining({ id: "antigravity-cli" }), expect.objectContaining({ id: "pi-cli" }), expect.objectContaining({ id: "opencode-cli" }), ]); @@ -924,7 +907,7 @@ describe("detectSetupInference", () => { ]); }); - it("omits Gemini CLI because setup verification cannot hard-disable its tools", async () => { + it("omits Gemini CLI instead of presenting an unverifiable setup route", async () => { vi.mocked(detectInferenceBackends).mockResolvedValueOnce([ { kind: "gemini-cli", @@ -950,9 +933,7 @@ describe("detectSetupInference", () => { expect(detection.candidates).toEqual([ expect.objectContaining({ kind: "claude-cli", recommended: false }), ]); - expect(detection.unavailableCandidates).toEqual([ - expect.objectContaining({ id: "gemini-cli" }), - ]); + expect(detection.unavailableCandidates).toEqual([]); }); it("reports installed Pi and OpenCode without offering them as setup inference routes", async () => { @@ -986,6 +967,7 @@ describe("detectSetupInference", () => { ]); expect(probeLocalCommand).toHaveBeenCalledWith("pi"); expect(probeLocalCommand).toHaveBeenCalledWith("opencode"); + expect(probeLocalCommand).not.toHaveBeenCalledWith("agy"); }); }); From c7d1205d5128544e219124bdb31ab305fa926ea7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 22:40:20 +0800 Subject: [PATCH 004/239] fix(google): guide broken CLI profiles to supported auth --- extensions/google/cli-backend-auth.runtime.ts | 8 +++-- extensions/google/setup-api.test.ts | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/extensions/google/cli-backend-auth.runtime.ts b/extensions/google/cli-backend-auth.runtime.ts index 8a3fd09a4f4e..768b74b858c3 100644 --- a/extensions/google/cli-backend-auth.runtime.ts +++ b/extensions/google/cli-backend-auth.runtime.ts @@ -37,6 +37,8 @@ const GEMINI_CLI_API_KEY_AUTH_ENV = [ ]; const GEMINI_CLI_PROFILE_AUTH_ENV = [...GEMINI_CLI_API_KEY_AUTH_ENV, "GEMINI_API_KEY"]; const GEMINI_CLI_PROFILE_SETTINGS_ENV = ["GEMINI_CLI_SYSTEM_SETTINGS_PATH"]; +const GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE = + "Open Models settings and connect Google with an AI Studio API key, then select that profile for this model."; type GeminiAuthProfileCredential = { type: "api_key" | "oauth" | "token"; @@ -97,14 +99,14 @@ function throwUnstageableSelectedGeminiProfile( } if (!credential) { throw new Error( - "Gemini CLI auth profile was selected but no credential material was found. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.", + `Gemini CLI auth profile was selected but no credential material was found. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`, ); } if (credential.provider !== GEMINI_CLI_PROVIDER_ID) { throwUnsupportedGeminiCredential(credential); } throw new Error( - "Gemini CLI execution supports google-gemini-cli OAuth or API-key auth profiles. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.", + `Gemini CLI execution requires a Google AI Studio API-key profile or a previously configured valid Gemini CLI OAuth profile. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`, ); } @@ -130,7 +132,7 @@ function requireGeminiOAuthCredential( !Number.isFinite(credential.expires) ) { throw new Error( - "Gemini CLI OAuth profile is missing usable token material. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.", + `Gemini CLI OAuth profile is incomplete and cannot be repaired by OpenClaw. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`, ); } diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index 3164410a2e6f..614672f9b9ea 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -753,7 +753,7 @@ describe("google gemini cli backend auth bridge", () => { token: "bearer-token", }, } as never), - ).rejects.toThrow(/OAuth or API-key auth profiles/); + ).rejects.toThrow(/Google AI Studio API-key profile/); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); } @@ -772,7 +772,33 @@ describe("google gemini cli backend auth bridge", () => { modelId: "gemini-3.1-flash-lite", authProfileId: "google-gemini-cli:missing", } as never), - ).rejects.toThrow(/no credential material/); + ).rejects.toThrow(/Open Models settings and connect Google with an AI Studio API key/); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("routes incomplete legacy Gemini OAuth profiles to supported Google setup", async () => { + const backend = buildGoogleGeminiCliBackend(); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-")); + + try { + await expect( + backend.prepareExecution?.({ + workspaceDir, + agentDir: path.join(workspaceDir, "agent"), + provider: "google-gemini-cli", + modelId: "gemini-3.1-flash-lite", + authProfileId: "google-gemini-cli:legacy", + authCredential: { + type: "oauth", + provider: "google-gemini-cli", + access: "expired-access-token", + }, + } as never), + ).rejects.toThrow( + /OAuth profile is incomplete and cannot be repaired by OpenClaw.*AI Studio API key/, + ); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); } From 5e5d436579a8d6e90d34bed94cfda26b4c2b06cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 22:51:30 +0800 Subject: [PATCH 005/239] fix(ui): clarify blocked inference setup --- ui/src/e2e/inference-setup-gate.e2e.test.ts | 22 ++++--- ui/src/e2e/model-providers.e2e.test.ts | 4 +- ui/src/e2e/model-setup.e2e.test.ts | 73 +++++++-------------- ui/src/i18n/locales/en.ts | 16 ++--- ui/src/pages/chat/chat-view.test.ts | 4 +- ui/src/pages/model-providers/view.test.ts | 4 +- ui/src/pages/model-setup/view.test.ts | 49 ++++---------- 7 files changed, 61 insertions(+), 111 deletions(-) diff --git a/ui/src/e2e/inference-setup-gate.e2e.test.ts b/ui/src/e2e/inference-setup-gate.e2e.test.ts index dffcb95ee624..0d24a94c5a99 100644 --- a/ui/src/e2e/inference-setup-gate.e2e.test.ts +++ b/ui/src/e2e/inference-setup-gate.e2e.test.ts @@ -24,6 +24,7 @@ async function captureProof(page: import("playwright").Page, fileName: string) { suite.define(() => { it("blocks empty chat home until a model is connected", async () => { const context = await suite.browser.newContext({ + colorScheme: "dark", locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1440 }, @@ -38,10 +39,10 @@ suite.define(() => { await expect.poll(() => page.locator(".agent-chat__composer-shell").count()).toBe(0); await expect.poll(() => page.locator("textarea").count()).toBe(0); await expect - .poll(() => page.getByRole("button", { name: "Configure a provider" }).count()) + .poll(() => page.getByRole("button", { name: "Connect an AI provider" }).count()) .toBe(1); await captureProof(page, "chat-home-desktop.png"); - await page.getByRole("button", { name: "Configure a provider" }).click(); + await page.getByRole("button", { name: "Connect an AI provider" }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup"); } finally { await context.close(); @@ -50,6 +51,7 @@ suite.define(() => { it("blocks the new-session composer until a model is connected", async () => { const context = await suite.browser.newContext({ + colorScheme: "dark", locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1440 }, @@ -64,7 +66,7 @@ suite.define(() => { await expect.poll(() => page.locator(".new-session-page__composer").count()).toBe(0); await expect.poll(() => page.locator("textarea").count()).toBe(0); await captureProof(page, "new-session-desktop.png"); - await page.getByRole("button", { name: "Configure a provider" }).click(); + await page.getByRole("button", { name: "Connect an AI provider" }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup"); } finally { await context.close(); @@ -73,6 +75,7 @@ suite.define(() => { it("shows the setup splash before starting custodian chat", async () => { const context = await suite.browser.newContext({ + colorScheme: "dark", locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1660 }, @@ -98,7 +101,7 @@ suite.define(() => { await page.setViewportSize({ height: 520, width: 900 }); await expect - .poll(() => page.getByRole("button", { name: "Configure a provider" }).isVisible()) + .poll(() => page.getByRole("button", { name: "Connect an AI provider" }).isVisible()) .toBe(true); await expect .poll(() => @@ -110,7 +113,7 @@ suite.define(() => { await captureProof(page, "custodian-short-window.png"); await page.setViewportSize({ height: 900, width: 1660 }); - await page.getByRole("button", { name: "Configure a provider" }).click(); + await page.getByRole("button", { name: "Connect an AI provider" }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup"); const modelsLink = page.locator('.settings-sidebar__item[href="/settings/model-providers"]'); await expect.poll(() => modelsLink.getAttribute("aria-current")).toBe("page"); @@ -122,6 +125,7 @@ suite.define(() => { it("distinguishes a configured provider that fails its live check", async () => { const context = await suite.browser.newContext({ + colorScheme: "dark", locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1660 }, @@ -156,17 +160,15 @@ suite.define(() => { message: "OpenClaw requires working inference: provider authentication failed", }); - await page - .getByRole("heading", { name: "OpenClaw couldn't use your configured AI" }) - .waitFor(); + await page.getByRole("heading", { name: "Configured AI needs attention" }).waitFor(); await expect.poll(() => page.locator(".agent-chat__composer-shell").count()).toBe(0); await expect - .poll(() => page.getByRole("button", { name: "Check provider settings" }).count()) + .poll(() => page.getByRole("button", { name: "Review connection" }).count()) .toBe(1); await expect.poll(() => page.getByRole("button", { name: "Retry" }).count()).toBe(1); await captureProof(page, "custodian-provider-unavailable.png"); - await page.getByRole("button", { name: "Check provider settings" }).click(); + await page.getByRole("button", { name: "Review connection" }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup"); const modelsLink = page.locator('.settings-sidebar__item[href="/settings/model-providers"]'); await expect.poll(() => modelsLink.getAttribute("aria-current")).toBe("page"); diff --git a/ui/src/e2e/model-providers.e2e.test.ts b/ui/src/e2e/model-providers.e2e.test.ts index b4954127ab02..1112fb1380ff 100644 --- a/ui/src/e2e/model-providers.e2e.test.ts +++ b/ui/src/e2e/model-providers.e2e.test.ts @@ -125,7 +125,9 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => { const openaiCard = page.locator('[data-provider-id="openai"]'); const readiness = page.locator('[data-model-readiness="model-required"]'); await readiness.waitFor(); - await expect.poll(async () => readiness.textContent()).toContain("Connect your AI"); + await expect + .poll(async () => readiness.textContent()) + .toContain("Connect a verified AI model"); await expect.poll(async () => readiness.textContent()).toContain("No models available"); await expect.poll(async () => openaiCard.textContent()).toContain("Signed in"); expect(await page.locator(".model-providers__defaults").count()).toBe(0); diff --git a/ui/src/e2e/model-setup.e2e.test.ts b/ui/src/e2e/model-setup.e2e.test.ts index 2a57414dcc8e..357c0aacff67 100644 --- a/ui/src/e2e/model-setup.e2e.test.ts +++ b/ui/src/e2e/model-setup.e2e.test.ts @@ -94,7 +94,7 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { try { const response = await page.goto(`${server.baseUrl}settings/model-setup?firstRun=1`); expect(response?.status()).toBe(200); - await page.getByRole("heading", { name: "Connect your AI" }).waitFor(); + await page.getByRole("heading", { name: "Connect a verified AI model" }).waitFor(); const candidate = page.locator('[data-candidate-kind="codex-cli"]'); await expect.poll(() => candidate.locator('[data-provider-icon="codex"]').count()).toBe(1); await candidate.getByRole("button", { name: "Test & use" }).click(); @@ -413,7 +413,7 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { } }); - it("turns an unverifiable Gemini CLI login into direct recovery actions", async () => { + it("offers supported Google setup in an accessible provider picker", async () => { const context = await browser.newContext({ colorScheme: "dark", locale: "en-US", @@ -427,24 +427,12 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { "chat.startup", "openclaw.setup.detect", "openclaw.setup.activate", - "openclaw.setup.auth.start", "openclaw.setup.prepare.start", ], methodResponses: { "openclaw.setup.detect": { candidates: [], - unavailableCandidates: [ - { - id: "gemini-cli", - brandId: "google-gemini-cli", - label: "Gemini CLI", - detail: "installed; login status unavailable", - reason: - "OpenClaw cannot confirm whether this private Gemini CLI login works without starting a session that may expose tools. Sign in through OpenClaw or use a Gemini API key to create a connection it can verify.", - authOptionId: "google-gemini-cli", - manualProviderId: "gemini-api-key", - }, - ], + unavailableCandidates: [], manualProviders: [ { id: "qwen-cn", @@ -470,20 +458,11 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { id: "gemini-api-key", brandId: "google", groupLabel: "Google", - label: "Google Gemini API key", - hint: "Use an AI Studio API key.", - }, - ], - authOptions: [ - { - id: "google-gemini-cli", - brandId: "google-gemini-cli", - label: "Gemini CLI OAuth", - groupLabel: "Google", - kind: "oauth", - featured: true, + label: "Google AI Studio API key", + hint: "Supported API-key access from aistudio.google.com/apikey", }, ], + authOptions: [], workspace: "/tmp/openclaw-e2e", setupComplete: false, }, @@ -493,20 +472,15 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { latencyMs: 412, lines: ["Model ready"], }, - "openclaw.setup.auth.start": { - sessionId: "gemini-oauth-session", - done: false, - status: "running", - }, }, }); try { const response = await page.goto(`${server.baseUrl}settings/model-setup`); expect(response?.status()).toBe(200); - await page.getByRole("heading", { name: "Found, but needs attention" }).waitFor(); - await page.getByRole("button", { name: "Sign in with Google" }).waitFor(); - await page.getByRole("button", { name: "Use API key" }).waitFor(); + await page.getByRole("heading", { name: "Connect a verified AI model" }).waitFor(); + await expect.poll(() => page.getByText("Gemini CLI OAuth").count()).toBe(0); + await expect.poll(() => page.getByText("Found, but needs attention").count()).toBe(0); const providerPicker = page.locator(".model-setup-provider-select"); const providerTrigger = providerPicker.locator(".model-setup-provider-select__trigger"); @@ -630,11 +604,16 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { .toBe(true); await accessValue.fill("sk-old-provider-secret"); - await page.getByRole("button", { name: "Use API key" }).click(); + await providerTrigger.click(); + await expect.poll(manualProviderMenuReady).toBe(true); + const googleProviderHidden = waitForProviderHide(); + await page.locator('[data-manual-provider="gemini-api-key"]').click(); + await googleProviderHidden; await expect.poll(() => providerTrigger.textContent()).toContain("Google"); + await expect.poll(() => providerTrigger.textContent()).toContain("AI Studio API key"); await expect.poll(() => accessValue.inputValue()).toBe(""); await expect - .poll(() => accessValue.evaluate((element) => element === document.activeElement)) + .poll(() => providerTrigger.evaluate((element) => element === document.activeElement)) .toBe(true); await providerTrigger.click(); @@ -696,21 +675,13 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { await expect .poll(async () => (await gateway.getRequests("openclaw.setup.detect")).length) .toBe(detectCountBeforeDismiss + 1); - await page.getByRole("button", { name: "Use API key" }).click(); + await providerTrigger.click(); + await expect.poll(manualProviderMenuReady).toBe(true); + const googleProviderHiddenAfterDismiss = waitForProviderHide(); + await page.locator('[data-manual-provider="gemini-api-key"]').click(); + await googleProviderHiddenAfterDismiss; await expect.poll(() => providerTrigger.textContent()).toContain("Google"); - - const detectCount = (await gateway.getRequests("openclaw.setup.detect")).length; - await page - .locator('[data-unavailable-candidate="gemini-cli"]') - .getByRole("button", { name: "Check again" }) - .click(); - await expect - .poll(async () => (await gateway.getRequests("openclaw.setup.detect")).length) - .toBe(detectCount + 1); - - await page.getByRole("button", { name: "Sign in with Google" }).click(); - const start = await gateway.waitForRequest("openclaw.setup.auth.start"); - expect(start.params).toMatchObject({ authChoice: "google-gemini-cli" }); + await expect.poll(() => page.getByText("Gemini CLI OAuth").count()).toBe(0); } finally { await context.close(); } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 11bad9bd2bd5..514815c87052 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1960,18 +1960,18 @@ export const en: TranslationMap = { plugin: "Plugin-provided panel.", }, modelSetup: { - heading: "Connect your AI", + heading: "Connect a verified AI model", intro: - "OpenClaw reuses AI access you already have — a CLI login, an API key, or a provider sign-in.", + "OpenClaw checks the AI access available on this Gateway and verifies the exact model before it enables conversations.", required: { title: "No AI provider configured", - body: "OpenClaw couldn't find a provider and model configured for this agent. Add one before starting a conversation.", - action: "Configure a provider", + body: "We couldn't find a provider and model configured for this agent. Choose a supported connection; OpenClaw will test it before enabling chat.", + action: "Connect an AI provider", }, connectionFailure: { - title: "OpenClaw couldn't use your configured AI", - body: "This agent has a provider and model selected, but the connection failed. Check the provider login or API key, model access, and service status, then try again.", - action: "Check provider settings", + title: "Configured AI needs attention", + body: "OpenClaw found the provider and model selected for this agent, but the live check failed. Your configuration is still intact. Review the credential, model access, or provider status, then verify again.", + action: "Review connection", }, loading: "Checking this Gateway for available AI access…", retry: "Retry", @@ -3923,7 +3923,7 @@ export const en: TranslationMap = { }, readiness: { title: "AI setup", - heading: "Connect your AI", + heading: "Connect a verified AI model", signedInNoModels: "You're signed in, but this account exposes no usable models. Choose another provider or account to continue.", notConfigured: "Choose a provider and verify the model OpenClaw will use.", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index dd78ceb3593a..73c1ea3c4053 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -4381,8 +4381,8 @@ describe("chat welcome", () => { canSend: false, disabledBanner: { kind: "composer-replacement", - text: "OpenClaw couldn't find a provider and model configured for this agent. Add one before starting a conversation.", - actionLabel: "Configure a provider", + text: "We couldn't find a provider and model configured for this agent. Choose a supported connection; OpenClaw will test it before enabling chat.", + actionLabel: "Connect an AI provider", onAction: () => undefined, }, modelSetupRequired: true, diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 02718444dccb..f4aabefa6d04 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -230,7 +230,7 @@ describe("renderModelProviders", () => { ); const readiness = container.querySelector('[data-model-readiness="model-required"]'); - expect(text(readiness)).toContain("Connect your AI"); + expect(text(readiness)).toContain("Connect a verified AI model"); expect(text(readiness)).toContain("No models available"); expect(text(readiness)).toContain("Choose another provider"); expect(container.querySelector(".model-providers__defaults")).toBeNull(); @@ -269,7 +269,7 @@ describe("renderModelProviders", () => { const readiness = container.querySelector('[data-model-readiness="model-required"]'); expect(text(readiness)).toContain("Model required"); - expect(button(readiness!, "Connect your AI")).toBeDefined(); + expect(button(readiness!, "Connect a verified AI model")).toBeDefined(); expect(container.querySelector(".model-providers__defaults")).toBeNull(); }); diff --git a/ui/src/pages/model-setup/view.test.ts b/ui/src/pages/model-setup/view.test.ts index 1e242e73c603..3f6f2f23ac2a 100644 --- a/ui/src/pages/model-setup/view.test.ts +++ b/ui/src/pages/model-setup/view.test.ts @@ -23,13 +23,10 @@ const detected: SystemAgentSetupDetectResult = { ], unavailableCandidates: [ { - id: "gemini-cli", - brandId: "google-gemini-cli", - label: "Gemini CLI", - detail: "installed; login status unavailable", - reason: "OpenClaw could not confirm a usable login.", - authOptionId: "google-gemini-cli", - manualProviderId: "gemini-api-key", + id: "pi-cli", + label: "Pi", + detail: "installed; no setup route available", + reason: "This local runtime must be configured outside OpenClaw.", }, ], manualProviders: [ @@ -37,8 +34,8 @@ const detected: SystemAgentSetupDetectResult = { id: "gemini-api-key", brandId: "google", groupLabel: "Google", - label: "Google Gemini API key", - hint: "Use an AI Studio API key.", + label: "Google AI Studio API key", + hint: "Supported API-key access from aistudio.google.com/apikey", }, { id: "openai", @@ -50,15 +47,6 @@ const detected: SystemAgentSetupDetectResult = { }, ], authOptions: [ - { - id: "google-gemini-cli", - brandId: "google-gemini-cli", - label: "Gemini CLI OAuth", - groupLabel: "Google", - kind: "oauth", - featured: true, - hint: "Continue with Google.", - }, { id: "openai-oauth", brandId: "openai", @@ -175,12 +163,12 @@ describe("renderModelSetup", () => { it("renders candidate, unavailable, sign-in, and manual sections", () => { const container = mount(props()); - expect(text(container)).toContain("Connect your AI"); + expect(text(container)).toContain("Connect a verified AI model"); expect(text(container)).toContain("Found on this Gateway"); expect(text(container)).toContain("Codex CLI"); expect(text(container)).toContain("openai/gpt-5 · Signed in locally"); expect(text(container)).toContain("Found, but needs attention"); - expect(text(container)).toContain("OpenClaw could not confirm a usable login"); + expect(text(container)).toContain("This local runtime must be configured outside OpenClaw"); expect(text(container)).toContain("Sign in with a provider"); expect(text(container)).toContain("Set up a local model"); expect(text(container)).toContain("Connect with an API key or token"); @@ -192,9 +180,6 @@ describe("renderModelSetup", () => { ); expect(container.querySelector('input[type="password"]')).not.toBeNull(); expect(container.querySelector("details")?.open).toBe(false); - expect( - container.querySelector('[data-unavailable-candidate="gemini-cli"] [data-provider-icon]'), - ).not.toBeNull(); expect( container.querySelector('[data-candidate-kind="codex-cli"] [data-provider-icon="codex"]'), ).not.toBeNull(); @@ -462,26 +447,16 @@ describe("renderModelSetup", () => { expect(onSuccessClose).toHaveBeenCalledOnce(); }); - it("offers direct recovery actions for an unavailable provider", () => { - const onStartAuth = vi.fn(); - const onUseManualProvider = vi.fn(); + it("only rechecks unavailable runtimes without a supported setup route", () => { const onDetect = vi.fn(); - const container = mount(props({ onStartAuth, onUseManualProvider, onDetect })); + const container = mount(props({ onDetect })); const buttons = container.querySelectorAll( - '[data-unavailable-candidate="gemini-cli"] button', + '[data-unavailable-candidate="pi-cli"] button', ); - expect([...buttons].map((button) => button.textContent?.trim())).toEqual([ - "Sign in with Google", - "Use API key", - "Check again", - ]); + expect([...buttons].map((button) => button.textContent?.trim())).toEqual(["Check again"]); buttons[0]?.click(); - buttons[1]?.click(); - buttons[2]?.click(); - expect(onStartAuth).toHaveBeenCalledWith(expect.objectContaining({ id: "google-gemini-cli" })); - expect(onUseManualProvider).toHaveBeenCalledWith("gemini-api-key"); expect(onDetect).toHaveBeenCalledOnce(); }); From d540255ce706fca0bfc4136c91e6e49f7ca95d8a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 22:56:26 +0800 Subject: [PATCH 006/239] docs(google): document supported auth paths --- docs/cli/openclaw.md | 6 +- docs/cli/setup.md | 5 +- docs/concepts/model-providers.md | 51 ++++------------ docs/concepts/usage-tracking.md | 2 +- docs/docs_map.md | 2 +- docs/gateway/cli-backends.md | 6 +- docs/help/faq-first-run.md | 18 +++--- docs/nodes/audio.md | 3 +- docs/nodes/media-understanding.md | 3 - docs/providers/google.md | 99 +++++++++++++++---------------- docs/providers/index.md | 2 +- docs/providers/models.md | 2 +- docs/start/onboarding-overview.md | 6 +- docs/start/onboarding.md | 15 ++--- docs/start/wizard.md | 7 +-- 15 files changed, 100 insertions(+), 127 deletions(-) diff --git a/docs/cli/openclaw.md b/docs/cli/openclaw.md index a7581f970ba9..7b7ec72b4efd 100644 --- a/docs/cli/openclaw.md +++ b/docs/cli/openclaw.md @@ -265,8 +265,10 @@ a single OpenClaw authority tool plus the inert native planning utility. In all three cases, setup writes remain confined to OpenClaw's audited approval contract. -Gemini CLI remains available for normal agents, but it cannot enforce the -tool-free probe required by the inference gate, so it cannot host OpenClaw. +Gemini CLI remains available as an explicitly configured runtime for normal +agents, but Gemini CLI and Antigravity are not inference-gate setup routes. +Use AI Studio API-key or Vertex AI for the inference gate. The optional Gemini +CLI runtime specifically requires an AI Studio API-key profile. ## Switching to an agent diff --git a/docs/cli/setup.md b/docs/cli/setup.md index 67ef90d724a2..4a2961593918 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -41,8 +41,9 @@ automatic pass. Detected local runtimes are auto-tested after CLI and API-key candidates; when several local models are available, OpenClaw prefers the strongest tool-calling instruct family. The selected candidate must answer a real completion before its provider and model configuration is saved. -Installed Gemini, Antigravity, Pi, and OpenCode CLIs are also reported when -they cannot serve as the reusable inference route for guided setup. +Pi and OpenCode CLIs may also be reported for context when they cannot serve as +the reusable inference route for guided setup. Gemini CLI and Antigravity are +not offered as detected setup routes. `setup` accepts the same onboarding flags as `openclaw onboard`, including auth (`--auth-choice`, `--token`, provider key flags), Gateway diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8e799ae96c33..23d297866780 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -234,49 +234,18 @@ Claude CLI reuse (`claude -p`) is a sanctioned OpenClaw integration path. Anthro - Thinking: `/think adaptive` uses Google dynamic thinking. Gemini 3/3.1 omit a fixed `thinkingLevel`; Gemini 2.5 sends `thinkingBudget: -1`. - Direct Gemini runs also accept `agents.defaults.models["google/"].params.cachedContent` (or legacy `cached_content`) to forward a provider-native `cachedContents/...` handle; Gemini cache hits surface as OpenClaw `cacheRead` -### Google Vertex and Gemini CLI +### Google Vertex and Gemini CLI runtime -- Providers: `google-vertex`, `google-gemini-cli` -- Auth: Vertex uses gcloud ADC; Gemini CLI uses its OAuth flow +- `google-vertex`: managed Google Cloud access through gcloud Application + Default Credentials. +- `google-gemini-cli`: optional local runtime for an explicitly configured + canonical `google/*` model. - -Gemini CLI OAuth in OpenClaw is an unofficial integration. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. - - -Gemini CLI OAuth is shipped as part of the bundled `google` plugin. - - - - - - ```bash - brew install gemini-cli - ``` - - - ```bash - npm install -g @google/gemini-cli - ``` - - - - - ```bash - openclaw plugins enable google - ``` - - - ```bash - openclaw models auth login --provider google-gemini-cli --set-default - ``` - - Default model: `google-gemini-cli/gemini-3-flash-preview`. You do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores tokens in auth profiles on the gateway host. - - - - If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host. - - +OpenClaw does not create Gemini CLI OAuth or Antigravity OAuth profiles. Connect +Google through an AI Studio API key or Vertex AI. If you explicitly choose the +Gemini CLI runtime, it can use the selected Google API-key profile. Existing +valid Gemini CLI OAuth profiles remain runtime-compatible, but they are not a +setup or recovery route. Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream messages and normalizes `stats.cached` into `cacheRead`; legacy diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 6b7d1b898913..3e22f1eb51b9 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -315,7 +315,7 @@ provider-neutral for CLI, app, and Control UI consumers. - **DeepSeek**: API key via env/config/auth store (`DEEPSEEK_API_KEY`). Shows each provider-reported currency balance. - **GitHub Copilot**: OAuth tokens in auth profiles. -- **Gemini CLI**: OAuth tokens in auth profiles. +- **Gemini CLI**: existing OAuth profiles or supported Google API-key profiles. - **MiniMax**: API key or MiniMax OAuth auth profile. OpenClaw treats `minimax`, `minimax-cn`, and `minimax-portal` as the same MiniMax quota surface, prefers stored MiniMax OAuth when present, and otherwise falls back diff --git a/docs/docs_map.md b/docs/docs_map.md index 588babdd0330..c34437b0d5ce 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2795,7 +2795,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Other subscription-style hosted options - H3: OpenCode - H3: Google Gemini (API key) - - H3: Google Vertex and Gemini CLI + - H3: Google Vertex and Gemini CLI runtime - H3: Z.AI (GLM) - H3: Vercel AI Gateway - H3: Other bundled provider plugins diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index f76e7795841f..2d5ace905d96 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -231,7 +231,11 @@ The bundled Google plugin registers for `google-gemini-cli`: | `sessionMode` | `existing` | | `sessionIdFields` | `["session_id", "sessionId"]` | -Prerequisite: the local Gemini CLI must be installed and on `PATH` as `gemini` (`brew install gemini-cli` or `npm install -g @google/gemini-cli`). +Prerequisites: the local Gemini CLI must be installed and on `PATH` as `gemini` +(`brew install gemini-cli` or `npm install -g @google/gemini-cli`), and the +selected model must have a supported Google AI Studio API-key profile. Existing +valid legacy Gemini CLI OAuth profiles remain runtime-compatible, but OpenClaw +does not create or repair them. Gemini CLI output notes: diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 732456b4b73e..e50ee1947649 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -606,18 +606,16 @@ and troubleshooting see the main [FAQ](/help/faq). - - Gemini CLI uses a **plugin auth flow**, not a client id or secret in `openclaw.json`. + + OpenClaw does not offer new Gemini CLI OAuth or Antigravity OAuth setup. + Connect Google with an AI Studio API key or Vertex AI instead. - 1. Install Gemini CLI locally so `gemini` is on `PATH`: - - Homebrew: `brew install gemini-cli` - - npm: `npm install -g @google/gemini-cli` - 2. Enable the plugin: `openclaw plugins enable google` - 3. Login: `openclaw models auth login --provider google-gemini-cli --set-default` - 4. Default model after login: `google/gemini-3.1-pro-preview` (runtime `google-gemini-cli`) - 5. Requests failing after login? Set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host and retry. + The optional `google-gemini-cli` runtime remains available for advanced + setups using a supported Google API-key profile. Existing valid legacy + Gemini CLI OAuth profiles remain executable for compatibility, but OpenClaw + cannot create or repair them. - OAuth tokens are stored in auth profiles on the gateway host. Details: [Google](/providers/google), [Model providers](/concepts/model-providers). + Details: [Google](/providers/google), [Model providers](/concepts/model-providers). diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md index 21a012a379fd..563f88c85775 100644 --- a/docs/nodes/audio.md +++ b/docs/nodes/audio.md @@ -37,7 +37,8 @@ If you have not configured models and `tools.media.audio.enabled` is not `false` Install/link provenance is capability evidence, not execution evidence. It never moves a candidate ahead of CPU sherpa by itself. OpenClaw does not load a model during setup or status checks just to probe a backend. Auto-detected whisper.cpp keeps its normal model-run logs enabled so OpenClaw can record the upstream `using … backend` line. Explicit CLI entries keep their configured output flags. -Gemini CLI auto-detect for media understanding was replaced by a sandboxed Antigravity CLI (`agy`) fallback for image/video; audio does not use a CLI fallback beyond the local binaries above. +Gemini CLI and Antigravity are not auto-detected for media understanding. Audio +does not use a CLI fallback beyond the local binaries above. To disable auto-detection, set `tools.media.audio.enabled: false`. To customize, add capability-tagged entries to `tools.media.models`. diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index 475b013d52ae..9909497c1a76 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -174,9 +174,6 @@ When `tools.media..enabled` is not `false` and no models are configu - Video: Google → Qwen → Moonshot - - First installed `agy` or `antigravity` binary (override with `OPENCLAW_ANTIGRAVITY_CLI`), sandboxed against the media's directory. - To disable auto-detection for a capability: diff --git a/docs/providers/google.md b/docs/providers/google.md index 6d23566d88b1..7f4eb71e8371 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -1,9 +1,9 @@ --- -summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, TTS, web search)" +summary: "Google Gemini setup (AI Studio API key, Vertex AI, optional CLI runtime, and multimodal tools)" title: "Google (Gemini)" read_when: - You want to use Google Gemini models with OpenClaw - - You need the API key or OAuth auth flow + - You need Google AI Studio, Vertex AI, or Gemini CLI runtime guidance --- The Google plugin provides access to Gemini models through Google AI Studio, plus image generation, media understanding (image/audio/video), text-to-speech, and web search via Gemini Grounding. @@ -11,15 +11,17 @@ The Google plugin provides access to Gemini models through Google AI Studio, plu - Provider: `google` - Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY` - API: Google Gemini API -- Runtime option: `agentRuntime.id: "google-gemini-cli"` reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`. +- Managed-cloud provider: `google-vertex` with Google Cloud Application Default Credentials +- Optional runtime: `agentRuntime.id: "google-gemini-cli"` runs an explicitly configured model through the local Gemini CLI ## Getting started -Choose your preferred auth method and follow the setup steps. +For most installations, use a Google AI Studio API key. Use `google-vertex` when +the Gateway already runs inside a managed Google Cloud environment. - - **Best for:** standard Gemini API access through Google AI Studio. + + **Recommended for:** standard Gemini API access. @@ -70,16 +72,23 @@ Choose your preferred auth method and follow the setup steps. - - **Best for:** signing in with your Google account through Gemini CLI OAuth instead of using a separate API key. + + **Advanced use only:** run a canonical `google/*` model through an installed + Gemini CLI while keeping authentication on the supported AI Studio API-key + path. - - The `google-gemini-cli` provider is an unofficial integration. Some users - report account restrictions when using OAuth this way. Use at your own risk. - + OpenClaw does not offer new Gemini CLI OAuth or Antigravity OAuth setup. + [Google ended consumer Gemini CLI Login with Google access on June 18, 2026](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals), + and the [Antigravity terms](https://antigravity.google/terms) prohibit + third-party tools from accessing the service through Antigravity OAuth. Use + an AI Studio API key or Vertex AI instead. - + + Complete the API-key setup in the first tab. OpenClaw must have a usable + `google` API-key profile before the CLI runtime can be selected. + + The local `gemini` command must be available on `PATH`. ```bash @@ -93,46 +102,37 @@ Choose your preferred auth method and follow the setup steps. OpenClaw supports both Homebrew installs and global npm installs, including common Windows/npm layouts. - - ```bash - openclaw models auth login --provider google-gemini-cli --set-default - ``` - - - ```bash - openclaw models list --provider google + + Keep the canonical Google model ref and opt that model into the CLI + runtime: + + ```json5 + { + agents: { + defaults: { + model: { primary: "google/gemini-3.1-pro-preview" }, + models: { + "google/gemini-3.1-pro-preview": { + agentRuntime: { id: "google-gemini-cli" }, + }, + }, + }, + }, + } ``` - - Default model: `google/gemini-3.1-pro-preview` - Runtime: `google-gemini-cli` - - Alias: `gemini-cli` + - Auth: selected Google AI Studio API-key profile + - Model refs: canonical `google/*` - Gemini 3.1 Pro's Gemini API model id is `gemini-3.1-pro-preview`. OpenClaw accepts the shorter `google/gemini-3.1-pro` as a convenience alias and normalizes it before provider calls. + Existing valid Gemini CLI OAuth profiles remain executable for compatibility, + but OpenClaw cannot create or repair them. If one breaks, replace it with a + Google AI Studio API-key profile. - **Environment variables:** - - - `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` - - `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` - - - If Gemini CLI OAuth requests fail after login, set `GOOGLE_CLOUD_PROJECT` or - `GOOGLE_CLOUD_PROJECT_ID` on the gateway host and retry. - - - - If login fails before the browser flow starts, make sure the local `gemini` - command is installed and on `PATH`. - - - Onboarding auto-detection lists an existing Gemini CLI login but never - auto-tests it because Gemini CLI has no tool-free probe. Choose Gemini CLI - OAuth or a Gemini API key to continue. - - `google-gemini-cli/*` model refs are legacy compatibility aliases. New - configs should use `google/*` model refs plus the `google-gemini-cli` - runtime when they want local Gemini CLI execution. + `google-gemini-cli/*` refs remain legacy compatibility aliases. New configs + should use `google/*` model refs plus the explicit runtime selection above. @@ -466,10 +466,9 @@ verifies a text response and `describe_view` function roundtrip. - When using the `google-gemini-cli` OAuth provider, OpenClaw uses Gemini - CLI `stream-json` output by default and normalizes usage from the final - `stats` payload. Legacy `--output-format json` overrides still use the - JSON parser. + The optional `google-gemini-cli` runtime uses Gemini CLI `stream-json` + output by default and normalizes usage from the final `stats` payload. + Legacy `--output-format json` overrides still use the JSON parser. - Streamed reply text comes from assistant `message` events. - For legacy JSON output, reply text comes from the CLI JSON `response` field. diff --git a/docs/providers/index.md b/docs/providers/index.md index 28d76cfe4045..11802f05f328 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -87,7 +87,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi ## Shared overview pages -- [Additional provider variants](/providers/models#additional-provider-variants) - Anthropic Vertex, Copilot Proxy, and Gemini CLI OAuth +- [Additional provider variants](/providers/models#additional-provider-variants) - Anthropic Vertex, Copilot Proxy, and the optional Gemini CLI runtime - [Image Generation](/tools/image-generation) - Shared `image_generate` tool, provider selection, and failover - [Music Generation](/tools/music-generation) - Shared `music_generate` tool, provider selection, and failover - [Video Generation](/tools/video-generation) - Shared `video_generate` tool, provider selection, and failover diff --git a/docs/providers/models.md b/docs/providers/models.md index 14e07184a0e7..29dced9eea9e 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -57,7 +57,7 @@ For the full provider catalog and advanced configuration, see - `anthropic-vertex` - install `@openclaw/anthropic-vertex-provider` for implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice - `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy` -- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default` +- `google-gemini-cli` - optional explicit runtime for canonical `google/*` models; requires a local `gemini` install and a supported Google AI Studio API-key profile; new Gemini CLI or Antigravity OAuth setup is not offered ## Related diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index 5c7b39a186fb..55395673d395 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -93,8 +93,10 @@ offering a verified manual API-key step when nothing is found. Sensitive credentials use masked input. Once inference passes, OpenClaw starts and helps configure the rest. -Gemini CLI remains available for normal agents after setup, but it is not -offered for this inference gate because it cannot enforce the tool-free probe. +Gemini CLI remains available as an explicitly configured runtime after setup, +but Gemini CLI and Antigravity are not offered as detected inference routes. +Use Google AI Studio API-key or Vertex AI for guided setup. The optional Gemini +CLI runtime specifically requires an AI Studio API-key profile. Full reference: [Onboarding (macOS App)](/start/onboarding) diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index baa91b1ab5a9..bc7be10af8ee 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -91,15 +91,16 @@ To use a Claude subscription when the Gateway host has no Claude CLI login, run printed token as **Anthropic setup-token** under **Connect with an API key or token**. -Installed Gemini CLI, Antigravity, Pi, and OpenCode CLIs are shown for context -when they cannot be selected as the reusable guided-setup inference route. -Gemini and Antigravity cannot enforce the tool-free inference probe. Pi and -OpenCode are whole-agent harnesses rather than setup inference routes; their -session integrations require separate runtime and plugin setup. +Pi and OpenCode installs may be shown for context when they cannot be selected +as the reusable guided-setup inference route. They are whole-agent harnesses, +not setup inference routes; their session integrations require separate runtime +and plugin setup. Gemini CLI and Antigravity are not offered as detected setup +routes. You can also sign in through the provider's own OAuth or device-pairing flow. -The built-in choices include OpenAI/ChatGPT, OpenRouter, GitHub Copilot, Google -Gemini CLI, xAI, MiniMax Global and CN, and Chutes. The list comes from the +The built-in choices include OpenAI/ChatGPT, OpenRouter, GitHub Copilot, xAI, +MiniMax Global and CN, and Chutes. Google is available through the supported AI +Studio API-key route. The list comes from the Gateway's active text-inference provider plugins rather than a fixed app list, so another provider can opt in without adding provider-specific macOS code. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 663c87980ee2..cd7c0de4ef1b 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -92,10 +92,9 @@ Plain `openclaw onboard` follows this path: 2. Detect configured models, API-key environment variables, supported local AI CLIs, and already installed tool-capable models from reachable Ollama or LM Studio servers on the Gateway host. This read-only pass never downloads a - model. Gemini CLI, Antigravity, Pi, and OpenCode installs are also reported - when they cannot serve as the reusable inference route for guided setup. - Gemini and Antigravity cannot enforce the tool-free probe; Pi and OpenCode - are whole-agent harnesses rather than setup inference routes. + model. Pi and OpenCode installs may also be reported for context when they + cannot serve as the reusable inference route. Gemini CLI and Antigravity are + not offered as detected setup routes. 3. Test the first detected candidate with a real completion. On failure, show the reason and continue to the next usable candidate. 4. If detection is exhausted, choose OpenAI, Anthropic, xAI (Grok), Google, or From b9e2d9e81b224ebebd9d3d6a6b8c526619b9a4b0 Mon Sep 17 00:00:00 2001 From: synthclaw Date: Thu, 30 Jul 2026 20:53:45 -0400 Subject: [PATCH 007/239] fix(openai): restrict GPT-Live broker cleanup to plugin disable only Root cause: the OpenAI plugin registered the process-wide Quicksilver browser session broker cleanup as an unconditional runtime lifecycle callback. Session reset/delete/restart cleanup would permanently stop the shared broker, while talk.catalog continued to report the provider as ready. All later GPT-Live browser session reservations failed until process restart. Fix: the cleanup callback now checks ctx.reason and only tears down the broker when reason === "disable". Session reset/delete/restart cleanup leaves the broker running. Regression test: verifies cleanup is a no-op for reset/delete/restart and returns a promise for disable. Fixes openclaw/openclaw#116525 --- extensions/openai/index.test.ts | 30 ++++++++++++++++++++++++++++++ extensions/openai/index.ts | 9 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 6366a171bd0f..c71e3c71b269 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -185,6 +185,36 @@ describe("openai plugin", () => { ); }); + it("only cleans up the GPT-Live broker on plugin disable, not session reset/delete/restart", () => { + const registerRuntimeLifecycle = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "openai", + name: "OpenAI Provider", + source: "test", + config: {}, + runtime: { config: { current: vi.fn(() => ({})) } } as never, + registerHttpRoute: vi.fn(), + registerRuntimeLifecycle, + }), + ); + + const lifecycle = registerRuntimeLifecycle.mock.calls[0]?.[0] as { + cleanup: (ctx: { reason: string }) => Promise | void; + }; + expect(lifecycle).toBeDefined(); + + // Session reset/delete/restart must NOT trigger broker cleanup + for (const reason of ["reset", "delete", "restart"]) { + const result = lifecycle.cleanup({ reason }); + expect(result).toBeUndefined(); + } + + // Plugin disable MUST trigger broker cleanup + const disableResult = lifecycle.cleanup({ reason: "disable" }); + expect(disableResult).toBeDefined(); + }); + it("generates PNG buffers from the OpenAI Images API", async () => { const { resolveApiKeySpy, postJsonRequestSpy } = mockOpenAIImageApiResponse({ finalUrl: "https://api.openai.com/v1/images/generations", diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index ec12e67847be..317858be162e 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -42,7 +42,14 @@ export default definePluginEntry({ api.lifecycle.registerRuntimeLifecycle({ id: "openai-quicksilver-realtime-browser-session", description: "Close GPT-Live browser sidebands when the OpenAI plugin stops", - cleanup: () => quicksilverSession.cleanup(), + cleanup: (ctx) => { + // Only tear down the process-wide broker when the plugin is actually + // being disabled. Session reset/delete/restart cleanup must not close + // the shared broker — it remains usable for later GPT-Live sessions. + if (ctx.reason === "disable") { + return quicksilverSession.cleanup(); + } + }, }); } const openAIToolCompatHooks = buildProviderToolCompatFamilyHooks("openai"); From 2f93db39eb3b5355c74de19d11c7e0aa052461cb Mon Sep 17 00:00:00 2001 From: synthclaw Date: Fri, 31 Jul 2026 00:30:35 -0400 Subject: [PATCH 008/239] fix(openai): return undefined from cleanup on non-disable reasons Fixes TS7030: Not all code paths return a value. --- extensions/openai/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index 317858be162e..7b5ccd4139db 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -49,6 +49,7 @@ export default definePluginEntry({ if (ctx.reason === "disable") { return quicksilverSession.cleanup(); } + return undefined; }, }); } From 5c257aca59913f437252df626395108485ff851c Mon Sep 17 00:00:00 2001 From: synthclaw Date: Fri, 31 Jul 2026 00:44:19 -0400 Subject: [PATCH 009/239] fix(edit): prefer-for-of loop in firstDifferenceIndex Fixes oxlint typescript(prefer-for-of) error. --- src/agents/sessions/tools/edit-diff.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agents/sessions/tools/edit-diff.ts b/src/agents/sessions/tools/edit-diff.ts index a5ed76ea9d52..0a665705bb51 100644 --- a/src/agents/sessions/tools/edit-diff.ts +++ b/src/agents/sessions/tools/edit-diff.ts @@ -347,8 +347,11 @@ function describeIndentation(line: string): string { function firstDifferenceIndex(left: string, right: string): number { const sharedLength = Math.min(left.length, right.length); - for (let index = 0; index < sharedLength; index++) { - if (left.charAt(index) !== right.charAt(index)) { + for (const [index, leftChar] of [...left].entries()) { + if (index >= sharedLength) { + break; + } + if (leftChar !== right.charAt(index)) { return index; } } From 781c3c1ce7ebb0548a50d4874354b8e68c8000be Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 13:15:52 +0800 Subject: [PATCH 010/239] fix(voice-call): cancel forced consults on teardown --- .../src/webhook/realtime-handler.ts | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index b6aebd936222..64b8ea27ab84 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -267,8 +267,10 @@ type RealtimeSpeakResult = { }; type ForcedConsultState = { + owner: ActiveRealtimeVoiceBridge; promise: Promise; sendSpeechPrompt: boolean; + cancelled: boolean; completedAt?: number; }; @@ -934,12 +936,10 @@ export class RealtimeCallHandler { }); }, onClose: (reason) => { - this.activeBridgesByCallId.delete(callId); - this.activeBridgesByCallId.delete(callSid); - this.activeTelephonyClosersByCallId.delete(callId); - this.activeTelephonyClosersByCallId.delete(callSid); if (nativeConsultOwner.current) { + this.clearActiveBridgeMappings(callId, callSid, nativeConsultOwner.current); this.cancelNativeConsult(callId, nativeConsultOwner.current); + this.cancelForcedConsult(callId, nativeConsultOwner.current); } this.clearUserTranscriptState(callId); harness.finishOutputAudio(reason); @@ -976,6 +976,10 @@ export class RealtimeCallHandler { emitCallEnd(reason); } }; + const previousSession = this.activeBridgesByCallId.get(callId); + if (previousSession && previousSession !== session) { + this.cancelForcedConsult(callId, previousSession); + } this.activeBridgesByCallId.set(callId, session); this.activeBridgesByCallId.set(callSid, session); this.activeTelephonyClosersByCallId.set(callId, closeTelephony); @@ -1007,13 +1011,10 @@ export class RealtimeCallHandler { try { closeSession(); } finally { - this.activeBridgesByCallId.delete(callId); - this.activeBridgesByCallId.delete(callSid); - this.activeTelephonyClosersByCallId.delete(callId); - this.activeTelephonyClosersByCallId.delete(callSid); + this.clearActiveBridgeMappings(callId, callSid, session); this.cancelNativeConsult(callId, session); + this.cancelForcedConsult(callId, session); this.clearUserTranscriptState(callId); - this.forcedConsultsByCallId.delete(callId); harness.close(); audioPacer.close(); } @@ -1084,6 +1085,30 @@ export class RealtimeCallHandler { state.cancel(); } + private cancelForcedConsult(callId: string, owner: ActiveRealtimeVoiceBridge): void { + const state = this.forcedConsultsByCallId.get(callId); + if (!state || state.owner !== owner) { + return; + } + state.cancelled = true; + state.sendSpeechPrompt = false; + this.forcedConsultsByCallId.delete(callId); + } + + private clearActiveBridgeMappings( + callId: string, + callSid: string, + owner: ActiveRealtimeVoiceBridge, + ): void { + for (const key of [callId, callSid]) { + if (this.activeBridgesByCallId.get(key) !== owner) { + continue; + } + this.activeBridgesByCallId.delete(key); + this.activeTelephonyClosersByCallId.delete(key); + } + } + private resolveUserTranscriptContext(callId: string): string | undefined { return ( this.partialUserTranscriptsByCallId.get(callId) ?? @@ -1218,7 +1243,9 @@ export class RealtimeCallHandler { ); params.clearAudio(); const state: ForcedConsultState = { + owner: params.session, sendSpeechPrompt: true, + cancelled: false, promise: Promise.resolve().then(() => params.handler( { @@ -1232,6 +1259,9 @@ export class RealtimeCallHandler { this.forcedConsultsByCallId.set(params.callId, state); try { const result = await state.promise; + if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) { + return; + } state.completedAt = Date.now(); coordinator.markDelivered(params.handle); const text = readSpeakableRealtimeVoiceToolResult(result, { @@ -1257,13 +1287,17 @@ export class RealtimeCallHandler { `[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`, ); } finally { - const cleanupTimer = setTimeout(() => { - if (this.forcedConsultsByCallId.get(params.callId) === state) { - this.forcedConsultsByCallId.delete(params.callId); - coordinator.remove(params.handle); - } - }, FORCED_CONSULT_NATIVE_DEDUPE_MS); - cleanupTimer.unref?.(); + if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) { + coordinator.remove(params.handle); + } else { + const cleanupTimer = setTimeout(() => { + if (this.forcedConsultsByCallId.get(params.callId) === state) { + this.forcedConsultsByCallId.delete(params.callId); + coordinator.remove(params.handle); + } + }, FORCED_CONSULT_NATIVE_DEDUPE_MS); + cleanupTimer.unref?.(); + } } } From c7b805c14f06603422b1b5fe40c522fe5512af8c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 13:15:55 +0800 Subject: [PATCH 011/239] test(voice-call): cover late forced consult teardown --- .../src/webhook/realtime-handler.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index 6b01aae4b53a..a5ddf35671d9 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -14,6 +14,7 @@ import type { CallManager } from "../manager.js"; import type { VoiceCallProvider } from "../providers/base.js"; import type { CallRecord, NormalizedEvent } from "../types.js"; import { connectWs, startUpgradeWsServer, waitForClose } from "../websocket-test-support.js"; +import { RealtimeAudioPacer } from "./realtime-audio-pacer.js"; import { RealtimeCallHandler } from "./realtime-handler.js"; const realtimeVoiceHarnessTestHooks = vi.hoisted(() => ({ @@ -201,6 +202,17 @@ function requireFirstMockCall(calls: readonly unknown[][], label: string): unkno return call; } +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + type RealtimeBridgeRequest = Parameters[0]; type RecentTalkEvent = { turnId?: string; type: string }; @@ -1480,6 +1492,197 @@ describe("RealtimeCallHandler path routing", () => { } }); + it("does not deliver a forced consult after its realtime session closes", async () => { + let callbacks: + | { + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + const sendUserMessage = vi.fn(); + const closeBridge = vi.fn(); + const bridge = makeBridge({ close: closeBridge, sendUserMessage }); + const createBridge = vi.fn( + (request: Parameters[0]) => { + callbacks = request; + return bridge; + }, + ); + const handler = makeHandler( + { consultPolicy: "always" }, + { + manager: { + getCallByProviderCallId: vi.fn(() => makeCallRecord("CA-forced-close")), + }, + realtimeProvider: makeRealtimeProvider(createBridge), + }, + ); + const consultResult = createDeferred<{ text: string }>(); + const consult = vi.fn(() => consultResult.promise); + handler.registerToolHandler("openclaw_agent_consult", consult); + const clearAudio = vi.spyOn(RealtimeAudioPacer.prototype, "clearAudio"); + const server = await startRealtimeServer(handler); + + try { + const ws = await connectWs(server.url); + ws.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-forced-close", callSid: "CA-forced-close" }, + }), + ); + await waitForRealtimeTest(() => { + expect(createBridge).toHaveBeenCalledTimes(1); + }); + + callbacks?.onTranscript?.("user", "Check the deployment.", true); + await waitForRealtimeTest(() => { + expect(consult).toHaveBeenCalledTimes(1); + }); + expect(clearAudio).toHaveBeenCalledTimes(1); + + const closed = waitForClose(ws); + ws.close(); + await closed; + await waitForRealtimeTest(() => { + expect(closeBridge).toHaveBeenCalledTimes(1); + }); + + consultResult.resolve({ text: "The deployment is healthy." }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(clearAudio).toHaveBeenCalledTimes(1); + expect(sendUserMessage).not.toHaveBeenCalled(); + } finally { + clearAudio.mockRestore(); + await server.close(); + } + }); + + it("keeps a replacement session's forced consult when the old result resolves late", async () => { + const callbacks: Array<{ + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + }> = []; + const oldSendUserMessage = vi.fn(); + const replacementSendUserMessage = vi.fn(); + const oldCloseBridge = vi.fn(); + const replacementCloseBridge = vi.fn(); + const bridges = [ + makeBridge({ close: oldCloseBridge, sendUserMessage: oldSendUserMessage }), + makeBridge({ + close: replacementCloseBridge, + sendUserMessage: replacementSendUserMessage, + }), + ]; + const createBridge = vi.fn( + (request: Parameters[0]) => { + callbacks.push(request); + const bridge = bridges[callbacks.length - 1]; + if (!bridge) { + throw new Error("unexpected replacement bridge"); + } + return bridge; + }, + ); + const handler = makeHandler( + { consultPolicy: "always" }, + { + manager: { + getCallByProviderCallId: vi.fn((providerCallId: string) => + makeCallRecord(providerCallId), + ), + }, + realtimeProvider: makeRealtimeProvider(createBridge), + }, + ); + const oldResult = createDeferred<{ text: string }>(); + const replacementResult = createDeferred<{ text: string }>(); + const consult = vi + .fn() + .mockImplementationOnce(() => oldResult.promise) + .mockImplementationOnce(() => replacementResult.promise); + handler.registerToolHandler("openclaw_agent_consult", consult); + const clearAudio = vi.spyOn(RealtimeAudioPacer.prototype, "clearAudio"); + const oldServer = await startRealtimeServer(handler); + let replacementServer: Awaited> | undefined; + let oldWs: WebSocket | undefined; + + try { + oldWs = await connectWs(oldServer.url); + oldWs.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-forced-old", callSid: "CA-forced-old" }, + }), + ); + await waitForRealtimeTest(() => { + expect(callbacks).toHaveLength(1); + }); + callbacks[0]?.onTranscript?.("user", "Check the old deployment.", true); + await waitForRealtimeTest(() => { + expect(consult).toHaveBeenCalledTimes(1); + }); + + replacementServer = await startRealtimeServer(handler); + const replacementWs = await connectWs(replacementServer.url); + try { + replacementWs.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-forced-replacement", callSid: "CA-forced-replacement" }, + }), + ); + await waitForRealtimeTest(() => { + expect(callbacks).toHaveLength(2); + }); + callbacks[1]?.onTranscript?.("user", "Check the new deployment.", true); + await waitForRealtimeTest(() => { + expect(consult).toHaveBeenCalledTimes(2); + }); + expect(clearAudio).toHaveBeenCalledTimes(2); + + const oldClosed = waitForClose(oldWs); + oldWs.close(); + await oldClosed; + await waitForRealtimeTest(() => { + expect(oldCloseBridge).toHaveBeenCalledTimes(1); + }); + + oldResult.resolve({ text: "The old deployment is healthy." }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(clearAudio).toHaveBeenCalledTimes(2); + expect(oldSendUserMessage).not.toHaveBeenCalled(); + + replacementResult.resolve({ text: "The new deployment is healthy." }); + await waitForRealtimeTest(() => { + expect(replacementSendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(clearAudio).toHaveBeenCalledTimes(3); + } finally { + if ( + replacementWs.readyState !== WebSocket.CLOSED && + replacementWs.readyState !== WebSocket.CLOSING + ) { + replacementWs.close(); + } + } + } finally { + if ( + oldWs && + oldWs.readyState !== WebSocket.CLOSED && + oldWs.readyState !== WebSocket.CLOSING + ) { + oldWs.close(); + } + clearAudio.mockRestore(); + await replacementServer?.close(); + await oldServer.close(); + } + }); + it("does not carry a final transcript into the next direct voice turn", async () => { let callbacks: | { From ee4a19e8aceece93419a42fc2ad5fd5f725f5ad8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:18:44 +0800 Subject: [PATCH 012/239] fix(voice-call): isolate replacement consult handoff --- .../src/webhook/realtime-handler.test.ts | 29 ++++++++++++++++--- .../src/webhook/realtime-handler.ts | 9 +++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index a5ddf35671d9..ae8186990460 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1561,15 +1561,18 @@ describe("RealtimeCallHandler path routing", () => { }); it("keeps a replacement session's forced consult when the old result resolves late", async () => { - const callbacks: Array<{ - onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; - }> = []; + const callbacks: RealtimeBridgeRequest[] = []; const oldSendUserMessage = vi.fn(); const replacementSendUserMessage = vi.fn(); + const oldSubmitToolResult = vi.fn(); const oldCloseBridge = vi.fn(); const replacementCloseBridge = vi.fn(); const bridges = [ - makeBridge({ close: oldCloseBridge, sendUserMessage: oldSendUserMessage }), + makeBridge({ + close: oldCloseBridge, + sendUserMessage: oldSendUserMessage, + submitToolResult: oldSubmitToolResult, + }), makeBridge({ close: replacementCloseBridge, sendUserMessage: replacementSendUserMessage, @@ -1642,6 +1645,24 @@ describe("RealtimeCallHandler path routing", () => { }); expect(clearAudio).toHaveBeenCalledTimes(2); + callbacks[0]?.onToolCall?.({ + itemId: "item-stale-native", + callId: "stale-native-consult", + name: "openclaw_agent_consult", + args: { question: "Check the old deployment." }, + }); + await waitForRealtimeTest(() => { + expect(oldSubmitToolResult).toHaveBeenCalledWith( + "stale-native-consult", + { + status: "cancelled", + message: "OpenClaw cancelled this consult before completion. Do not restart it.", + }, + undefined, + ); + }); + expect(consult).toHaveBeenCalledTimes(2); + const oldClosed = waitForClose(oldWs); oldWs.close(); await oldClosed; diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index 64b8ea27ab84..784634118e15 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -271,6 +271,7 @@ type ForcedConsultState = { promise: Promise; sendSpeechPrompt: boolean; cancelled: boolean; + cancel: () => void; completedAt?: number; }; @@ -1092,6 +1093,7 @@ export class RealtimeCallHandler { } state.cancelled = true; state.sendSpeechPrompt = false; + state.cancel(); this.forcedConsultsByCallId.delete(callId); } @@ -1246,6 +1248,7 @@ export class RealtimeCallHandler { owner: params.session, sendSpeechPrompt: true, cancelled: false, + cancel: () => coordinator.markCancelled(params.handle), promise: Promise.resolve().then(() => params.handler( { @@ -1443,7 +1446,11 @@ export class RealtimeCallHandler { coordinator.remove(pending); } } - const forcedConsult = this.forcedConsultsByCallId.get(callId); + const forcedConsultState = this.forcedConsultsByCallId.get(callId); + const forcedConsult = + forcedConsultState?.owner === bridge && !forcedConsultState.cancelled + ? forcedConsultState + : undefined; if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) { if (forcedConsult) { forcedConsult.sendSpeechPrompt = false; From 5e465953314fb6de34014fbe923c09ffdf2af2b5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:34:09 +0800 Subject: [PATCH 013/239] fix(voice-call): cancel pending replacement consults --- .../src/webhook/realtime-handler.test.ts | 48 +++++++++----- .../src/webhook/realtime-handler.ts | 66 ++++++++++++++----- 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index ae8186990460..e456691265cc 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1561,6 +1561,10 @@ describe("RealtimeCallHandler path routing", () => { }); it("keeps a replacement session's forced consult when the old result resolves late", async () => { + const sessionHarnesses: RealtimeVoiceSessionHarness[] = []; + realtimeVoiceHarnessTestHooks.onCreate = (harness) => { + sessionHarnesses.push(harness); + }; const callbacks: RealtimeBridgeRequest[] = []; const oldSendUserMessage = vi.fn(); const replacementSendUserMessage = vi.fn(); @@ -1626,6 +1630,20 @@ describe("RealtimeCallHandler path routing", () => { await waitForRealtimeTest(() => { expect(consult).toHaveBeenCalledTimes(1); }); + const oldCoordinator = expectDefined( + sessionHarnesses[0], + "old voice-call realtime session harness", + ).forcedConsults; + const oldForcedHandle = expectDefined( + oldCoordinator.handles().find((handle) => handle.question === "Check the old deployment."), + "old forced consult handle", + ); + const stalePendingHandle = expectDefined( + oldCoordinator.prepare("Pending work from the old session."), + "stale pending forced consult handle", + ); + const stalePendingRun = vi.fn(); + oldCoordinator.schedule(stalePendingHandle, 60_000, stalePendingRun); replacementServer = await startRealtimeServer(handler); const replacementWs = await connectWs(replacementServer.url); @@ -1639,6 +1657,8 @@ describe("RealtimeCallHandler path routing", () => { await waitForRealtimeTest(() => { expect(callbacks).toHaveLength(2); }); + expect(oldCoordinator.handles()).not.toContainEqual(stalePendingHandle); + expect(stalePendingRun).not.toHaveBeenCalled(); callbacks[1]?.onTranscript?.("user", "Check the new deployment.", true); await waitForRealtimeTest(() => { expect(consult).toHaveBeenCalledTimes(2); @@ -1651,31 +1671,27 @@ describe("RealtimeCallHandler path routing", () => { name: "openclaw_agent_consult", args: { question: "Check the old deployment." }, }); - await waitForRealtimeTest(() => { - expect(oldSubmitToolResult).toHaveBeenCalledWith( - "stale-native-consult", - { - status: "cancelled", - message: "OpenClaw cancelled this consult before completion. Do not restart it.", - }, - undefined, - ); + await new Promise((resolve) => { + setTimeout(resolve, 0); }); + expect(oldSubmitToolResult).not.toHaveBeenCalled(); expect(consult).toHaveBeenCalledTimes(2); - const oldClosed = waitForClose(oldWs); - oldWs.close(); - await oldClosed; - await waitForRealtimeTest(() => { - expect(oldCloseBridge).toHaveBeenCalledTimes(1); - }); - oldResult.resolve({ text: "The old deployment is healthy." }); await new Promise((resolve) => { setTimeout(resolve, 0); }); expect(clearAudio).toHaveBeenCalledTimes(2); expect(oldSendUserMessage).not.toHaveBeenCalled(); + expect(oldCoordinator.handles()).toContainEqual(oldForcedHandle); + expect(oldCoordinator.isCancelled(oldForcedHandle)).toBe(true); + + const oldClosed = waitForClose(oldWs); + oldWs.close(); + await oldClosed; + await waitForRealtimeTest(() => { + expect(oldCloseBridge).toHaveBeenCalledTimes(1); + }); replacementResult.resolve({ text: "The new deployment is healthy." }); await waitForRealtimeTest(() => { diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index 784634118e15..653400880fbe 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -275,6 +275,11 @@ type ForcedConsultState = { completedAt?: number; }; +type ForcedConsultSession = { + owner: ActiveRealtimeVoiceBridge; + coordinator: RealtimeVoiceSessionHarness["forcedConsults"]; +}; + type NativeConsultState = { owner: ActiveRealtimeVoiceBridge; startedAt: number; @@ -345,6 +350,7 @@ export class RealtimeCallHandler { ReturnType >(); private readonly forcedConsultsByCallId = new Map(); + private readonly forcedConsultSessionsByCallId = new Map(); private readonly nativeConsultsInFlightByCallId = new Map(); private closePromise: Promise | null = null; private closing = false; @@ -940,7 +946,7 @@ export class RealtimeCallHandler { if (nativeConsultOwner.current) { this.clearActiveBridgeMappings(callId, callSid, nativeConsultOwner.current); this.cancelNativeConsult(callId, nativeConsultOwner.current); - this.cancelForcedConsult(callId, nativeConsultOwner.current); + this.cancelForcedConsultSession(callId, nativeConsultOwner.current); } this.clearUserTranscriptState(callId); harness.finishOutputAudio(reason); @@ -977,10 +983,14 @@ export class RealtimeCallHandler { emitCallEnd(reason); } }; - const previousSession = this.activeBridgesByCallId.get(callId); - if (previousSession && previousSession !== session) { - this.cancelForcedConsult(callId, previousSession); + const previousForcedConsultSession = this.forcedConsultSessionsByCallId.get(callId); + if (previousForcedConsultSession && previousForcedConsultSession.owner !== session) { + this.cancelForcedConsultSession(callId, previousForcedConsultSession.owner); } + this.forcedConsultSessionsByCallId.set(callId, { + owner: session, + coordinator: harness.forcedConsults, + }); this.activeBridgesByCallId.set(callId, session); this.activeBridgesByCallId.set(callSid, session); this.activeTelephonyClosersByCallId.set(callId, closeTelephony); @@ -1014,7 +1024,7 @@ export class RealtimeCallHandler { } finally { this.clearActiveBridgeMappings(callId, callSid, session); this.cancelNativeConsult(callId, session); - this.cancelForcedConsult(callId, session); + this.cancelForcedConsultSession(callId, session); this.clearUserTranscriptState(callId); harness.close(); audioPacer.close(); @@ -1097,6 +1107,22 @@ export class RealtimeCallHandler { this.forcedConsultsByCallId.delete(callId); } + private cancelForcedConsultSession( + callId: string, + owner: ActiveRealtimeVoiceBridge | undefined, + ): void { + if (!owner) { + return; + } + const session = this.forcedConsultSessionsByCallId.get(callId); + if (!session || session.owner !== owner) { + return; + } + session.coordinator.clearPending(); + this.cancelForcedConsult(callId, owner); + this.forcedConsultSessionsByCallId.delete(callId); + } + private clearActiveBridgeMappings( callId: string, callSid: string, @@ -1188,7 +1214,10 @@ export class RealtimeCallHandler { transcript: string; clearAudio: () => void; }): void { - if (this.config.consultPolicy !== "always") { + if ( + this.config.consultPolicy !== "always" || + this.activeBridgesByCallId.get(params.callId) !== params.session + ) { return; } const question = params.transcript.trim(); @@ -1290,16 +1319,18 @@ export class RealtimeCallHandler { `[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`, ); } finally { - if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) { - coordinator.remove(params.handle); - } else { - const cleanupTimer = setTimeout(() => { - if (this.forcedConsultsByCallId.get(params.callId) === state) { - this.forcedConsultsByCallId.delete(params.callId); - coordinator.remove(params.handle); - } - }, FORCED_CONSULT_NATIVE_DEDUPE_MS); - cleanupTimer.unref?.(); + if (!state.cancelled) { + if (this.forcedConsultsByCallId.get(params.callId) !== state) { + coordinator.remove(params.handle); + } else { + const cleanupTimer = setTimeout(() => { + if (this.forcedConsultsByCallId.get(params.callId) === state) { + this.forcedConsultsByCallId.delete(params.callId); + coordinator.remove(params.handle); + } + }, FORCED_CONSULT_NATIVE_DEDUPE_MS); + cleanupTimer.unref?.(); + } } } } @@ -1438,6 +1469,9 @@ export class RealtimeCallHandler { } }; if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) { + if (this.activeBridgesByCallId.get(callId) !== bridge) { + return; + } const coordinator = harness.forcedConsults; const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId); if (forcedMatch.kind === "none") { From 31475b8ca322d72d1879d2f56607056c872ba136 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 14:37:49 +0800 Subject: [PATCH 014/239] fix(voice-call): recheck consult owner after await --- extensions/voice-call/src/webhook/realtime-handler.test.ts | 7 +++++++ extensions/voice-call/src/webhook/realtime-handler.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index e456691265cc..d46f6c20651a 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1644,6 +1644,13 @@ describe("RealtimeCallHandler path routing", () => { ); const stalePendingRun = vi.fn(); oldCoordinator.schedule(stalePendingHandle, 60_000, stalePendingRun); + callbacks[0]?.onToolCall?.({ + itemId: "item-old-native", + callId: "old-native-consult", + name: "openclaw_agent_consult", + args: { question: "Check the old deployment." }, + }); + expect(consult).toHaveBeenCalledTimes(1); replacementServer = await startRealtimeServer(handler); const replacementWs = await connectWs(replacementServer.url); diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index 653400880fbe..155d3ccd19f6 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -1507,6 +1507,13 @@ export class RealtimeCallHandler { const result = await forcedConsult.promise.catch((error: unknown) => ({ error: formatErrorMessage(error), })); + if ( + forcedConsult.cancelled || + forcedConsult.owner !== bridge || + this.forcedConsultsByCallId.get(callId) !== forcedConsult + ) { + return; + } await submitFinalToolResult(result); return; } From 1629cbe5607ccc6c27c4863311980511350cf42c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:47:17 +0800 Subject: [PATCH 015/239] fix(voice-call): cancel consults on bridge replacement --- .../src/webhook/realtime-handler.test.ts | 118 ++++++++++++++++++ .../src/webhook/realtime-handler.ts | 30 +++-- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index d46f6c20651a..4f85c402b4f1 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1727,6 +1727,124 @@ describe("RealtimeCallHandler path routing", () => { } }); + it("does not share a native consult with a replacement realtime session", async () => { + const callbacks: RealtimeBridgeRequest[] = []; + const oldSubmitToolResult = vi.fn(); + const replacementSubmitToolResult = vi.fn(); + const bridges = [ + makeBridge({ + supportsToolResultContinuation: true, + submitToolResult: oldSubmitToolResult, + }), + makeBridge({ + supportsToolResultContinuation: true, + submitToolResult: replacementSubmitToolResult, + }), + ]; + const createBridge = vi.fn((request: RealtimeBridgeRequest) => { + callbacks.push(request); + const bridge = bridges[callbacks.length - 1]; + if (!bridge) { + throw new Error("unexpected replacement bridge"); + } + return bridge; + }); + const handler = makeHandler(undefined, { + manager: { + getCallByProviderCallId: vi.fn((providerCallId: string) => makeCallRecord(providerCallId)), + }, + realtimeProvider: makeRealtimeProvider(createBridge), + }); + const oldResult = createDeferred<{ text: string }>(); + const replacementResult = createDeferred<{ text: string }>(); + const consult = vi + .fn() + .mockImplementationOnce(() => oldResult.promise) + .mockImplementationOnce(() => replacementResult.promise); + handler.registerToolHandler("openclaw_agent_consult", consult); + const oldServer = await startRealtimeServer(handler); + let replacementServer: Awaited> | undefined; + let oldWs: WebSocket | undefined; + + try { + oldWs = await connectWs(oldServer.url); + oldWs.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-native-old", callSid: "CA-native-old" }, + }), + ); + await waitForRealtimeTest(() => { + expect(callbacks).toHaveLength(1); + }); + callbacks[0]?.onToolCall?.({ + itemId: "item-native-old", + callId: "native-old", + name: "openclaw_agent_consult", + args: { question: "Check the old deployment." }, + }); + await waitForRealtimeTest(() => { + expect(consult).toHaveBeenCalledTimes(1); + expect(oldSubmitToolResult).toHaveBeenCalledTimes(1); + }); + + replacementServer = await startRealtimeServer(handler); + const replacementWs = await connectWs(replacementServer.url); + try { + replacementWs.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-native-replacement", callSid: "CA-native-replacement" }, + }), + ); + await waitForRealtimeTest(() => { + expect(callbacks).toHaveLength(2); + }); + callbacks[1]?.onToolCall?.({ + itemId: "item-native-replacement", + callId: "native-replacement", + name: "openclaw_agent_consult", + args: { question: "Check the new deployment." }, + }); + await waitForRealtimeTest(() => { + expect(consult).toHaveBeenCalledTimes(2); + }); + + oldResult.resolve({ text: "The old deployment is healthy." }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(oldSubmitToolResult).toHaveBeenCalledTimes(1); + + replacementResult.resolve({ text: "The new deployment is healthy." }); + await waitForRealtimeTest(() => { + expect(replacementSubmitToolResult).toHaveBeenLastCalledWith( + "native-replacement", + { text: "The new deployment is healthy." }, + undefined, + ); + }); + } finally { + if ( + replacementWs.readyState !== WebSocket.CLOSED && + replacementWs.readyState !== WebSocket.CLOSING + ) { + replacementWs.close(); + } + } + } finally { + if ( + oldWs && + oldWs.readyState !== WebSocket.CLOSED && + oldWs.readyState !== WebSocket.CLOSING + ) { + oldWs.close(); + } + await replacementServer?.close(); + await oldServer.close(); + } + }); + it("does not carry a final transcript into the next direct voice turn", async () => { let callbacks: | { diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index 155d3ccd19f6..7417857d4fe8 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -275,7 +275,7 @@ type ForcedConsultState = { completedAt?: number; }; -type ForcedConsultSession = { +type RealtimeConsultSession = { owner: ActiveRealtimeVoiceBridge; coordinator: RealtimeVoiceSessionHarness["forcedConsults"]; }; @@ -350,7 +350,7 @@ export class RealtimeCallHandler { ReturnType >(); private readonly forcedConsultsByCallId = new Map(); - private readonly forcedConsultSessionsByCallId = new Map(); + private readonly consultSessionsByCallId = new Map(); private readonly nativeConsultsInFlightByCallId = new Map(); private closePromise: Promise | null = null; private closing = false; @@ -945,8 +945,7 @@ export class RealtimeCallHandler { onClose: (reason) => { if (nativeConsultOwner.current) { this.clearActiveBridgeMappings(callId, callSid, nativeConsultOwner.current); - this.cancelNativeConsult(callId, nativeConsultOwner.current); - this.cancelForcedConsultSession(callId, nativeConsultOwner.current); + this.cancelConsultSession(callId, nativeConsultOwner.current); } this.clearUserTranscriptState(callId); harness.finishOutputAudio(reason); @@ -983,11 +982,11 @@ export class RealtimeCallHandler { emitCallEnd(reason); } }; - const previousForcedConsultSession = this.forcedConsultSessionsByCallId.get(callId); - if (previousForcedConsultSession && previousForcedConsultSession.owner !== session) { - this.cancelForcedConsultSession(callId, previousForcedConsultSession.owner); + const previousConsultSession = this.consultSessionsByCallId.get(callId); + if (previousConsultSession && previousConsultSession.owner !== session) { + this.cancelConsultSession(callId, previousConsultSession.owner); } - this.forcedConsultSessionsByCallId.set(callId, { + this.consultSessionsByCallId.set(callId, { owner: session, coordinator: harness.forcedConsults, }); @@ -1023,8 +1022,7 @@ export class RealtimeCallHandler { closeSession(); } finally { this.clearActiveBridgeMappings(callId, callSid, session); - this.cancelNativeConsult(callId, session); - this.cancelForcedConsultSession(callId, session); + this.cancelConsultSession(callId, session); this.clearUserTranscriptState(callId); harness.close(); audioPacer.close(); @@ -1107,20 +1105,20 @@ export class RealtimeCallHandler { this.forcedConsultsByCallId.delete(callId); } - private cancelForcedConsultSession( - callId: string, - owner: ActiveRealtimeVoiceBridge | undefined, - ): void { + private cancelConsultSession(callId: string, owner: ActiveRealtimeVoiceBridge | undefined): void { if (!owner) { return; } - const session = this.forcedConsultSessionsByCallId.get(callId); + const session = this.consultSessionsByCallId.get(callId); if (!session || session.owner !== owner) { return; } + // Forced and native consults share bridge ownership. Replacement or close + // must invalidate both before a newer bridge can observe call-scoped state. session.coordinator.clearPending(); this.cancelForcedConsult(callId, owner); - this.forcedConsultSessionsByCallId.delete(callId); + this.cancelNativeConsult(callId, owner); + this.consultSessionsByCallId.delete(callId); } private clearActiveBridgeMappings( From c30322418e110c8289a2a08f2e239c11c79a7382 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:00:31 +0800 Subject: [PATCH 016/239] fix(openai): share GPT-Live browser broker ownership --- extensions/openai/index.test.ts | 46 ++++++++++++++-- extensions/openai/index.ts | 18 +++--- .../realtime-quicksilver-session-owner.ts | 55 +++++++++++++++++++ 3 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 extensions/openai/realtime-quicksilver-session-owner.ts diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index c71e3c71b269..eab72936eff6 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -156,7 +156,7 @@ describe("openai plugin", () => { vi.restoreAllMocks(); }); - it("registers the native GPT-Live offer route and cleanup lifecycle", () => { + it("registers the native GPT-Live offer route and cleanup lifecycle", async () => { const registerHttpRoute = vi.fn(); const registerRuntimeLifecycle = vi.fn(); plugin.register( @@ -183,9 +183,47 @@ describe("openai plugin", () => { cleanup: expect.any(Function), }), ); + await registerRuntimeLifecycle.mock.calls[0]?.[0].cleanup({ reason: "disable" }); }); - it("only cleans up the GPT-Live broker on plugin disable, not session reset/delete/restart", () => { + it("shares one GPT-Live broker across full registrations and ignores late old cleanup", async () => { + const register = () => { + const registerHttpRoute = vi.fn(); + const registerRuntimeLifecycle = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "openai", + name: "OpenAI Provider", + source: "test", + config: {}, + runtime: { config: { current: vi.fn(() => ({})) } } as never, + registerHttpRoute, + registerRuntimeLifecycle, + }), + ); + return { + handler: registerHttpRoute.mock.calls[0]?.[0].handler as unknown, + cleanup: registerRuntimeLifecycle.mock.calls[0]?.[0].cleanup as (ctx: { + reason: string; + }) => Promise | void, + }; + }; + + const first = register(); + const second = register(); + expect(second.handler).toBe(first.handler); + + await first.cleanup({ reason: "disable" }); + const replacement = register(); + expect(replacement.handler).not.toBe(first.handler); + + await second.cleanup({ reason: "disable" }); + const afterLateCleanup = register(); + expect(afterLateCleanup.handler).toBe(replacement.handler); + await replacement.cleanup({ reason: "disable" }); + }); + + it("only cleans up the GPT-Live broker on plugin disable, not session reset/delete/restart", async () => { const registerRuntimeLifecycle = vi.fn(); plugin.register( createTestPluginApi({ @@ -204,15 +242,13 @@ describe("openai plugin", () => { }; expect(lifecycle).toBeDefined(); - // Session reset/delete/restart must NOT trigger broker cleanup for (const reason of ["reset", "delete", "restart"]) { const result = lifecycle.cleanup({ reason }); expect(result).toBeUndefined(); } - // Plugin disable MUST trigger broker cleanup const disableResult = lifecycle.cleanup({ reason: "disable" }); - expect(disableResult).toBeDefined(); + await expect(disableResult).resolves.toBeUndefined(); }); it("generates PNG buffers from the OpenAI Images API", async () => { diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index 7b5ccd4139db..ec98dc8d0908 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -12,9 +12,10 @@ import { resolveOpenAISystemPromptContribution, } from "./prompt-overlay.js"; import { - createOpenAIQuicksilverBrowserSessionBroker, - OPENAI_QUICKSILVER_OFFER_PATH, -} from "./realtime-quicksilver-session.js"; + acquireOpenAIQuicksilverBrowserSessionBroker, + releaseOpenAIQuicksilverBrowserSessionBroker, +} from "./realtime-quicksilver-session-owner.js"; +import { OPENAI_QUICKSILVER_OFFER_PATH } from "./realtime-quicksilver-session.js"; import { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; import { buildOpenAISpeechProvider } from "./speech-provider.js"; @@ -27,7 +28,7 @@ export default definePluginEntry({ register(api) { const quicksilverSession = api.registrationMode === "full" - ? createOpenAIQuicksilverBrowserSessionBroker({ + ? acquireOpenAIQuicksilverBrowserSessionBroker({ getConfig: () => api.runtime.config.current() as OpenClawConfig, logger: api.logger, }) @@ -43,13 +44,10 @@ export default definePluginEntry({ id: "openai-quicksilver-realtime-browser-session", description: "Close GPT-Live browser sidebands when the OpenAI plugin stops", cleanup: (ctx) => { - // Only tear down the process-wide broker when the plugin is actually - // being disabled. Session reset/delete/restart cleanup must not close - // the shared broker — it remains usable for later GPT-Live sessions. - if (ctx.reason === "disable") { - return quicksilverSession.cleanup(); + if (ctx.reason !== "disable") { + return undefined; } - return undefined; + return releaseOpenAIQuicksilverBrowserSessionBroker(quicksilverSession); }, }); } diff --git a/extensions/openai/realtime-quicksilver-session-owner.ts b/extensions/openai/realtime-quicksilver-session-owner.ts new file mode 100644 index 000000000000..3f12ae11e617 --- /dev/null +++ b/extensions/openai/realtime-quicksilver-session-owner.ts @@ -0,0 +1,55 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton"; +import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; +import { createOpenAIQuicksilverBrowserSessionBroker } from "./realtime-quicksilver-session.js"; + +const OPENAI_QUICKSILVER_SESSION_OWNER_KEY = Symbol.for( + "openclaw.openai.quicksilverBrowserSessionOwner.v1", +); + +type BrokerSession = ReturnType; + +type BrokerParams = { + getConfig: () => OpenClawConfig | undefined; + logger: Pick; +}; + +type BrokerOwner = { + current?: { + params: BrokerParams; + session: BrokerSession; + }; +}; + +function resolveBrokerOwner(): BrokerOwner { + return resolveGlobalSingleton(OPENAI_QUICKSILVER_SESSION_OWNER_KEY, () => ({})); +} + +export function acquireOpenAIQuicksilverBrowserSessionBroker(params: BrokerParams): BrokerSession { + const owner = resolveBrokerOwner(); + if (owner.current) { + owner.current.params.getConfig = params.getConfig; + owner.current.params.logger = params.logger; + return owner.current.session; + } + + // Full plugin registration can run more than once in one process. The provider and + // HTTP route must share one reservation map or an offer reserved by one rejects at another. + const mutableParams = { ...params }; + const session = createOpenAIQuicksilverBrowserSessionBroker(mutableParams); + owner.current = { params: mutableParams, session }; + return session; +} + +export async function releaseOpenAIQuicksilverBrowserSessionBroker( + session: BrokerSession, +): Promise { + const owner = resolveBrokerOwner(); + if (owner.current?.session !== session) { + return; + } + + // Release ownership before async teardown so a later registration can install a replacement. + owner.current = undefined; + await session.cleanup(); +} From 570ef1cac1a397b1177f993c567a82b6607a40f3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:00:46 +0800 Subject: [PATCH 017/239] fix(edit): restore UTF-16 difference offsets --- src/agents/sessions/tools/edit-diff.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/agents/sessions/tools/edit-diff.ts b/src/agents/sessions/tools/edit-diff.ts index 0a665705bb51..a5ed76ea9d52 100644 --- a/src/agents/sessions/tools/edit-diff.ts +++ b/src/agents/sessions/tools/edit-diff.ts @@ -347,11 +347,8 @@ function describeIndentation(line: string): string { function firstDifferenceIndex(left: string, right: string): number { const sharedLength = Math.min(left.length, right.length); - for (const [index, leftChar] of [...left].entries()) { - if (index >= sharedLength) { - break; - } - if (leftChar !== right.charAt(index)) { + for (let index = 0; index < sharedLength; index++) { + if (left.charAt(index) !== right.charAt(index)) { return index; } } From 5fc976571e5b307aaa9be92170ed09108cd5eec1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 01:05:32 -0700 Subject: [PATCH 018/239] fix(tui): require a fresh agent roster (#116715) --- src/tui/tui-agent-list-refresh.ts | 19 +++++++ src/tui/tui-command-handlers.test.ts | 56 ++++++++++++++++++++- src/tui/tui-command-handlers.ts | 9 +++- src/tui/tui-session-actions.test.ts | 75 ++++++++++++++++++++++++++++ src/tui/tui-session-actions.ts | 15 +++--- 5 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 src/tui/tui-agent-list-refresh.ts diff --git a/src/tui/tui-agent-list-refresh.ts b/src/tui/tui-agent-list-refresh.ts new file mode 100644 index 000000000000..09548abefa34 --- /dev/null +++ b/src/tui/tui-agent-list-refresh.ts @@ -0,0 +1,19 @@ +import { err, ok, type Result } from "@openclaw/normalization-core/result"; +import type { TuiAgentsList } from "./tui-backend.js"; +import { formatTuiErrorMessage } from "./tui-formatters.js"; + +/** Refresh an authoritative agent roster without discarding the last good snapshot on failure. */ +export async function refreshTuiAgentList(params: { + load: () => Promise; + apply: (result: TuiAgentsList) => void; + reportError: (message: string) => void; +}): Promise> { + try { + params.apply(await params.load()); + return ok(undefined); + } catch (error) { + const message = formatTuiErrorMessage(error); + params.reportError(message); + return err(message); + } +} diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index e09bb52f7ccb..bd0952856433 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -2,6 +2,7 @@ import type { OverlayHandle } from "@earendil-works/pi-tui"; import { expectDefined } from "@openclaw/normalization-core"; +import type { Result } from "@openclaw/normalization-core/result"; import { describe, expect, it, vi } from "vitest"; import { createSessionProjection, @@ -35,6 +36,7 @@ type SetActivityStatusMock = ReturnType & ((text: string) => void) type SetSessionMock = ReturnType & ((key: string) => Promise); type ConsumeCompletedRunMock = ReturnType & ((runId: string) => boolean); type FlushPendingHistoryRefreshMock = ReturnType & (() => void); +type RefreshAgentsMock = ReturnType & (() => Promise>); function createOverlayHandle(): OverlayHandle { return { @@ -131,6 +133,9 @@ function createHarness(params?: { consumeCompletedRunForPendingSend?: ConsumeCompletedRunMock; isRunObserved?: (runId: string) => boolean; flushPendingHistoryRefreshIfIdle?: FlushPendingHistoryRefreshMock; + refreshAgents?: RefreshAgentsMock; + agentDefaultId?: string; + agents?: Array<{ id: string; kind?: "agent" | "system"; name?: string }>; }) { const sendChat = params?.sendChat ?? @@ -172,12 +177,17 @@ function createHarness(params?: { const requestExit = vi.fn(); const abortActive = params?.abortActive ?? (vi.fn().mockResolvedValue(undefined) as AbortActiveMock); + const refreshAgents = + params?.refreshAgents ?? + (vi.fn().mockResolvedValue({ ok: true, value: undefined }) as RefreshAgentsMock); const runAuthFlow: RunAuthFlow | undefined = params?.runAuthFlow ?? (params?.opts?.local ? (vi.fn().mockResolvedValue({ exitCode: 0, signal: null }) as unknown as RunAuthFlow) : undefined); const state = { + agentDefaultId: params?.agentDefaultId ?? "main", + agents: params?.agents ?? [], currentAgentId: params?.currentAgentId ?? "main", currentSessionKey: params?.currentSessionKey ?? "agent:main:main", currentSessionId: params?.currentSessionId ?? null, @@ -219,7 +229,7 @@ function createHarness(params?: { refreshSessionInfo: refreshSessionInfo as never, loadHistory, setSession, - refreshAgents: vi.fn(), + refreshAgents, abortActive, setActivityStatus, formatSessionKey: vi.fn(), @@ -272,11 +282,55 @@ function createHarness(params?: { forgetLocalBtwRunId, requestExit, abortActive, + refreshAgents, state, }; } describe("tui command handlers", () => { + it("does not open the agent picker from a cached roster after refresh failure", async () => { + const refreshAgents = vi + .fn() + .mockResolvedValue({ ok: false, error: "gateway unavailable" }) as RefreshAgentsMock; + const { handleCommand, openOverlay, requestRender } = createHarness({ + refreshAgents, + agents: [{ id: "cached", name: "Cached Agent" }], + }); + + await handleCommand("/agents"); + + expect(refreshAgents).toHaveBeenCalledTimes(1); + expect(openOverlay).not.toHaveBeenCalled(); + expect(requestRender).toHaveBeenCalled(); + }); + + it("opens the agent picker only after a successful refresh", async () => { + const refreshAgents = vi + .fn() + .mockResolvedValue({ ok: true, value: undefined }) as RefreshAgentsMock; + const { handleCommand, openOverlay } = createHarness({ + refreshAgents, + agentDefaultId: "team-lead", + agents: [ + { id: "team-lead", name: "Lead Agent" }, + { id: "system-agent", kind: "system", name: "System Agent" }, + ], + }); + + await handleCommand("/agents"); + + expect(refreshAgents).toHaveBeenCalledTimes(1); + expect(openOverlay).toHaveBeenCalledTimes(1); + const selector = firstMockArg(openOverlay, "openOverlay") as SelectableOverlay; + expect(selector.items).toEqual([ + { + value: "team-lead", + label: "team-lead (Lead Agent)", + description: "default", + }, + ]); + }); + it("bounds session picker hydration to recent TUI sessions", async () => { const listSessions = vi.fn().mockResolvedValue({ sessions: [ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 323d5f86ec49..d35cfcdf7624 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -1,6 +1,7 @@ // Implements TUI slash command handlers and backend action dispatch. import { randomUUID } from "node:crypto"; import type { Component, OverlayHandle, SelectItem, TUI } from "@earendil-works/pi-tui"; +import type { Result } from "@openclaw/normalization-core/result"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { modelKey } from "../agents/model-ref-shared.js"; import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js"; @@ -74,7 +75,7 @@ type CommandHandlerContext = { refreshSessionInfo: () => Promise; loadHistory: () => Promise; setSession: (key: string) => Promise; - refreshAgents: () => Promise; + refreshAgents: () => Promise>; abortActive: (params?: { preferActive?: boolean }) => Promise; setActivityStatus: (text: string) => void; formatSessionKey: (key: string) => string; @@ -289,7 +290,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { }; const openAgentSelector = async () => { - await refreshAgents(); + const refreshResult = await refreshAgents(); + if (!refreshResult.ok) { + tui.requestRender(); + return; + } const selectableAgents = state.agents.filter((agent) => agent.kind !== "system"); if (selectableAgents.length === 0) { chatLog.addSystem("no agents found"); diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 9afa4cfa433e..d523a2dbb655 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -134,6 +134,81 @@ describe("tui session actions", () => { ...overrides, }); + it("keeps the cached agent roster when a refresh fails", async () => { + const cachedAgents = [{ id: "cached", name: "Cached Agent" }]; + const state = createBaseState({ + agentDefaultId: "cached", + sessionMainKey: "cached-main", + sessionScope: "per-sender", + agents: cachedAgents, + currentAgentId: "cached", + }); + const agentNames = new Map([["cached", "Cached Agent"]]); + const addSystem = vi.fn(); + const { refreshAgents } = createTestSessionActions({ + client: { + listAgents: vi.fn().mockRejectedValue(new Error("gateway unavailable")), + } as unknown as TuiBackend, + chatLog: { addSystem } as unknown as import("./components/chat-log.js").ChatLog, + state, + agentNames, + }); + + await expect(refreshAgents()).resolves.toEqual({ + ok: false, + error: "gateway unavailable", + }); + expect(state.agents).toBe(cachedAgents); + expect(state.agentDefaultId).toBe("cached"); + expect(state.sessionMainKey).toBe("cached-main"); + expect(state.sessionScope).toBe("per-sender"); + expect([...agentNames]).toEqual([["cached", "Cached Agent"]]); + expect(addSystem).toHaveBeenCalledWith("agents list failed: gateway unavailable"); + }); + + it("returns success after applying a normalized fresh agent roster", async () => { + const state = createBaseState({ + agents: [{ id: "cached", name: "Cached Agent" }], + currentAgentId: "cached", + }); + const agentNames = new Map([["cached", "Cached Agent"]]); + const updateHeader = vi.fn(); + const updateFooter = vi.fn(); + const { refreshAgents } = createTestSessionActions({ + client: { + listAgents: vi.fn().mockResolvedValue({ + defaultId: " Team Lead ", + mainKey: " Primary ", + scope: "per-sender", + agents: [ + { id: " Team Lead ", name: " Lead Agent " }, + { id: " System Agent ", kind: "system", name: " System Agent " }, + ], + }), + } as unknown as TuiBackend, + state, + agentNames, + updateHeader, + updateFooter, + }); + + await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined }); + expect(state.agentDefaultId).toBe("team-lead"); + expect(state.sessionMainKey).toBe("primary"); + expect(state.sessionScope).toBe("per-sender"); + expect(state.agents).toEqual([ + { id: "team-lead", kind: undefined, name: "Lead Agent" }, + { id: "system-agent", kind: "system", name: "System Agent" }, + ]); + expect(state.currentAgentId).toBe("team-lead"); + expect([...agentNames]).toEqual([ + ["team-lead", "Lead Agent"], + ["system-agent", "System Agent"], + ]); + expect(updateHeader).toHaveBeenCalledTimes(1); + expect(updateFooter).toHaveBeenCalledTimes(1); + }); + it("queues session refreshes and applies the latest result", async () => { let resolveFirst: ((value: unknown) => void) | undefined; let resolveSecond: ((value: unknown) => void) | undefined; diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index c3536aa1dff7..c27e38d7ba14 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -10,6 +10,7 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; +import { refreshTuiAgentList } from "./tui-agent-list-refresh.js"; import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { asString, @@ -172,14 +173,12 @@ export function createSessionActions(context: SessionActionContext) { updateFooter(); }; - const refreshAgents = async () => { - try { - const result = await client.listAgents(); - applyAgentsResult(result); - } catch (err) { - chatLog.addSystem(`agents list failed: ${formatTuiErrorMessage(err)}`); - } - }; + const refreshAgents = () => + refreshTuiAgentList({ + load: () => client.listAgents(), + apply: applyAgentsResult, + reportError: (message) => chatLog.addSystem(`agents list failed: ${message}`), + }); const updateAgentFromSessionKey = (key: string) => { const parsed = parseAgentSessionKey(key); From a84ede72489ca01e67759ec49a90b5d8837a18cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 13:27:06 +0800 Subject: [PATCH 019/239] fix(ui): bound Talk relay microphone uplink --- .../chat/realtime-talk-gateway-relay.test.ts | 110 ++++++++++++++++++ .../pages/chat/realtime-talk-gateway-relay.ts | 50 ++++++-- 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index 35e970bf7be0..c9c206720cac 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -380,6 +380,116 @@ describe("GatewayRelayRealtimeTalkTransport", () => { expect(onInputLevel).toHaveBeenLastCalledWith(0); }); + it("bounds stalled microphone appends and aborts every owner on stop", async () => { + const onStatus = vi.fn(); + const client = createClient(); + let activeAppends = 0; + let peakActiveAppends = 0; + const appendSignals: AbortSignal[] = []; + vi.mocked(client["request"]).mockImplementation((method, _params, options) => { + if (method !== "talk.session.appendAudio") { + return Promise.resolve({}); + } + const signal = options?.signal; + if (!signal) { + return Promise.reject(new Error("missing append abort signal")); + } + appendSignals.push(signal); + activeAppends += 1; + peakActiveAppends = Math.max(peakActiveAppends, activeAppends); + return new Promise((_, reject) => { + signal.addEventListener( + "abort", + () => { + activeAppends -= 1; + reject(new Error("append aborted")); + }, + { once: true }, + ); + }); + }); + const transport = createTransport({ callbacks: { onStatus }, client }); + + await transport.start(); + const samples = new Float32Array(4096); + for (let index = 0; index < 10_000; index += 1) { + pumpMicrophone(samples); + } + + const appendCalls = requestCallsFor(client, "talk.session.appendAudio"); + expect(appendCalls).toHaveLength(4); + expect(peakActiveAppends).toBe(4); + expect(activeAppends).toBe(4); + expect(new Set(appendSignals).size).toBe(1); + expect( + appendCalls.every( + (call) => call[2]?.signal === appendSignals[0] && call[2]?.timeoutMs === 8_000, + ), + ).toBe(true); + + transport.stop(); + transport.stop(); + await Promise.resolve(); + + expect(activeAppends).toBe(0); + expect(appendSignals.every((signal) => signal.aborted)).toBe(true); + expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1); + expect(onStatus).not.toHaveBeenCalled(); + }); + + it("preserves accepted microphone frame order", async () => { + const client = createClient(); + const transport = createTransport({ client }); + + await transport.start(); + for (const timestamp of [10, 20, 30, 40]) { + audioCurrentTime = timestamp / 1_000; + pumpMicrophone(new Float32Array(4096)); + } + + expect( + requestCallsFor(client, "talk.session.appendAudio").map( + (call) => (call[1] as { timestamp: number }).timestamp, + ), + ).toEqual([10, 20, 30, 40]); + transport.stop(); + }); + + it("ignores a stale append rejection after a replacement starts", async () => { + const oldStatus = vi.fn(); + const oldClient = createClient(); + let rejectOldAppend: (error: Error) => void = () => undefined; + vi.mocked(oldClient["request"]).mockImplementation((method) => { + if (method !== "talk.session.appendAudio") { + return Promise.resolve({}); + } + return new Promise((_, reject) => { + rejectOldAppend = reject; + }); + }); + const oldTransport = createTransport({ callbacks: { onStatus: oldStatus }, client: oldClient }); + + await oldTransport.start(); + pumpMicrophone(new Float32Array(4096)); + oldTransport.stop(); + + const replacementStatus = vi.fn(); + const replacementClient = createClient(); + const replacement = createTransport({ + callbacks: { onStatus: replacementStatus }, + client: replacementClient, + }); + await replacement.start(); + pumpMicrophone(new Float32Array(4096)); + rejectOldAppend(new Error("late stale append failure")); + await Promise.resolve(); + + expect(requestCallsFor(replacementClient, "talk.session.appendAudio")).toHaveLength(1); + expect(oldStatus).not.toHaveBeenCalled(); + expect(replacementStatus).not.toHaveBeenCalled(); + replacement.stop(); + }); + it("stops microphone pumping when the relay rejects appended audio", async () => { const onStatus = vi.fn(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.ts index 747f07b4cd35..b71189dfedf1 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.ts @@ -22,6 +22,8 @@ import { const BARGE_IN_RMS_THRESHOLD = 0.02; const BARGE_IN_PEAK_THRESHOLD = 0.08; const BARGE_IN_CONSECUTIVE_SPEECH_FRAMES = 2; +const MAX_PENDING_AUDIO_APPENDS = 4; +const AUDIO_APPEND_TIMEOUT_MS = 8_000; export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport { private media: MediaStream | null = null; @@ -31,6 +33,8 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport private readonly inputPump = new RealtimeTalkPcmInputPump(); private unsubscribe: (() => void) | null = null; private closed = false; + private audioAppendAbortController: AbortController | null = null; + private readonly pendingAudioAppends = new Set>(); private readonly outputQueue = new RealtimeTalkPcmOutputQueue(); private readonly consultAbortControllers = new Map(); private readonly completedToolCalls = new Set(); @@ -80,6 +84,8 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.media = media; this.inputContext = new AudioContext({ sampleRate: this.session.audio.inputSampleRateHz }); this.outputContext = new AudioContext({ sampleRate: this.session.audio.outputSampleRateHz }); + this.abortPendingAudioAppends(); + this.audioAppendAbortController = new AbortController(); if (this.ctx.callbacks.onInputLevel) { this.inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); this.inputMeter.start(this.media, this.inputContext); @@ -104,6 +110,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.unsubscribe?.(); this.unsubscribe = null; this.inputPump.stop(); + this.abortPendingAudioAppends(); this.inputMeter?.stop(); this.inputMeter = null; // Mark callbacks recurse until playback drains, so shutdown must cancel every owned timer. @@ -128,18 +135,35 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport if (this.closed) { return; } - const pcm = floatToPcm16(samples); if (this.detectBargeInSpeech(samples)) { this.cancelOutputForBargeIn(); } - void this.ctx.client - .request("talk.session.appendAudio", { - sessionId: this.session.relaySessionId, - audioBase64: bytesToBase64(pcm), - timestamp: Math.round((this.inputContext?.currentTime ?? 0) * 1000), - }) + const abortController = this.audioAppendAbortController; + // Live microphone frames become stale once the Gateway falls behind, so drop new + // frames at the ownership cap instead of growing a latency queue. + if ( + !abortController || + abortController.signal.aborted || + this.pendingAudioAppends.size >= MAX_PENDING_AUDIO_APPENDS + ) { + return; + } + const pcm = floatToPcm16(samples); + const request = this.ctx.client + .request( + "talk.session.appendAudio", + { + sessionId: this.session.relaySessionId, + audioBase64: bytesToBase64(pcm), + timestamp: Math.round((this.inputContext?.currentTime ?? 0) * 1000), + }, + { + signal: abortController.signal, + timeoutMs: AUDIO_APPEND_TIMEOUT_MS, + }, + ) .catch((error: unknown) => { - if (!this.closed) { + if (!this.closed && !abortController.signal.aborted) { this.ctx.callbacks.onStatus?.( "error", error instanceof Error ? error.message : String(error), @@ -147,9 +171,19 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.stop(); } }); + this.pendingAudioAppends.add(request); + void request.finally(() => { + this.pendingAudioAppends.delete(request); + }); }); } + private abortPendingAudioAppends(): void { + this.audioAppendAbortController?.abort(); + this.audioAppendAbortController = null; + this.pendingAudioAppends.clear(); + } + private handleRelayEvent(event: GatewayRelayEvent): void { if (event.relaySessionId !== this.session.relaySessionId || this.closed) { return; From 9018b02f1469b77eb95fbc0b3fd37d4337c16f2c Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Fri, 31 Jul 2026 13:41:20 +0530 Subject: [PATCH 020/239] fix: keep owner-only tools out of non-owner skill commands [AI] (#116532) * fix: enforce owner tools in skill dispatch * fix: type skill dispatch policy layers --- ...ine-actions.skip-when-config-empty.test.ts | 60 ++++++++++++++++++- .../reply/get-reply-inline-actions.ts | 1 + src/skills/runtime/tool-dispatch.test.ts | 43 ++++++++++++- src/skills/runtime/tool-dispatch.ts | 15 ++++- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 1be3d5123b47..d27b91d7904f 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -1057,7 +1057,7 @@ describe("handleInlineActions", () => { expect(result).toEqual({ kind: "reply", reply: { text: "✅ Done." } }); const toolsArgs = mockObjectArg(createOpenClawToolsMock, "createOpenClawTools"); - expect(toolsArgs).not.toHaveProperty("senderIsOwner"); + expect(toolsArgs.senderIsOwner).toBe(true); expect(toolsArgs.nativeChannelId).toBe("oc_native_chat"); expect(toolsArgs.beforeToolCallHookContext).toMatchObject({ cwd: "/tmp", @@ -1350,6 +1350,64 @@ describe("handleInlineActions", () => { expect(toolExecute).not.toHaveBeenCalled(); }); + it("does not expose owner-only tools to authorized non-owner skill dispatch", async () => { + const typing = createTypingController(); + const toolExecute = vi.fn(async () => ({ content: "sent" })); + createOpenClawToolsMock.mockReturnValue([ + { + name: "conversations_send", + execute: toolExecute, + }, + ]); + + const ctx = buildTestCtx({ + Body: "/send_conversation hello", + CommandBody: "/send_conversation hello", + }); + const skillCommands: SkillCommandSpec[] = [ + { + name: "send_conversation", + skillName: "send-conversation", + description: "Send a conversation message", + dispatch: { + kind: "tool", + toolName: "conversations_send", + argMode: "raw", + }, + sourceFilePath: "/tmp/plugin/commands/send-conversation.md", + }, + ]; + + const result = await handleInlineActions( + createHandleInlineActionsInput({ + ctx, + typing, + cleanedBody: "/send_conversation hello", + command: { + isAuthorizedSender: true, + senderId: "allowed-user", + senderIsOwner: false, + abortKey: "allowed-user", + rawBodyNormalized: "/send_conversation hello", + commandBodyNormalized: "/send_conversation hello", + }, + overrides: { + cfg: { commands: { text: true } }, + allowTextCommands: true, + skillCommands, + }, + }), + ); + + expect(result).toEqual({ + kind: "reply", + reply: { text: "❌ Tool not available: conversations_send" }, + }); + const toolsArgs = mockObjectArg(createOpenClawToolsMock, "createOpenClawTools"); + expect(toolsArgs.senderIsOwner).toBe(false); + expect(toolExecute).not.toHaveBeenCalled(); + }); + it("applies subagent policy to ACP envelope inline dispatch sessions", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inline-acp-policy-")); try { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index e96d04712305..160f5f40ee2c 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -419,6 +419,7 @@ export async function handleInlineActions(params: { workspaceDir, provider, model, + senderIsOwner: command.senderIsOwner, senderId: command.senderId, currentChannelId: command.channelId, groupId: extractExplicitGroupId(ctx.From), diff --git a/src/skills/runtime/tool-dispatch.test.ts b/src/skills/runtime/tool-dispatch.test.ts index 3f2ecf67cc58..f56660de0b01 100644 --- a/src/skills/runtime/tool-dispatch.test.ts +++ b/src/skills/runtime/tool-dispatch.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; type CreateOpenClawToolsArg = { beforeToolCallHookContext?: { @@ -13,6 +14,8 @@ type CreateOpenClawToolsArg = { }; cronCreatorToolAllowlist?: Array; nativeChannelId?: string; + pluginToolDenylist?: string[]; + senderIsOwner?: boolean; }; const hoisted = vi.hoisted(() => { @@ -29,6 +32,7 @@ const hoisted = vi.hoisted(() => { makeTool("read"), makeTool("cron"), makeTool("exec"), + makeTool("conversations_send"), ]), }; }); @@ -55,6 +59,7 @@ describe("resolveSkillDispatchTools", () => { workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test", provider: "openai", model: "gpt-5.5", + senderIsOwner: true, }); const args = hoisted.createOpenClawToolsMock.mock.calls[0]?.[0]; @@ -72,14 +77,16 @@ describe("resolveSkillDispatchTools", () => { workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test", provider: "openai", model: "gpt-5.5", + senderIsOwner: true, }); const args = hoisted.createOpenClawToolsMock.mock.calls.at(-1)?.[0]; - expect(tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec"]); + expect(tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec", "conversations_send"]); expect(args?.cronCreatorToolAllowlist).toEqual([ { name: "read" }, { name: "automations" }, { name: "exec" }, + { name: "conversations_send" }, ]); }); @@ -92,6 +99,7 @@ describe("resolveSkillDispatchTools", () => { workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test", provider: "openai", model: "gpt-5.5", + senderIsOwner: true, skillCommand: { name: "daily-brief", skillFile: "/workspace/skills/daily-brief/SKILL.md", @@ -138,6 +146,7 @@ describe("resolveSkillDispatchTools", () => { workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test", provider: "openai", model: "gpt-5.5", + senderIsOwner: true, }); expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["read", "exec"])); @@ -145,4 +154,36 @@ describe("resolveSkillDispatchTools", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + + it("removes owner-only core tools for authorized non-owner dispatch", () => { + const common = { + message: { surface: "telegram", senderId: "allowed-user" }, + cfg: {} as OpenClawConfig, + agentId: "main", + sessionKey: "agent:main:telegram:direct:allowed-user", + workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test", + provider: "openai", + model: "gpt-5.5", + }; + + const nonOwnerTools = resolveSkillDispatchTools({ + ...common, + senderIsOwner: false, + }); + const nonOwnerArgs = hoisted.createOpenClawToolsMock.mock.calls.at(-1)?.[0]; + expect(nonOwnerTools.map((tool) => tool.name)).not.toContain("conversations_send"); + expect(nonOwnerArgs?.senderIsOwner).toBe(false); + expect(nonOwnerArgs?.pluginToolDenylist).toEqual( + expect.arrayContaining([...GATEWAY_OWNER_ONLY_CORE_TOOLS]), + ); + + const ownerTools = resolveSkillDispatchTools({ + ...common, + senderIsOwner: true, + }); + const ownerArgs = hoisted.createOpenClawToolsMock.mock.calls.at(-1)?.[0]; + expect(ownerTools.map((tool) => tool.name)).toContain("conversations_send"); + expect(ownerArgs?.senderIsOwner).toBe(true); + expect(ownerArgs?.pluginToolDenylist).not.toContain("conversations_send"); + }); }); diff --git a/src/skills/runtime/tool-dispatch.ts b/src/skills/runtime/tool-dispatch.ts index 4097c6598deb..8ed5108298a2 100644 --- a/src/skills/runtime/tool-dispatch.ts +++ b/src/skills/runtime/tool-dispatch.ts @@ -16,6 +16,7 @@ import { mergeAlsoAllowPolicy, replaceWithEffectiveToolAllowlist, resolveToolProfilePolicy, + type ToolPolicyLike, } from "../../agents/tool-policy.js"; import { replaceWithEffectiveCronCreatorToolAllowlist, @@ -25,6 +26,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { getPluginToolMeta } from "../../plugins/tools.js"; +import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; import type { SkillCommandSpec } from "../types.js"; @@ -45,8 +47,8 @@ type SkillDispatchMessageContext = { /** * Policy-enforcement seam for skill `command-dispatch: tool` invocations. - * Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 - * stays closed across allow/deny, group, sandbox, and subagent policy layers. + * Keep this aligned with normal tool surfaces across sender, group, sandbox, + * and subagent policy layers. */ export function resolveSkillDispatchTools(params: { message: SkillDispatchMessageContext; @@ -58,6 +60,7 @@ export function resolveSkillDispatchTools(params: { workspaceDir: string; provider: string; model: string; + senderIsOwner: boolean; senderId?: string; currentChannelId?: string; skillCommand?: Pick & { @@ -116,7 +119,10 @@ export function resolveSkillDispatchTools(params: { sessionKey: params.sessionKey, }); const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined; - const explicitPolicyList = [ + const ownerOnlyCoreToolPolicy = !params.senderIsOwner + ? { deny: [...GATEWAY_OWNER_ONLY_CORE_TOOLS] } + : undefined; + const explicitPolicyList: Array = [ profilePolicy, providerProfilePolicy, globalPolicy, @@ -128,6 +134,7 @@ export function resolveSkillDispatchTools(params: { sandboxPolicy, subagentPolicy, inheritedToolPolicy, + ownerOnlyCoreToolPolicy, ]; const explicitDenylist = collectExplicitDenylist(explicitPolicyList); const inheritedToolAllowlist: string[] = []; @@ -166,6 +173,7 @@ export function resolveSkillDispatchTools(params: { sandboxed: sandboxRuntime.sandboxed, requesterAgentIdOverride: params.agentId, requesterSenderId: params.senderId, + senderIsOwner: params.senderIsOwner, sessionId: params.sessionEntry?.sessionId, currentChannelId: params.currentChannelId, ...(beforeToolCallHookContext ? { beforeToolCallHookContext } : {}), @@ -200,6 +208,7 @@ export function resolveSkillDispatchTools(params: { { policy: sandboxPolicy, label: "sandbox tools.allow" }, { policy: subagentPolicy, label: "subagent tools.allow" }, { policy: inheritedToolPolicy, label: "inherited tools" }, + { policy: ownerOnlyCoreToolPolicy, label: "gateway sender owner-only tools" }, ], declaredToolAllowlist: buildDeclaredToolAllowlistContext({ config: params.cfg, From 52f81a368473c15aa4df4ad50aa6c5539d58b94c Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Fri, 31 Jul 2026 13:42:17 +0530 Subject: [PATCH 021/239] fix(exec): require approval for abbreviated inline eval flags [AI] (#116529) * fix: gate abbreviated interpreter eval flags * fix(exec): cover legacy gawk source abbreviation --- src/infra/command-analysis/inline-eval.test.ts | 15 +++++++++++++++ src/infra/command-analysis/inline-eval.ts | 6 ++++++ src/node-host/invoke-system-run.test.ts | 13 +++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/infra/command-analysis/inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts index 8ed36b53733c..1a16b081b4d4 100644 --- a/src/infra/command-analysis/inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -218,6 +218,18 @@ describe("exec inline eval detection", () => { argv: ["gawk", "-f", "library.awk", '--source=BEGIN{system("id")}', "/dev/null"], expected: "gawk --source", }, + { + argv: ["gawk", "-f", "library.awk", '--s=BEGIN{system("id")}', "/dev/null"], + expected: "gawk --source", + }, + { + argv: ["gawk", "-f", "library.awk", '--so=BEGIN{system("id")}', "/dev/null"], + expected: "gawk --source", + }, + { + argv: ["gawk", "-f", "library.awk", "--sou", 'BEGIN{system("id")}', "/dev/null"], + expected: "gawk --source", + }, { argv: ["find", ".", "-exec", "id", "{}", ";"], expected: "find -exec" }, { argv: ["find", "--", ".", "-exec", "id", "{}", ";"], expected: "find -exec" }, { argv: ["find", ".", "-ok", "id", "{}", ";"], expected: "find -ok" }, @@ -229,6 +241,8 @@ describe("exec inline eval detection", () => { { argv: ["make", "-E", "$(shell id)"], expected: "make -E" }, { argv: ["make", "-E$(shell id)"], expected: "make -E" }, { argv: ["make", "--eval=$(shell id)"], expected: "make --eval" }, + { argv: ["make", "--ev=$(shell id)"], expected: "make --eval" }, + { argv: ["make", "--eva", "$(shell id)"], expected: "make --eval" }, { argv: ["sed", "s/.*/id/e", "/dev/null"], expected: "sed inline program" }, { argv: ["gsed", "-e", "s/.*/id/e", "/dev/null"], expected: "gsed -e" }, { argv: ["sed", "-es/.*/id/e", "/dev/null"], expected: "sed -e" }, @@ -294,6 +308,7 @@ describe("exec inline eval detection", () => { expect(detectInterpreterInlineEvalArgv(["find", ".", "-name", "*.ts"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["xargs", "-0"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["make", "test"])).toBeNull(); + expect(detectInterpreterInlineEvalArgv(["make", "--e=$(info ok)"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["sed", "-f", "script.sed", "input.txt"])).toBeNull(); expect( detectInterpreterInlineEvalArgv(["sed", "-i", "-f", "script.sed", "input.txt"]), diff --git a/src/infra/command-analysis/inline-eval.ts b/src/infra/command-analysis/inline-eval.ts index 96f69143d0e2..28ef604520bd 100644 --- a/src/infra/command-analysis/inline-eval.ts +++ b/src/infra/command-analysis/inline-eval.ts @@ -91,6 +91,9 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [ { names: ["awk", "gawk", "mawk", "nawk"], exactFlags: new Set(["-e", "--source"]), + // gawk before 4.0 accepted "--s" for "--source"; modern releases reject it + // as ambiguous with "--sandbox", so the older executable case sets the floor. + abbreviatedFlags: [{ label: "--source", full: "--source", min: "--s" }], prefixFlags: [{ label: "--source", prefix: "--source=" }], }, { @@ -285,6 +288,9 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [ exactFlags: new Set(["-f", "--file", "--makefile", "--eval"]), rawExactFlags: new Map([["-E", "-E"]]), rawPrefixFlags: [{ label: "-E", prefix: "-E" }], + // GNU make keeps "--e" ambiguous with "--environment-overrides"; + // "--ev" is the shortest unique spelling of "--eval". + abbreviatedFlags: [{ label: "--eval", full: "--eval", min: "--ev" }], prefixFlags: [ { label: "-f", prefix: "-f" }, { label: "--file", prefix: "--file=" }, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index a96dfa01d828..2bee4aa16368 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -3372,6 +3372,19 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectInvokeErrorMessage(malicious.sendInvokeResult, { message: "awk inline program requires explicit approval in strictInlineEval mode", }); + + const abbreviated = await runSystemInvoke({ + preferMacAppExecHost: false, + command: [executablePath, '--s=BEGIN{system("id")}', "/dev/null"], + cwd: tempDir, + security: "allowlist", + ask: "on-miss", + }); + + expect(abbreviated.runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(abbreviated.sendInvokeResult, { + message: "gawk --source requires explicit approval in strictInlineEval mode", + }); }, }); } finally { From fe5d591cc339dfd87c4b6cc730ae7b652638f90b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 07:37:21 +0100 Subject: [PATCH 022/239] fix: preserve queued session refresh options --- .../lib/sessions/index.event-refresh.test.ts | 143 ++++++++++++++++++ ui/src/lib/sessions/index.ts | 81 +++++++--- 2 files changed, 203 insertions(+), 21 deletions(-) diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 0a74c3332214..7017529a4d06 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -176,6 +176,149 @@ describe("event-driven session list refresh", () => { } }); + it.each([ + { timing: "before", fireBeforeInitialCompletion: true }, + { timing: "after", fireBeforeInitialCompletion: false }, + ])( + "preserves queued explicit options when the event debounce fires $timing the active request completes", + async ({ fireBeforeInitialCompletion }) => { + vi.useFakeTimers(); + const firstList = deferred(); + const secondList = deferred(); + const secondListStarted = deferred(); + let listCalls = 0; + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + listCalls += 1; + if (listCalls === 1) { + return await firstList.promise; + } + if (listCalls === 2) { + secondListStarted.resolve(); + return await secondList.promise; + } + return sessionsResult(listCalls); + }); + const { sessions, emitEvent } = createHarness( + request as unknown as GatewayBrowserClient["request"], + ); + + try { + const initialRefresh = sessions.refresh({ agentId: "main", force: true }); + const explicitRefresh = sessions.refresh({ + agentId: "other", + search: "queued", + archivedFilter: "archived", + limit: 17, + includeDerivedTitles: true, + backgroundHydrate: true, + force: true, + }); + + emitEvent(sessionChangedEvent("agent:main:later-event")); + if (fireBeforeInitialCompletion) { + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + expect(request).toHaveBeenCalledTimes(1); + } + + firstList.resolve(sessionsResult(1)); + await secondListStarted.promise; + if (!fireBeforeInitialCompletion) { + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + } + + expect(request.mock.calls[1]?.[1]).toEqual({ + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + limit: 17, + includeDerivedTitles: true, + archived: true, + agentId: "other", + search: "queued", + }); + expect(sessions.state.loading).toBe(false); + + secondList.resolve(sessionsResult(2)); + await Promise.all([initialRefresh, explicitRefresh]); + expect(request).toHaveBeenCalledTimes(2); + } finally { + firstList.resolve(sessionsResult(1)); + secondList.resolve(sessionsResult(2)); + sessions.dispose(); + vi.useRealTimers(); + } + }, + ); + + it("keeps event invalidation after a queued append refresh", async () => { + vi.useFakeTimers(); + const firstList = deferred(); + const secondList = deferred(); + const secondListStarted = deferred(); + const thirdListStarted = deferred(); + let listCalls = 0; + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + listCalls += 1; + if (listCalls === 1) { + return await firstList.promise; + } + if (listCalls === 2) { + secondListStarted.resolve(); + return await secondList.promise; + } + if (listCalls === 3) { + thirdListStarted.resolve(); + } + return sessionsResult(listCalls); + }); + const { sessions, emitEvent } = createHarness( + request as unknown as GatewayBrowserClient["request"], + ); + + try { + const initialRefresh = sessions.refresh({ agentId: "main", limit: 25, force: true }); + const appendRefresh = sessions.refresh({ + agentId: "main", + limit: 25, + offset: 25, + append: true, + force: true, + }); + emitEvent(sessionChangedEvent("agent:main:later-event")); + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + + firstList.resolve(sessionsResult(1)); + await secondListStarted.promise; + expect(request.mock.calls[1]?.[1]).toMatchObject({ + agentId: "main", + limit: 25, + offset: 25, + }); + + secondList.resolve(sessionsResult(2)); + await thirdListStarted.promise; + expect(request.mock.calls[2]?.[1]).toMatchObject({ + agentId: "main", + limit: 25, + }); + expect(request.mock.calls[2]?.[1]).not.toHaveProperty("offset"); + + await Promise.all([initialRefresh, appendRefresh]); + expect(request).toHaveBeenCalledTimes(3); + } finally { + firstList.resolve(sessionsResult(1)); + secondList.resolve(sessionsResult(2)); + sessions.dispose(); + vi.useRealTimers(); + } + }); + it("queues one trailing refresh for an event during an in-flight refresh", async () => { vi.useFakeTimers(); const secondList = deferred(); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 05f239c55f0c..863037b0ea2e 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -727,7 +727,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil sectionOrder: [], }; let inFlight: Promise | null = null; - let queuedRefresh: SessionRefreshOptions | null = null; + let queuedExplicitRefresh: SessionRefreshOptions | null = null; + let eventRefreshQueued = false; let eventRefreshTimer: ReturnType | null = null; let eventRefreshDeadline: number | null = null; let canonicalListRevision = 0; @@ -975,6 +976,33 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil } }; + const clearEventRefreshTimer = () => { + if (eventRefreshTimer !== null) { + globalThis.clearTimeout(eventRefreshTimer); + eventRefreshTimer = null; + } + eventRefreshDeadline = null; + }; + + const takeNextQueuedRefresh = (): SessionRefreshOptions | null => { + const explicitRefresh = queuedExplicitRefresh; + queuedExplicitRefresh = null; + if (explicitRefresh) { + // A replacement that has not started yet observes every earlier event. + // Appends still need a canonical replacement after their requested page. + if (explicitRefresh.append !== true) { + clearEventRefreshTimer(); + eventRefreshQueued = false; + } + return explicitRefresh; + } + if (!eventRefreshQueued) { + return null; + } + eventRefreshQueued = false; + return { ...lastListOptions, force: true }; + }; + const drainRefreshQueue = async (options: SessionRefreshOptions) => { const epoch = connectionEpoch; let next: SessionRefreshOptions | null = options; @@ -983,17 +1011,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (disposed || connectionEpoch !== epoch) { return; } - next = queuedRefresh; - queuedRefresh = null; + next = takeNextQueuedRefresh(); } }; - const clearEventRefreshTimer = () => { - if (eventRefreshTimer !== null) { - globalThis.clearTimeout(eventRefreshTimer); - eventRefreshTimer = null; - } - eventRefreshDeadline = null; + const startRefresh = (options: SessionRefreshOptions) => { + const request = drainRefreshQueue(options).finally(() => { + if (inFlight === request) { + inFlight = null; + } + }); + inFlight = request; + return request; }; const refresh = (options: SessionRefreshOptions = {}) => { @@ -1003,7 +1032,10 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (inFlight) { // An explicit queued refresh subsumes any older event invalidation. clearEventRefreshTimer(); - queuedRefresh = options; + queuedExplicitRefresh = options; + if (options.append !== true) { + eventRefreshQueued = false; + } return inFlight; } const hasListOverrides = Object.entries(options).some( @@ -1014,13 +1046,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil } // An explicit refresh that will issue a request must run now. clearEventRefreshTimer(); - const request = drainRefreshQueue(options).finally(() => { - if (inFlight === request) { - inFlight = null; - } - }); - inFlight = request; - return request; + return startRefresh(options); + }; + + const refreshFromEvent = () => { + if (gateway.snapshot.phase !== "connected" || !gateway.snapshot.client || disposed) { + return Promise.resolve(); + } + if (inFlight) { + eventRefreshQueued = true; + return inFlight; + } + return startRefresh({ ...lastListOptions, force: true }); }; const flushEventRefresh = () => { @@ -1028,7 +1065,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return; } clearEventRefreshTimer(); - void refresh({ ...lastListOptions, force: true }); + void refreshFromEvent(); }; const scheduleEventRefresh = () => { @@ -1044,7 +1081,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil eventRefreshTimer = globalThis.setTimeout(() => { eventRefreshTimer = null; eventRefreshDeadline = null; - void refresh({ ...lastListOptions, force: true }); + void refreshFromEvent(); }, delay); }; @@ -1818,7 +1855,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil invalidateGroupsLoad(); swarmActivity.clear(); inFlight = null; - queuedRefresh = null; + queuedExplicitRefresh = null; + eventRefreshQueued = false; rollbackPendingModelPatches(); preparedWorkSessionKeys.clear(); pullRequestSummaries.clear(); @@ -2010,7 +2048,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil invalidateGroupsLoad(); connectionConnected = false; inFlight = null; - queuedRefresh = null; + queuedExplicitRefresh = null; + eventRefreshQueued = false; subscribedClient = null; pendingModelPatches.clear(); preparedWorkSessionKeys.clear(); From ba2116249e69221191a8341ff1fb82ba2bf636e7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 07:37:32 +0100 Subject: [PATCH 023/239] docs: note queued session refresh fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0db9422133e..7fc8f587723b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. - **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended. From 75d10f8e4f71f007db425d49f2949ccd21612938 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 07:49:11 +0100 Subject: [PATCH 024/239] fix: retain event invalidation for pagination --- .../lib/sessions/index.event-refresh.test.ts | 38 ++++++++++++++----- ui/src/lib/sessions/index.ts | 20 +++++----- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 7017529a4d06..8cf4e52b4323 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -253,12 +253,27 @@ describe("event-driven session list refresh", () => { }, ); - it("keeps event invalidation after a queued append refresh", async () => { + it.each([ + { + timing: "after the append is queued", + eventBeforeAppend: false, + queueReplacementFirst: false, + }, + { + timing: "before the append is queued", + eventBeforeAppend: true, + queueReplacementFirst: false, + }, + { + timing: "before a queued replacement is replaced by the append", + eventBeforeAppend: true, + queueReplacementFirst: true, + }, + ])("keeps event invalidation $timing", async ({ eventBeforeAppend, queueReplacementFirst }) => { vi.useFakeTimers(); const firstList = deferred(); const secondList = deferred(); const secondListStarted = deferred(); - const thirdListStarted = deferred(); let listCalls = 0; const request = vi.fn(async (method: string) => { if (method !== "sessions.list") { @@ -272,9 +287,6 @@ describe("event-driven session list refresh", () => { secondListStarted.resolve(); return await secondList.promise; } - if (listCalls === 3) { - thirdListStarted.resolve(); - } return sessionsResult(listCalls); }); const { sessions, emitEvent } = createHarness( @@ -283,6 +295,12 @@ describe("event-driven session list refresh", () => { try { const initialRefresh = sessions.refresh({ agentId: "main", limit: 25, force: true }); + if (eventBeforeAppend) { + emitEvent(sessionChangedEvent("agent:main:later-event")); + } + if (queueReplacementFirst) { + void sessions.refresh({ agentId: "discarded", force: true }); + } const appendRefresh = sessions.refresh({ agentId: "main", limit: 25, @@ -290,7 +308,9 @@ describe("event-driven session list refresh", () => { append: true, force: true, }); - emitEvent(sessionChangedEvent("agent:main:later-event")); + if (!eventBeforeAppend) { + emitEvent(sessionChangedEvent("agent:main:later-event")); + } await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); firstList.resolve(sessionsResult(1)); @@ -302,15 +322,13 @@ describe("event-driven session list refresh", () => { }); secondList.resolve(sessionsResult(2)); - await thirdListStarted.promise; + await Promise.all([initialRefresh, appendRefresh]); + expect(request).toHaveBeenCalledTimes(3); expect(request.mock.calls[2]?.[1]).toMatchObject({ agentId: "main", limit: 25, }); expect(request.mock.calls[2]?.[1]).not.toHaveProperty("offset"); - - await Promise.all([initialRefresh, appendRefresh]); - expect(request).toHaveBeenCalledTimes(3); } finally { firstList.resolve(sessionsResult(1)); secondList.resolve(sessionsResult(2)); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 863037b0ea2e..8abc495bb911 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -984,6 +984,11 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil eventRefreshDeadline = null; }; + const absorbPendingEventRefresh = () => { + clearEventRefreshTimer(); + eventRefreshQueued = false; + }; + const takeNextQueuedRefresh = (): SessionRefreshOptions | null => { const explicitRefresh = queuedExplicitRefresh; queuedExplicitRefresh = null; @@ -991,8 +996,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil // A replacement that has not started yet observes every earlier event. // Appends still need a canonical replacement after their requested page. if (explicitRefresh.append !== true) { - clearEventRefreshTimer(); - eventRefreshQueued = false; + absorbPendingEventRefresh(); } return explicitRefresh; } @@ -1030,12 +1034,9 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return Promise.resolve(); } if (inFlight) { - // An explicit queued refresh subsumes any older event invalidation. - clearEventRefreshTimer(); + // Keep event invalidation pending until the queued request actually + // starts: a later explicit call can still replace this request. queuedExplicitRefresh = options; - if (options.append !== true) { - eventRefreshQueued = false; - } return inFlight; } const hasListOverrides = Object.entries(options).some( @@ -1044,8 +1045,9 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (state.result && !options.force && !hasListOverrides) { return Promise.resolve(); } - // An explicit refresh that will issue a request must run now. - clearEventRefreshTimer(); + if (options.append !== true) { + absorbPendingEventRefresh(); + } return startRefresh(options); }; From fcf2e7aadc1ed5f2928de57633fe57d5291c6f9d Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 08:01:10 +0100 Subject: [PATCH 025/239] test: type session request parameters (#116713) --- ui/src/lib/sessions/index.event-refresh.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 8cf4e52b4323..60eae3fc9be4 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -187,7 +187,7 @@ describe("event-driven session list refresh", () => { const secondList = deferred(); const secondListStarted = deferred(); let listCalls = 0; - const request = vi.fn(async (method: string) => { + const request = vi.fn(async (method: string, _params?: unknown) => { if (method !== "sessions.list") { throw new Error(`Unexpected request: ${method}`); } @@ -275,7 +275,7 @@ describe("event-driven session list refresh", () => { const secondList = deferred(); const secondListStarted = deferred(); let listCalls = 0; - const request = vi.fn(async (method: string) => { + const request = vi.fn(async (method: string, _params?: unknown) => { if (method !== "sessions.list") { throw new Error(`Unexpected request: ${method}`); } From f5c986bb59c25103b86defa635316d7796a141d8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 13:13:16 +0800 Subject: [PATCH 026/239] fix(gateway): close talk relays on disconnect --- src/gateway/server/ws-connection.test.ts | 29 +++++++ src/gateway/server/ws-connection.ts | 2 + src/gateway/talk-realtime-relay-operations.ts | 16 ++++ src/gateway/talk-realtime-relay.test.ts | 82 +++++++++++++++++++ src/gateway/talk-realtime-relay.ts | 1 + 5 files changed, 130 insertions(+) diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index 92cc4a021cd7..60f0a425951b 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -15,12 +15,14 @@ const { attachGatewayWsMessageHandlerMock, attachWorkerWsMessageHandlerMock, broadcastPresenceSnapshotMock, + closeTalkRealtimeRelaySessionsForConnectionMock, touchPresenceMock, upsertPresenceMock, } = vi.hoisted(() => ({ attachGatewayWsMessageHandlerMock: vi.fn(), attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()), broadcastPresenceSnapshotMock: vi.fn(), + closeTalkRealtimeRelaySessionsForConnectionMock: vi.fn(), touchPresenceMock: vi.fn(), upsertPresenceMock: vi.fn(), })); @@ -38,6 +40,9 @@ vi.mock("../../infra/system-presence.js", () => ({ vi.mock("./presence-events.js", () => ({ broadcastPresenceSnapshot: broadcastPresenceSnapshotMock, })); +vi.mock("../talk-realtime-relay.js", () => ({ + closeTalkRealtimeRelaySessionsForConnection: closeTalkRealtimeRelaySessionsForConnectionMock, +})); import { attachGatewayWsConnectionHandler } from "./ws-connection.js"; import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js"; @@ -91,6 +96,7 @@ describe("attachGatewayWsConnectionHandler", () => { attachGatewayWsMessageHandlerMock.mockReset(); attachWorkerWsMessageHandlerMock.mockClear(); broadcastPresenceSnapshotMock.mockReset(); + closeTalkRealtimeRelaySessionsForConnectionMock.mockReset(); touchPresenceMock.mockReset(); upsertPresenceMock.mockReset(); }); @@ -262,6 +268,29 @@ describe("attachGatewayWsConnectionHandler", () => { expect(socket.ping).toHaveBeenCalledOnce(); }); + it("releases realtime Talk relays when a gateway connection closes", async () => { + const { passed, socket } = await connectTestWs(); + const handlerParams = passed as { + connId: string; + setClient: (client: unknown) => boolean; + }; + expect( + handlerParams.setClient({ + socket, + connect: { client: { id: "openclaw-control-ui", mode: "webchat" } }, + connId: handlerParams.connId, + usesSharedGatewayAuth: false, + }), + ).toBe(true); + + socket.emit("close", 1000, Buffer.from("done")); + + expect(closeTalkRealtimeRelaySessionsForConnectionMock).toHaveBeenCalledOnce(); + expect(closeTalkRealtimeRelaySessionsForConnectionMock).toHaveBeenCalledWith( + handlerParams.connId, + ); + }); + it("continues protocol pings after pong and stops when the connection closes", async () => { vi.useFakeTimers(); const socket = Object.assign(createGatewayWsTestSocket({ ping: true }), { diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 6357d1b7a487..f0db7cb79dd6 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -28,6 +28,7 @@ import { } from "../server-constants.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js"; import { formatError } from "../server-utils.js"; +import { closeTalkRealtimeRelaySessionsForConnection } from "../talk-realtime-relay.js"; import { formatForLog, logWs } from "../ws-log.js"; import { getHealthVersion, incrementPresenceVersion } from "./health-state.js"; import type { PreauthConnectionBudget } from "./preauth-connection-budget.js"; @@ -554,6 +555,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti } if (connectionKind === "gateway") { const context = buildRequestContext(); + closeTalkRealtimeRelaySessionsForConnection(connId); context.unsubscribeAllSessionEvents(connId); // Detach (or, with a zero grace period, kill) any PTY shells this // connection owned; detached sessions stay reattachable via diff --git a/src/gateway/talk-realtime-relay-operations.ts b/src/gateway/talk-realtime-relay-operations.ts index 97b0d977df1a..2b609a27f095 100644 --- a/src/gateway/talk-realtime-relay-operations.ts +++ b/src/gateway/talk-realtime-relay-operations.ts @@ -6,6 +6,7 @@ import { import { registerClientVoiceConsultRun } from "../talk/client-voice-session.js"; import type { RealtimeVoiceToolResultOptions } from "../talk/provider-types.js"; import { abortChatRunById } from "./chat-abort.js"; +import { formatError } from "./server-utils.js"; import { cancelForcedConsults, submitForcedTalkRealtimeRelayToolResult, @@ -107,6 +108,21 @@ export function closeRelaySession(session: RelaySession, reason: "completed" | " }); } +/** Releases every realtime relay session owned by a disconnected gateway connection. */ +export function closeTalkRealtimeRelaySessionsForConnection(connId: string): void { + for (const session of relaySessions.values()) { + if (session.connId === connId) { + try { + closeRelaySession(session, "completed"); + } catch (error) { + session.context.logGateway.warn( + `failed to close realtime relay session after connection disconnect: ${formatError(error)}`, + ); + } + } + } +} + function pruneExpiredRelaySessions(nowMs = Date.now()): void { closeExpiredTalkRelaySessions({ sessions: relaySessions.values(), diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index e213aec5205a..f8a28e77a5ff 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -25,6 +25,7 @@ import { createChatRunState } from "./server-chat-state.js"; import { acknowledgeTalkRealtimeRelayMark, cancelTalkRealtimeRelayTurn, + closeTalkRealtimeRelaySessionsForConnection, createTalkRealtimeRelaySession as createTalkRealtimeRelaySessionRaw, ensureTalkRealtimeRelayVoiceSession, flushTalkRealtimeRelayVoiceWrites, @@ -92,6 +93,87 @@ describe("talk realtime gateway relay", () => { }; } + it("closes only realtime relays owned by the disconnected connection", () => { + const bridgeCloses: Array> = []; + const bridgeAudioSends: Array> = []; + const provider = createIdleRelayProvider(); + provider.createBridge = () => { + const close = vi.fn(); + const sendAudio = vi.fn(); + bridgeCloses.push(close); + bridgeAudioSends.push(sendAudio); + return { + connect: vi.fn(async () => undefined), + sendAudio, + setMediaTimestamp: vi.fn(), + handleBargeIn: vi.fn(), + submitToolResult: vi.fn(), + acknowledgeMark: vi.fn(), + close, + isConnected: vi.fn(() => true), + }; + }; + const logGateway = { warn: vi.fn() }; + const context = { + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({}), + logGateway, + } as never; + const createSession = (connId: string) => + createTalkRealtimeRelaySession({ + context, + connId, + provider, + providerConfig: {}, + instructions: "brief", + tools: [], + }); + const firstOwned = createSession("conn-owner"); + const secondOwned = createSession("conn-owner"); + const unrelated = createSession("conn-other"); + bridgeCloses[0]?.mockImplementationOnce(() => { + throw new Error("provider close failed"); + }); + + expect(() => closeTalkRealtimeRelaySessionsForConnection("conn-owner")).not.toThrow(); + closeTalkRealtimeRelaySessionsForConnection("conn-owner"); + + expect(bridgeCloses[0]).toHaveBeenCalledOnce(); + expect(bridgeCloses[1]).toHaveBeenCalledOnce(); + expect(bridgeCloses[2]).not.toHaveBeenCalled(); + expect(logGateway.warn).toHaveBeenCalledWith( + "failed to close realtime relay session after connection disconnect: provider close failed", + ); + expect(() => + sendTalkRealtimeRelayAudio({ + relaySessionId: firstOwned.relaySessionId, + connId: "conn-owner", + audioBase64: "AQI=", + }), + ).toThrow("Unknown realtime relay session"); + expect(() => + sendTalkRealtimeRelayAudio({ + relaySessionId: secondOwned.relaySessionId, + connId: "conn-owner", + audioBase64: "AQI=", + }), + ).toThrow("Unknown realtime relay session"); + + sendTalkRealtimeRelayAudio({ + relaySessionId: unrelated.relaySessionId, + connId: "conn-other", + audioBase64: "AQI=", + }); + expect(bridgeAudioSends[2]).toHaveBeenCalledOnce(); + stopTalkRealtimeRelaySession({ + relaySessionId: unrelated.relaySessionId, + connId: "conn-other", + }); + closeTalkRealtimeRelaySessionsForConnection("conn-other"); + expect(bridgeCloses[2]).toHaveBeenCalledOnce(); + }); + it("injects the host agent runner only into gateway-relay bridge creation", () => { let bridgeRequest: RealtimeVoiceBridgeCreateRequest | undefined; const provider = createIdleRelayProvider(); diff --git a/src/gateway/talk-realtime-relay.ts b/src/gateway/talk-realtime-relay.ts index 225a59f2b426..2ae9964ec36c 100644 --- a/src/gateway/talk-realtime-relay.ts +++ b/src/gateway/talk-realtime-relay.ts @@ -4,6 +4,7 @@ export { createTalkRealtimeRelaySession } from "./talk-realtime-relay-session-cr export { acknowledgeTalkRealtimeRelayMark, cancelTalkRealtimeRelayTurn, + closeTalkRealtimeRelaySessionsForConnection, ensureTalkRealtimeRelayVoiceSession, flushTalkRealtimeRelayVoiceWrites, registerTalkRealtimeRelayAgentRun, From c278eb0ba78596e39f0f1851e26dc7ba9d5c131c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:48:10 +0800 Subject: [PATCH 027/239] fix(gateway): finalize talk relay teardown after close errors --- src/gateway/talk-realtime-relay-operations.ts | 29 ++-- src/gateway/talk-realtime-relay.test.ts | 152 +++++++++++------- 2 files changed, 112 insertions(+), 69 deletions(-) diff --git a/src/gateway/talk-realtime-relay-operations.ts b/src/gateway/talk-realtime-relay-operations.ts index 2b609a27f095..e54b99717c06 100644 --- a/src/gateway/talk-realtime-relay-operations.ts +++ b/src/gateway/talk-realtime-relay-operations.ts @@ -94,18 +94,23 @@ export function closeRelaySession(session: RelaySession, reason: "completed" | " forgetUnifiedTalkSession(session.id); clearTimeout(session.cleanupTimer); abortRelayAgentRuns(session, reason === "error" ? "relay-error" : "relay-closed"); - session.bridge.close(); - closeRelayVoiceSession(session); - broadcastToOwner(session.context, session.connId, { - relaySessionId: session.id, - type: "close", - reason, - talkEvent: session.harness.talk.emit({ - type: "session.closed", - payload: { reason }, - final: true, - }), - }); + try { + session.bridge.close(); + } finally { + // Provider teardown may throw, but the relay must still reach its durable + // voice and owner-visible terminal state before that error is surfaced. + closeRelayVoiceSession(session); + broadcastToOwner(session.context, session.connId, { + relaySessionId: session.id, + type: "close", + reason, + talkEvent: session.harness.talk.emit({ + type: "session.closed", + payload: { reason }, + final: true, + }), + }); + } } /** Releases every realtime relay session owned by a disconnected gateway connection. */ diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index f8a28e77a5ff..4c87e0f23860 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; * Tests talk realtime relay event forwarding and connection cleanup. */ import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { setActiveEmbeddedRun } from "../agents/embedded-agent-runner/runs.js"; import { testing as embeddedRunTesting } from "../agents/embedded-agent-runner/runs.test-support.js"; import { @@ -37,6 +38,7 @@ import { } from "./talk-realtime-relay.js"; const activeRelaySessions = new Map(); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); function createTalkRealtimeRelaySession( params: Parameters[0], @@ -93,7 +95,10 @@ describe("talk realtime gateway relay", () => { }; } - it("closes only realtime relays owned by the disconnected connection", () => { + it("closes only realtime relays owned by the disconnected connection", async () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + const tempDir = await fs.realpath(tempDirs.make("openclaw-relay-disconnect-")); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const bridgeCloses: Array> = []; const bridgeAudioSends: Array> = []; const provider = createIdleRelayProvider(); @@ -113,65 +118,98 @@ describe("talk realtime gateway relay", () => { isConnected: vi.fn(() => true), }; }; - const logGateway = { warn: vi.fn() }; - const context = { - broadcastToConnIds: vi.fn(), - chatAbortControllers: new Map(), - getRuntimeConfig: () => ({}), - logGateway, - } as never; - const createSession = (connId: string) => - createTalkRealtimeRelaySession({ - context, - connId, - provider, - providerConfig: {}, - instructions: "brief", - tools: [], - }); - const firstOwned = createSession("conn-owner"); - const secondOwned = createSession("conn-owner"); - const unrelated = createSession("conn-other"); - bridgeCloses[0]?.mockImplementationOnce(() => { - throw new Error("provider close failed"); - }); - - expect(() => closeTalkRealtimeRelaySessionsForConnection("conn-owner")).not.toThrow(); - closeTalkRealtimeRelaySessionsForConnection("conn-owner"); - - expect(bridgeCloses[0]).toHaveBeenCalledOnce(); - expect(bridgeCloses[1]).toHaveBeenCalledOnce(); - expect(bridgeCloses[2]).not.toHaveBeenCalled(); - expect(logGateway.warn).toHaveBeenCalledWith( - "failed to close realtime relay session after connection disconnect: provider close failed", - ); - expect(() => - sendTalkRealtimeRelayAudio({ + try { + const logGateway = { warn: vi.fn() }; + const broadcastToConnIds = vi.fn(); + const context = { + broadcastToConnIds, + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({}), + logGateway, + } as never; + const createSession = (connId: string) => + createTalkRealtimeRelaySession({ + context, + connId, + provider, + providerConfig: {}, + instructions: "brief", + tools: [], + }); + const firstOwned = createSession("conn-owner"); + const secondOwned = createSession("conn-owner"); + const unrelated = createSession("conn-other"); + ensureTalkRealtimeRelayVoiceSession({ relaySessionId: firstOwned.relaySessionId, connId: "conn-owner", - audioBase64: "AQI=", - }), - ).toThrow("Unknown realtime relay session"); - expect(() => - sendTalkRealtimeRelayAudio({ - relaySessionId: secondOwned.relaySessionId, - connId: "conn-owner", - audioBase64: "AQI=", - }), - ).toThrow("Unknown realtime relay session"); + sessionKey: "agent:main:main", + }); + expect(clientVoiceSessionTesting.readRecord("main", firstOwned.relaySessionId)).toMatchObject( + { + status: "open", + }, + ); + bridgeCloses[0]?.mockImplementationOnce(() => { + throw new Error("provider close failed"); + }); - sendTalkRealtimeRelayAudio({ - relaySessionId: unrelated.relaySessionId, - connId: "conn-other", - audioBase64: "AQI=", - }); - expect(bridgeAudioSends[2]).toHaveBeenCalledOnce(); - stopTalkRealtimeRelaySession({ - relaySessionId: unrelated.relaySessionId, - connId: "conn-other", - }); - closeTalkRealtimeRelaySessionsForConnection("conn-other"); - expect(bridgeCloses[2]).toHaveBeenCalledOnce(); + expect(() => closeTalkRealtimeRelaySessionsForConnection("conn-owner")).not.toThrow(); + closeTalkRealtimeRelaySessionsForConnection("conn-owner"); + + expect(bridgeCloses[0]).toHaveBeenCalledOnce(); + expect(bridgeCloses[1]).toHaveBeenCalledOnce(); + expect(bridgeCloses[2]).not.toHaveBeenCalled(); + expect(logGateway.warn).toHaveBeenCalledWith( + "failed to close realtime relay session after connection disconnect: provider close failed", + ); + await vi.waitFor(() => + expect( + clientVoiceSessionTesting.readRecord("main", firstOwned.relaySessionId)?.status, + ).toBe("closed"), + ); + expect( + broadcastToConnIds.mock.calls.some( + ([event, payload]) => + event === "talk.event" && + payload.relaySessionId === firstOwned.relaySessionId && + payload.type === "close" && + payload.talkEvent?.type === "session.closed" && + payload.talkEvent.final === true, + ), + ).toBe(true); + expect(() => + sendTalkRealtimeRelayAudio({ + relaySessionId: firstOwned.relaySessionId, + connId: "conn-owner", + audioBase64: "AQI=", + }), + ).toThrow("Unknown realtime relay session"); + expect(() => + sendTalkRealtimeRelayAudio({ + relaySessionId: secondOwned.relaySessionId, + connId: "conn-owner", + audioBase64: "AQI=", + }), + ).toThrow("Unknown realtime relay session"); + + sendTalkRealtimeRelayAudio({ + relaySessionId: unrelated.relaySessionId, + connId: "conn-other", + audioBase64: "AQI=", + }); + expect(bridgeAudioSends[2]).toHaveBeenCalledOnce(); + stopTalkRealtimeRelaySession({ + relaySessionId: unrelated.relaySessionId, + connId: "conn-other", + }); + closeTalkRealtimeRelaySessionsForConnection("conn-other"); + expect(bridgeCloses[2]).toHaveBeenCalledOnce(); + } finally { + clientVoiceSessionTesting.reset(); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + envSnapshot.restore(); + } }); it("injects the host agent runner only into gateway-relay bridge creation", () => { From 5a9c249780f95074bb6e4f30c3df7060f9c4df93 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:12:59 +0800 Subject: [PATCH 028/239] fix(testing): repair realtime relay live smoke import --- scripts/dev/realtime-talk-live-smoke.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/dev/realtime-talk-live-smoke.ts b/scripts/dev/realtime-talk-live-smoke.ts index a724a9dade36..17449d477e19 100644 --- a/scripts/dev/realtime-talk-live-smoke.ts +++ b/scripts/dev/realtime-talk-live-smoke.ts @@ -213,6 +213,10 @@ function transcriptIncludesMarker(transcripts: string[], marker: string): boolea return normalizeTranscript(transcripts.join(" ")).includes(normalizeTranscript(marker)); } +function resolveGatewayRelayModulePath(repoRoot = process.cwd()): string { + return `/@fs/${repoRoot.replaceAll("\\", "/")}/ui/src/pages/chat/realtime-talk-gateway-relay.ts`; +} + async function sendPcmAudioInChunks( bridge: RealtimeVoiceBridge, audio: Buffer, @@ -899,10 +903,7 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-")); try { const { createServer } = await import("vite"); - const repoRoot = process.cwd().replaceAll("\\", "/"); - const relayModulePath = JSON.stringify( - `/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`, - ); + const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath()); await writeFile( path.join(dir, "index.html"), '', @@ -1154,6 +1155,7 @@ export const testing = { parseRealtimeSmokeArgs, readOpenAIRealtimeBrowserResponseText, readBoundedText, + resolveGatewayRelayModulePath, resolveOpenAIHttpTimeoutMs, sendPcmAudioInChunks, transcriptIncludesMarker, From 5540f0c7f8cec22b43265a3b431752be91475ddc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:14:19 +0800 Subject: [PATCH 029/239] test(testing): guard realtime relay smoke module path --- test/scripts/dev-tooling-safety.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 47edc59afe1d..f3b2d62d8cc9 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -699,6 +699,13 @@ describe("script-specific dev tooling hardening", () => { expect(realtimeSmokeTesting.transcriptIncludesMarker(["ocean"], "glacier")).toBe(false); }); + it("resolves the realtime relay smoke to an existing Control UI module", () => { + const modulePath = realtimeSmokeTesting.resolveGatewayRelayModulePath(process.cwd()); + + expect(modulePath.endsWith("/ui/src/pages/chat/realtime-talk-gateway-relay.ts")).toBe(true); + expect(existsSync(modulePath.slice("/@fs/".length))).toBe(true); + }); + it("bounds OpenAI realtime smoke response body reads by content-length", async () => { const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES; const response = new Response("{}", { From 6d132b93b6af20f19ecd61ec46ee0330863dead7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:18:45 +0800 Subject: [PATCH 030/239] fix(testing): load relay smoke through UI Vite config --- scripts/dev/realtime-talk-live-smoke.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/dev/realtime-talk-live-smoke.ts b/scripts/dev/realtime-talk-live-smoke.ts index 17449d477e19..8c83b6e28f07 100644 --- a/scripts/dev/realtime-talk-live-smoke.ts +++ b/scripts/dev/realtime-talk-live-smoke.ts @@ -903,7 +903,8 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-")); try { const { createServer } = await import("vite"); - const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath()); + const repoRoot = process.cwd(); + const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath(repoRoot)); await writeFile( path.join(dir, "index.html"), '', @@ -911,8 +912,6 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise await writeFile( path.join(dir, "main.ts"), ` -const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath}); - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const listeners = new Set(); const requests = []; @@ -952,6 +951,7 @@ const client = { }; try { + const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath}); const transport = new GatewayRelayRealtimeTalkTransport( { provider: "smoke", @@ -1014,9 +1014,14 @@ try { `, ); server = await createServer({ + configFile: path.join(repoRoot, "ui/vite.config.ts"), root: dir, logLevel: "silent", - server: { host: "127.0.0.1", port: 0 }, + server: { + host: "127.0.0.1", + port: 0, + fs: { allow: [dir, repoRoot] }, + }, }); await server.listen(); const address = server.httpServer?.address(); From 0ce230c861ed92bc322fd96dbaa3b75740e25091 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:40:38 +0800 Subject: [PATCH 031/239] test(google): cover expired CLI OAuth compatibility --- extensions/google/cli-backend-auth.test.ts | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/extensions/google/cli-backend-auth.test.ts b/extensions/google/cli-backend-auth.test.ts index e574aafe3213..e68c8d5ba740 100644 --- a/extensions/google/cli-backend-auth.test.ts +++ b/extensions/google/cli-backend-auth.test.ts @@ -637,6 +637,30 @@ describe("google gemini cli backend auth bridge", () => { } }); + it("keeps expired but refreshable legacy OAuth profiles on the compatibility path", async () => { + await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { + const context = buildGeminiOAuthPrepareContext(workspaceDir); + if (!context.authCredential) { + throw new Error("expected Gemini OAuth test credentials"); + } + context.authCredential.expires = Date.now() - 60_000; + + const prepared = await buildGoogleGeminiCliBackend().prepareExecution?.(context); + try { + await stageGeminiPreparedExecution(prepared); + const home = prepared?.env?.GEMINI_CLI_HOME; + const raw = await fs.readFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "utf8"); + expect(JSON.parse(raw)).toMatchObject({ + access_token: "access-token", + refresh_token: "refresh-token", + expiry_date: context.authCredential.expires, + }); + } finally { + await prepared?.cleanup?.(); + } + }); + }); + it("stages Gemini CLI JSON through same-directory atomic renames", async () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const backend = buildGoogleGeminiCliBackend(); From 42f1e8dd4177fbec711deeccc6c0daafa76ace74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:25 +0800 Subject: [PATCH 032/239] fix(openai): terminalize preconnect lifecycle --- .../openai/realtime-voice-lifecycle.test.ts | 14 ++++++ extensions/openai/realtime-voice-lifecycle.ts | 43 ++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/extensions/openai/realtime-voice-lifecycle.test.ts b/extensions/openai/realtime-voice-lifecycle.test.ts index 85b044e94f2a..181077ee0ec9 100644 --- a/extensions/openai/realtime-voice-lifecycle.test.ts +++ b/extensions/openai/realtime-voice-lifecycle.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { OpenAIRealtimeVoiceLifecycle } from "./realtime-voice-lifecycle.js"; describe("OpenAIRealtimeVoiceLifecycle", () => { + it("terminalizes preconnect cancellation until an explicit fresh connection", () => { + const lifecycle = new OpenAIRealtimeVoiceLifecycle(); + + expect(lifecycle.phase()).toBe("idle"); + expect(lifecycle.cancel()).toBe(true); + expect(lifecycle.phase()).toBe("terminal"); + expect(lifecycle.cancel()).toBe(false); + + const connection = lifecycle.connect(); + expect(lifecycle.phase()).toBe("connecting"); + expect(lifecycle.ready(connection)).toBe(true); + expect(lifecycle.phase()).toBe("ready"); + }); + it("moves a connection from connecting to ready", () => { const lifecycle = new OpenAIRealtimeVoiceLifecycle(); const connection = lifecycle.connect(); diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index a436a5ac92af..5f136ca16587 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -1,4 +1,9 @@ -type OpenAIRealtimeVoiceLifecyclePhase = "connecting" | "ready" | "retry-wait" | "terminal"; +type OpenAIRealtimeVoiceLifecyclePhase = + | "idle" + | "connecting" + | "ready" + | "retry-wait" + | "terminal"; type OpenAIRealtimeVoiceTerminalOutcome = "completed" | "error"; @@ -7,20 +12,29 @@ export type OpenAIRealtimeVoiceConnection = Readonly<{ signal: AbortSignal; }>; -type OpenAIRealtimeVoiceLifecycleState = { +type OpenAIRealtimeVoiceIdleState = { + phase: "idle" | "terminal"; + terminalOutcome?: "completed"; +}; + +type OpenAIRealtimeVoiceConnectionState = { connection: OpenAIRealtimeVoiceConnection; controller: AbortController; - phase: OpenAIRealtimeVoiceLifecyclePhase; + phase: Exclude; retryAttempts: number; terminalOutcome?: OpenAIRealtimeVoiceTerminalOutcome; terminalNotified: boolean; }; export class OpenAIRealtimeVoiceLifecycle { - private state: OpenAIRealtimeVoiceLifecycleState | undefined; + private state: OpenAIRealtimeVoiceIdleState | OpenAIRealtimeVoiceConnectionState = { + phase: "idle", + }; connect(): OpenAIRealtimeVoiceConnection { - this.state?.controller.abort(new Error("OpenAI realtime voice connection replaced")); + if ("controller" in this.state) { + this.state.controller.abort(new Error("OpenAI realtime voice connection replaced")); + } const controller = new AbortController(); const connection = this.createConnection(controller); this.state = { @@ -72,9 +86,16 @@ export class OpenAIRealtimeVoiceLifecycle { cancel(): boolean { const state = this.state; - if (!state || state.terminalOutcome) { + if (state.phase === "terminal") { return false; } + if (state.phase === "idle") { + this.state = { + phase: "terminal", + terminalOutcome: "completed", + }; + return true; + } state.phase = "terminal"; state.terminalOutcome = "completed"; state.controller.abort(new Error("OpenAI realtime voice session canceled")); @@ -125,8 +146,8 @@ export class OpenAIRealtimeVoiceLifecycle { return this.state?.phase === "ready"; } - phase(): OpenAIRealtimeVoiceLifecyclePhase | undefined { - return this.state?.phase; + phase(): OpenAIRealtimeVoiceLifecyclePhase { + return this.state.phase; } terminalOutcome( @@ -141,7 +162,9 @@ export class OpenAIRealtimeVoiceLifecycle { private currentState( connection: OpenAIRealtimeVoiceConnection, - ): OpenAIRealtimeVoiceLifecycleState | undefined { - return this.state?.connection.id === connection.id ? this.state : undefined; + ): OpenAIRealtimeVoiceConnectionState | undefined { + return "connection" in this.state && this.state.connection.id === connection.id + ? this.state + : undefined; } } From 337a2b3fc7efb3770fe55fb35ec56fcca18569bc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:54 +0800 Subject: [PATCH 033/239] fix(openai): discard preconnect audio on close --- CHANGELOG.md | 1 + .../realtime-quicksilver-bridge.test.ts | 22 +++++++++++ .../openai/realtime-quicksilver-bridge.ts | 5 ++- .../openai/realtime-voice-provider.test.ts | 37 +++++++++++++++++++ extensions/openai/realtime-voice-provider.ts | 5 ++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc8f587723b..3e4f39696aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **OpenAI realtime preconnect close:** discard queued Talk audio when a bridge closes before its first connection, keep repeated closes idempotent, and require an explicit fresh connect before audio can flow again. - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. diff --git a/extensions/openai/realtime-quicksilver-bridge.test.ts b/extensions/openai/realtime-quicksilver-bridge.test.ts index 91ceca298e94..6bcb03df22a6 100644 --- a/extensions/openai/realtime-quicksilver-bridge.test.ts +++ b/extensions/openai/realtime-quicksilver-bridge.test.ts @@ -209,6 +209,28 @@ describe("OpenAIQuicksilverVoiceBridge", () => { harness.bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const harness = createHarness(); + + harness.bridge.sendAudio(Buffer.from("queued-before-connect")); + harness.bridge.close(); + harness.bridge.close(); + harness.bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(harness.connections).toHaveLength(0); + expect(harness.onClose).not.toHaveBeenCalled(); + + await harness.bridge.connect(); + + expect( + sentEvents(harness.socket).filter((event) => event.type === "input_audio.append"), + ).toHaveLength(0); + + harness.bridge.close(); + expect(harness.onClose).toHaveBeenCalledOnce(); + expect(harness.onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const sockets: FakeSocket[] = []; const bridge = new OpenAIQuicksilverVoiceBridge({ diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index e0dd6583d4c0..de8583cfcca1 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -361,10 +361,13 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } if (this.socket?.readyState === WEBSOCKET_OPEN) { this.sendEvent({ type: "session.close" }); } diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 701d9d32c2a1..5980daf8b2cd 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1549,6 +1549,43 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const onClose = vi.fn(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + onClose, + }); + + bridge.sendAudio(Buffer.from("queued-before-connect")); + bridge.close(); + bridge.close(); + bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(onClose).not.toHaveBeenCalled(); + + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to connect"); + } + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + await connecting; + + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const bridge = provider.createBridge({ diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 68f13eb388c7..c1e18f520ed4 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -734,10 +734,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } const ws = this.ws; this.ws = null; ws?.close(1000, "Bridge closed"); From 20e68e01dff5359c3a334271a8cdfaf994743e6b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:45:53 +0800 Subject: [PATCH 034/239] fix(gateway): sign device proofs with challenge time (#116679) --- CHANGELOG.md | 1 + apps/.i18n/native-source.json | 50 +++++------ .../ai/openclaw/app/gateway/GatewaySession.kt | 53 ++++++++---- .../GatewaySessionCustomHeadersTest.kt | 2 +- .../app/gateway/GatewaySessionInvokeTest.kt | 76 +++++++++++++++- .../gateway/GatewaySessionReconnectTest.kt | 2 +- .../WatchApp/Sources/WatchDirectNode.swift | 24 +++++- apps/linux/src-tauri/src/gateway_ws.rs | 84 +++++++++++++----- .../OpenClawMacCLI/WizardCommand.swift | 20 +++-- .../GatewayConnectionControlTests.swift | 2 +- .../GatewayWebSocketTestSupport.swift | 7 +- .../Sources/OpenClawKit/GatewayChannel.swift | 22 +++-- .../OpenClawKit/GatewayChannelSupport.swift | 1 + .../GatewayConnectChallengeSupport.swift | 33 ++++++- .../GatewayConnectChallengeSupportTests.swift | 34 ++++++++ .../GatewayNodeSessionTests.swift | 2 +- docs/gateway/clients.md | 9 +- docs/gateway/protocol.md | 7 ++ .../modules/copilot-gateway.js | 3 +- .../modules/copilot-gateway.test.ts | 35 +++++++- .../modules/copilot-runtime.js | 2 +- .../chrome-extension/sidepanel.e2e.test.ts | 2 +- packages/gateway-client/README.md | 1 + .../src/browser-device-auth.test.ts | 43 +++++++++- .../gateway-client/src/browser-device-auth.ts | 8 +- packages/gateway-client/src/client.ts | 20 +++-- .../src/client.watchdog.test.ts | 4 +- .../src/protocol-client.handshake.test.ts | 71 ++++++++++++++- .../gateway-client/src/protocol-client.ts | 50 +++++------ .../gateway-client/src/protocol-request.ts | 27 ++++++ packages/sdk/src/index.e2e.test.ts | 2 +- src/cli/acp-cli-exit.process.test.ts | 2 +- src/gateway/client.test.ts | 86 +++++++++++++++++-- src/gateway/minimal-gateway.test-helpers.ts | 2 +- src/gateway/watch-node-http.test.ts | 24 +++++- src/gateway/watch-node-http.ts | 2 +- src/tui/gateway-chat.scopes.test.ts | 2 +- ui/src/api/gateway.node.test.ts | 85 ++++++++++++++++-- ui/src/api/gateway.ts | 12 ++- ui/src/test-helpers/control-ui-e2e.ts | 2 +- 40 files changed, 756 insertions(+), 158 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayConnectChallengeSupportTests.swift create mode 100644 packages/gateway-client/src/protocol-request.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc8f587723b..1d2dbf3dcebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. +- **Gateway device clock skew:** sign device proofs with the Gateway-issued challenge timestamp across TypeScript, Control UI, browser extension, Android, Apple, Linux, and watchOS clients so incorrect local clocks no longer block authentication, while retaining no-challenge compatibility for pre-challenge Control UI servers and older watch-node HTTP endpoints and keeping nonce binding and freshness checks enforced. Fixes #103455. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. - **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index b1fec7538da6..1ef813f75d65 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1915,7 +1915,7 @@ }, { "kind": "ui-call", - "line": 1034, + "line": 1039, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Accept", "surface": "android", @@ -1923,7 +1923,7 @@ }, { "kind": "conditional-branch", - "line": 1834, + "line": 1849, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -1931,7 +1931,7 @@ }, { "kind": "conditional-branch", - "line": 1834, + "line": 1849, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -28683,7 +28683,7 @@ }, { "kind": "ui-localized-call", - "line": 64, + "line": 84, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Gateway HTTP error (%@)", "surface": "apple", @@ -28691,7 +28691,7 @@ }, { "kind": "ui-localized-call", - "line": 121, + "line": 141, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Ready to connect", "surface": "apple", @@ -28699,7 +28699,7 @@ }, { "kind": "ui-localized-call", - "line": 132, + "line": 152, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Ignored an expired direct connection setup. Send setup again from iPhone.", "surface": "apple", @@ -28707,7 +28707,7 @@ }, { "kind": "ui-localized-call", - "line": 145, + "line": 165, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Direct mode requires a trusted HTTPS Gateway endpoint.", "surface": "apple", @@ -28715,7 +28715,7 @@ }, { "kind": "ui-localized-call", - "line": 163, + "line": 183, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Could not save direct connection securely.", "surface": "apple", @@ -28723,7 +28723,7 @@ }, { "kind": "ui-localized-call", - "line": 180, + "line": 200, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Setup received. Connecting…", "surface": "apple", @@ -28731,7 +28731,7 @@ }, { "kind": "ui-localized-call", - "line": 196, + "line": 216, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Direct connection is off", "surface": "apple", @@ -28739,7 +28739,7 @@ }, { "kind": "ui-localized-call", - "line": 197, + "line": 217, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Use iPhone Settings to enable direct connection.", "surface": "apple", @@ -28747,7 +28747,7 @@ }, { "kind": "ui-localized-call", - "line": 225, + "line": 245, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Reconnects when OpenClaw is active", "surface": "apple", @@ -28755,7 +28755,7 @@ }, { "kind": "ui-localized-call", - "line": 279, + "line": 299, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Direct connection failed: %@", "surface": "apple", @@ -28763,7 +28763,7 @@ }, { "kind": "ui-localized-call", - "line": 281, + "line": 301, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "No usable Gateway endpoint", "surface": "apple", @@ -28771,7 +28771,7 @@ }, { "kind": "ui-localized-call", - "line": 297, + "line": 317, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Connecting directly…", "surface": "apple", @@ -28779,7 +28779,7 @@ }, { "kind": "ui-localized-call", - "line": 301, + "line": 321, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Could not save the watch device identity", "surface": "apple", @@ -28787,7 +28787,7 @@ }, { "kind": "ui-localized-call", - "line": 331, + "line": 351, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "No watch device credential", "surface": "apple", @@ -28795,7 +28795,7 @@ }, { "kind": "ui-localized-call", - "line": 348, + "line": 368, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Could not save the watch device credential", "surface": "apple", @@ -28803,7 +28803,7 @@ }, { "kind": "ui-localized-call", - "line": 362, + "line": 382, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Connected directly", "surface": "apple", @@ -28811,7 +28811,7 @@ }, { "kind": "ui-localized-call", - "line": 466, + "line": 488, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Could not sign watch identity", "surface": "apple", @@ -28819,7 +28819,7 @@ }, { "kind": "ui-localized-call", - "line": 559, + "line": 581, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Invalid Gateway response", "surface": "apple", @@ -28827,7 +28827,7 @@ }, { "kind": "ui-localized-call", - "line": 584, + "line": 606, "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", "source": "Paired, but could not finish secure setup", "surface": "apple", @@ -39939,7 +39939,7 @@ }, { "kind": "conditional-branch", - "line": 470, + "line": 474, "path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift", "source": " [\\(initial)]", "surface": "apple", @@ -39947,7 +39947,7 @@ }, { "kind": "conditional-branch", - "line": 517, + "line": 521, "path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift", "source": " — \\(option.hint!)", "surface": "apple", @@ -39955,7 +39955,7 @@ }, { "kind": "conditional-branch", - "line": 524, + "line": 528, "path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift", "source": " [\\(initialIndices.map(String.init).joined(separator: \",\"))]", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index ca4da20609c6..4ab2db3cfe11 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -907,6 +907,11 @@ class GatewaySession( val hello: GatewayHelloSummary, ) + private data class ConnectChallenge( + val nonce: String, + val issuedAtMs: Long, + ) + private enum class ConnectionState { CONNECTING, READY, @@ -926,7 +931,7 @@ class GatewaySession( private val state = AtomicReference(ConnectionState.CONNECTING) private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() - private val connectNonceDeferred = CompletableDeferred() + private val connectChallengeDeferred = CompletableDeferred() private val terminalCallbackClaimed = AtomicBoolean(false) private val connectResponseAccepted = AtomicBoolean(false) @@ -1224,7 +1229,7 @@ class GatewaySession( if (connectResponseAccepted.get()) { connectHandshakeJob?.join() } else { - connectNonceDeferred.completeExceptionally(connectError) + connectChallengeDeferred.completeExceptionally(connectError) } if (shouldNotify) onDisconnected(message) } finally { @@ -1262,8 +1267,8 @@ class GatewaySession( connectHandshakeJob = connectionScope.launch { try { - val nonce = awaitConnectNonce() - sendConnect(nonce) + val challenge = awaitConnectChallenge() + sendConnect(challenge) } catch (err: Throwable) { connectDeferred.completeExceptionally(err) closeQuietly() @@ -1310,7 +1315,7 @@ class GatewaySession( } } - private suspend fun sendConnect(connectNonce: String) { + private suspend fun sendConnect(connectChallenge: ConnectChallenge) { val identity = identityStore.loadOrCreate() val storedEntry = deviceAuthStore.loadEntry(endpoint.stableId, identity.deviceId, options.role) val storedToken = storedEntry?.token?.trim() @@ -1331,7 +1336,7 @@ class GatewaySession( val payload = buildConnectParams( identity = identity, - connectNonce = connectNonce, + connectChallenge = connectChallenge, selectedAuth = selectedAuth, ) val res = request(GatewayMethod.Connect.rawValue, payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) @@ -1512,7 +1517,7 @@ class GatewaySession( private fun buildConnectParams( identity: DeviceIdentity, - connectNonce: String, + connectChallenge: ConnectChallenge, selectedAuth: SelectedConnectAuth, ): JsonObject { val client = options.client @@ -1548,7 +1553,8 @@ class GatewaySession( } val connectScopes = resolveConnectScopes(selectedAuth) - val signedAtMs = System.currentTimeMillis() + val signedAtMs = connectChallenge.issuedAtMs + val connectNonce = connectChallenge.nonce // V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail. val payload = DeviceAuthPayload.buildV3( @@ -1681,9 +1687,15 @@ class GatewaySession( val payloadJson = frame["payload"]?.toString() ?: frame["payloadJSON"].asStringOrNull() if (event == GatewayEvent.ConnectChallenge.rawValue) { - val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) { - connectNonceDeferred.complete(nonce.trim()) + if (!connectChallengeDeferred.isCompleted) { + val challenge = extractConnectChallenge(payloadJson) + if (challenge == null) { + connectChallengeDeferred.completeExceptionally( + IllegalStateException("gateway connect challenge invalid"), + ) + } else { + connectChallengeDeferred.complete(challenge) + } } return } @@ -1696,17 +1708,20 @@ class GatewaySession( onEvent(event, payloadJson) } - private suspend fun awaitConnectNonce(): String = + private suspend fun awaitConnectChallenge(): ConnectChallenge = try { - withTimeout(2_000) { connectNonceDeferred.await() } - } catch (err: Throwable) { + withTimeout(2_000) { connectChallengeDeferred.await() } + } catch (err: TimeoutCancellationException) { throw IllegalStateException("connect challenge timeout", err) } - private fun extractConnectNonce(payloadJson: String?): String? { + private fun extractConnectChallenge(payloadJson: String?): ConnectChallenge? { if (payloadJson.isNullOrBlank()) return null val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null - return obj["nonce"].asStringOrNull() + val nonce = obj["nonce"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val issuedAtMs = + obj["ts"].asJsonIntegerLongOrNull()?.takeIf { it >= 0 } ?: return null + return ConnectChallenge(nonce = nonce, issuedAtMs = issuedAtMs) } private fun handleInvokeEvent(payloadJson: String) { @@ -2215,6 +2230,12 @@ private fun JsonElement?.asLongOrNull(): Long? = else -> null } +private fun JsonElement?.asJsonIntegerLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> if (isString) null else content.toLongOrNull() + else -> null + } + private fun JsonElement?.asIntOrNull(): Int? = when (this) { is JsonPrimitive -> content.toIntOrNull() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt index 98039b378d07..e4e6dd044775 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt @@ -38,7 +38,7 @@ import java.util.concurrent.atomic.AtomicReference private const val TEST_TIMEOUT_MS = 8_000L private const val CONNECT_CHALLENGE_FRAME = - """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""" + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}""" private class NoopDeviceAuthStore : DeviceAuthTokenStore { override fun loadEntry( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 07c652d2ce19..af4d53768d8f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -41,8 +41,9 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference private const val TEST_TIMEOUT_MS = 8_000L +private const val CONNECT_CHALLENGE_TS = 1_700_000_000_123L private const val CONNECT_CHALLENGE_FRAME = - """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""" + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":$CONNECT_CHALLENGE_TS}}""" private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { private val tokens = mutableMapOf() @@ -92,6 +93,76 @@ private data class InvokeScenarioResult( @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { + @Test + fun connect_usesGatewayChallengeTimestamp() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + if (method == "connect") { + assertEquals( + CONNECT_CHALLENGE_TS, + frame["params"] + ?.jsonObject + ?.get("device") + ?.jsonObject + ?.get("signedAt") + ?.jsonPrimitive + ?.content + ?.toLong(), + ) + webSocket.send(connectResponseFrame(id)) + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_rejectsChallengeWithoutTimestamp() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val connectRequests = AtomicInteger() + val server = + startGatewayServer( + json = json, + challengeFrame = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) { _, _, method, _ -> + if (method == "connect") connectRequests.incrementAndGet() + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + withTimeout(TEST_TIMEOUT_MS) { + while (lastDisconnect.get().isEmpty()) delay(10) + } + assertFalse(connected.isCompleted) + assertEquals(0, connectRequests.get()) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun canvasRoutePinsOnlyTheConnectedTlsEndpoint() { val fingerprint = "ab".repeat(32) @@ -1484,6 +1555,7 @@ class GatewaySessionInvokeTest { private fun startGatewayServer( json: Json, + challengeFrame: String = CONNECT_CHALLENGE_FRAME, onHandshake: ((RecordedRequest) -> Unit)? = null, onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit, ): MockWebServer = @@ -1498,7 +1570,7 @@ class GatewaySessionInvokeTest { webSocket: WebSocket, response: Response, ) { - webSocket.send(CONNECT_CHALLENGE_FRAME) + webSocket.send(challengeFrame) } override fun onMessage( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index 44656312aea3..2aca809d369f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -45,7 +45,7 @@ import java.util.concurrent.atomic.AtomicInteger private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME = - """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""" + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}""" private class ReconnectDeviceAuthStore : DeviceAuthTokenStore { override fun loadEntry( diff --git a/apps/ios/WatchApp/Sources/WatchDirectNode.swift b/apps/ios/WatchApp/Sources/WatchDirectNode.swift index b3062528da81..3a99c33a7a2c 100644 --- a/apps/ios/WatchApp/Sources/WatchDirectNode.swift +++ b/apps/ios/WatchApp/Sources/WatchDirectNode.swift @@ -36,6 +36,26 @@ final class WatchDirectNode { private struct ChallengeResponse: Decodable { let nonce: String + let ts: Int64? + + private enum CodingKeys: String, CodingKey { + case nonce + case ts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.nonce = try container.decode(String.self, forKey: .nonce) + self.ts = container.contains(.ts) + ? try container.decode(Int64.self, forKey: .ts) + : nil + if let ts, ts < 0 { + throw DecodingError.dataCorruptedError( + forKey: .ts, + in: container, + debugDescription: "Gateway challenge timestamp must be non-negative") + } + } } private struct PollResponse: Decodable { @@ -427,6 +447,8 @@ final class WatchDirectNode { let params = try connectParams( identity: identity, nonce: challenge.nonce, + // Older watch-node Gateways omitted ts; retain their original local-clock behavior. + signedAtMs: challenge.ts ?? Int64(Date().timeIntervalSince1970 * 1000), credential: credential, notificationsAuthorized: notificationSettings.authorizationStatus == .authorized || notificationSettings.authorizationStatus == .provisional) @@ -442,10 +464,10 @@ final class WatchDirectNode { private func connectParams( identity: DeviceIdentity, nonce: String, + signedAtMs: Int64, credential: ConnectCredential, notificationsAuthorized: Bool) throws -> ConnectParams { - let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) let payload = GatewayDeviceAuthPayload.buildV3( fields: .init( deviceId: identity.deviceId, diff --git a/apps/linux/src-tauri/src/gateway_ws.rs b/apps/linux/src-tauri/src/gateway_ws.rs index 580502bc765a..8f40bfb11662 100644 --- a/apps/linux/src-tauri/src/gateway_ws.rs +++ b/apps/linux/src-tauri/src/gateway_ws.rs @@ -16,7 +16,7 @@ use std::fmt; use std::io::ErrorKind; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use subtle::ConstantTimeEq; use tauri::{AppHandle, Emitter, Manager, Webview}; use tokio::sync::{mpsc, oneshot}; @@ -721,8 +721,7 @@ impl GatewayClient { let mut socket = tokio::time::timeout(CONNECT_TIMEOUT, connect_gateway_socket(config)) .await .map_err(|_| RequestFailure::transport("Gateway connection timed out."))??; - let nonce = wait_for_connect_challenge(&mut socket).await?; - let signed_at_ms = unix_time_ms().map_err(RequestFailure::transport)?; + let challenge = wait_for_connect_challenge(&mut socket).await?; // Native child WebViews use platform HTTP trust and cannot bind the optional // WebSocket leaf pin, so pinned Gateway connections remain capability-free. let inline_widgets_available = config @@ -732,8 +731,8 @@ impl GatewayClient { let params = connect_params( &identity, &auth, - &nonce, - signed_at_ms, + &challenge.nonce, + challenge.issued_at_ms, inline_widgets_available, ) .map_err(RequestFailure::transport)?; @@ -1127,22 +1126,42 @@ fn request_frame(id: &str, method: &str, params: Value) -> Value { }) } -async fn wait_for_connect_challenge(socket: &mut GatewaySocket) -> Result { +#[derive(Debug, PartialEq, Eq)] +struct ConnectChallenge { + nonce: String, + issued_at_ms: u64, +} + +fn parse_connect_challenge(value: &Value) -> Result { + let nonce = value + .get("payload") + .and_then(|payload| payload.get("nonce")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|nonce| !nonce.is_empty()); + let issued_at_ms = value + .get("payload") + .and_then(|payload| payload.get("ts")) + .and_then(Value::as_u64) + .ok_or_else(|| RequestFailure::transport("Gateway challenge timestamp was invalid."))?; + nonce + .map(|nonce| ConnectChallenge { + nonce: nonce.to_owned(), + issued_at_ms, + }) + .ok_or_else(|| RequestFailure::transport("Gateway challenge omitted nonce.")) +} + +async fn wait_for_connect_challenge( + socket: &mut GatewaySocket, +) -> Result { tokio::time::timeout(HANDSHAKE_TIMEOUT, async { loop { let value = next_json(socket).await?; if value.get("type").and_then(Value::as_str) == Some("event") && value.get("event").and_then(Value::as_str) == Some("connect.challenge") { - let nonce = value - .get("payload") - .and_then(|payload| payload.get("nonce")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|nonce| !nonce.is_empty()); - return nonce - .map(ToOwned::to_owned) - .ok_or_else(|| RequestFailure::transport("Gateway challenge omitted nonce.")); + return parse_connect_challenge(&value); } } }) @@ -1470,13 +1489,6 @@ fn dispatch_chat_event(app: &AppHandle, frame: &Value) { } } -fn unix_time_ms() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .map_err(|error| format!("Could not read system time: {error}")) -} - #[cfg(test)] mod tests { use super::*; @@ -1665,6 +1677,34 @@ mod tests { std::fs::remove_dir_all(directory).expect("remove connect fixture"); } + #[test] + fn connect_challenge_uses_gateway_timestamp() { + let Ok(challenge) = parse_connect_challenge(&json!({ + "payload": { + "nonce": " fixture-nonce ", + "ts": 1_700_000_000_123_u64 + } + })) else { + panic!("expected valid challenge"); + }; + + assert_eq!( + challenge, + ConnectChallenge { + nonce: "fixture-nonce".to_string(), + issued_at_ms: 1_700_000_000_123, + } + ); + assert!(parse_connect_challenge(&json!({ + "payload": { "nonce": "missing-time" } + })) + .is_err()); + assert!(parse_connect_challenge(&json!({ + "payload": { "nonce": "fixture-nonce", "ts": "1700000000123" } + })) + .is_err()); + } + #[test] fn hello_tick_policy_sets_two_interval_watchdog() { let hello = validate_hello(json!({ diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index fb9cf67c4292..1f2564863f50 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -156,6 +156,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> actor GatewayWizardClient { private enum ConnectChallengeError: Error { + case invalid case timeout } @@ -271,14 +272,15 @@ actor GatewayWizardClient { } else if let password = self.password { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } - let connectNonce = try await self.waitForConnectChallenge() + let connectChallenge = try await self.waitForConnectChallenge() + let connectNonce = connectChallenge.nonce guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { throw NSError( domain: "OpenClawMacCLI", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not access the persisted device identity"]) } - let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + let signedAtMs = connectChallenge.issuedAtMs let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload( fields: .init( deviceId: identity.deviceId, @@ -320,7 +322,7 @@ actor GatewayWizardClient { } } - private func waitForConnectChallenge() async throws -> String { + private func waitForConnectChallenge() async throws -> GatewayConnectChallenge { guard let task = self.task else { throw ConnectChallengeError.timeout } return try await AsyncTimeout.withTimeout( seconds: self.connectChallengeTimeoutSeconds, @@ -329,11 +331,13 @@ actor GatewayWizardClient { while true { let message = try await task.receive() let frame = try await self.decodeFrame(message) - if case let .event(evt) = frame, evt.event == "connect.challenge", - let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = GatewayConnectChallengeSupport.nonce(from: payload) - { - return nonce + if case let .event(evt) = frame, evt.event == "connect.challenge" { + guard let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let challenge = GatewayConnectChallengeSupport.challenge(from: payload) + else { + throw ConnectChallengeError.invalid + } + return challenge } } }) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 735dd4c0f446..ca532d1de8ae 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -57,7 +57,7 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { if !sentChallenge { sentChallenge = true return .string(""" - {"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}} + {"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce","ts":1777777777000}} """) } if let request = latestUnrespondedRequest() { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift index 26db9e86869b..dbbbe1475224 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -9,12 +9,15 @@ extension WebSocketTasking { } enum GatewayWebSocketTestSupport { - static func connectChallengeData(nonce: String = "test-nonce") -> Data { + static func connectChallengeData( + nonce: String = "test-nonce", + ts: Int64 = 1_800_000_000_000) -> Data + { let json = """ { "type": "event", "event": "connect.challenge", - "payload": { "nonce": "\(nonce)" } + "payload": { "nonce": "\(nonce)", "ts": \(ts) } } """ return Data(json.utf8) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 146bfa37f47f..1301815409e5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -510,8 +510,9 @@ public actor GatewayChannelActor { deviceId: identity?.deviceId, connectionGeneration: connectionGeneration, to: ¶ms) - let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) - let connectNonce = try await self.waitForConnectChallenge(task: task, attemptID: attemptID) + let connectChallenge = try await self.waitForConnectChallenge(task: task, attemptID: attemptID) + let signedAtMs = connectChallenge.issuedAtMs + let connectNonce = connectChallenge.nonce try self.ensureCurrentConnectAttempt(attemptID, task: task) try self.requireCurrentConnection(connectionGeneration) if includeDeviceIdentity, let identity { @@ -1146,7 +1147,10 @@ extension GatewayChannelActor { } } - private func waitForConnectChallenge(task: WebSocketTaskBox, attemptID: UUID) async throws -> String { + private func waitForConnectChallenge( + task: WebSocketTaskBox, + attemptID: UUID) async throws -> GatewayConnectChallenge + { try await AsyncTimeout.withTimeout( seconds: self.connectChallengeTimeoutSeconds, onTimeout: { ConnectChallengeError.timeout }, @@ -1157,11 +1161,13 @@ extension GatewayChannelActor { try await self.ensureCurrentConnectAttempt(attemptID, task: task) guard let data = self.decodeMessageData(msg) else { continue } guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge", - let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = GatewayConnectChallengeSupport.nonce(from: payload) - { - return nonce + if case let .event(evt) = frame, evt.event == "connect.challenge" { + guard let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let challenge = GatewayConnectChallengeSupport.challenge(from: payload) + else { + throw ConnectChallengeError.invalid + } + return challenge } } }) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift index 40da9a188fc0..a1fad4fb51aa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift @@ -44,6 +44,7 @@ final class GatewayRequestCancellationGate: @unchecked Sendable { extension GatewayChannelActor { enum ConnectChallengeError: Error { + case invalid case timeout } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift index 866e6b544ed9..b30ffb9fd275 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift @@ -1,11 +1,40 @@ import Foundation import OpenClawProtocol +public struct GatewayConnectChallenge: Sendable, Equatable { + public let nonce: String + public let issuedAtMs: Int64 + + public init(nonce: String, issuedAtMs: Int64) { + self.nonce = nonce + self.issuedAtMs = issuedAtMs + } +} + public enum GatewayConnectChallengeSupport { - public static func nonce(from payload: [String: OpenClawProtocol.AnyCodable]?) -> String? { + public static func challenge( + from payload: [String: OpenClawProtocol.AnyCodable]?) -> GatewayConnectChallenge? + { guard let nonce = payload?["nonce"]?.value as? String else { return nil } let trimmed = nonce.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - return trimmed + guard let rawTimestamp = payload?["ts"]?.value, + let issuedAtMs = self.integerMilliseconds(rawTimestamp), + issuedAtMs >= 0 + else { return nil } + return GatewayConnectChallenge(nonce: trimmed, issuedAtMs: issuedAtMs) + } + + private static func integerMilliseconds(_ value: Any?) -> Int64? { + switch value { + case let value as Int: + Int64(exactly: value) + case let value as Int64: + value + case let value as Double where value.isFinite && value.rounded() == value: + Int64(exactly: value) + default: + nil + } } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayConnectChallengeSupportTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayConnectChallengeSupportTests.swift new file mode 100644 index 000000000000..d52486a5d0fb --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayConnectChallengeSupportTests.swift @@ -0,0 +1,34 @@ +import Foundation +import OpenClawKit +import OpenClawProtocol +import Testing + +struct GatewayConnectChallengeSupportTests { + @Test func `parses gateway issued timestamp`() { + let challenge = GatewayConnectChallengeSupport.challenge(from: [ + "nonce": AnyCodable(" nonce-1 "), + "ts": AnyCodable(1_700_000_000_123), + ]) + + #expect(challenge == GatewayConnectChallenge( + nonce: "nonce-1", + issuedAtMs: 1_700_000_000_123)) + } + + @Test func `rejects malformed challenge`() { + let payloads: [[String: AnyCodable]] = [ + ["nonce": AnyCodable("nonce-1"), "ts": AnyCodable("1700000000123")], + ["nonce": AnyCodable("nonce-1"), "ts": AnyCodable(-1)], + ["nonce": AnyCodable(" "), "ts": AnyCodable(1_700_000_000_123)], + ] + for payload in payloads { + #expect(GatewayConnectChallengeSupport.challenge(from: payload) == nil) + } + } + + @Test func `rejects challenge without timestamp`() { + #expect(GatewayConnectChallengeSupport.challenge(from: [ + "nonce": AnyCodable("nonce-1"), + ]) == nil) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 646888f9f0b9..799d9b1ae673 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -397,7 +397,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda let frame: [String: Any] = [ "type": "event", "event": "connect.challenge", - "payload": ["nonce": nonce], + "payload": ["nonce": nonce, "ts": 1_800_000_000_000], ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index 82bd0c129e47..0369b63f4108 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -65,9 +65,12 @@ gateway` or the `openclaw onboard --gateway-auth ...` options, then let device pairing mint the client token: 1. Persist an Ed25519 device identity in the client. -2. Wait for `connect.challenge`, sign the challenge-bound device payload, and send - `connect` with the requested operator role, scopes, and the shared Gateway token - or password for bootstrap authentication. +2. Wait for `connect.challenge`, use its `ts` as the device proof's `signedAt`, + sign the challenge-bound device payload, and send `connect` with the requested + operator role, scopes, and the shared Gateway token or password for bootstrap + authentication. A received WebSocket challenge without a non-negative integer + `ts` is invalid. Clients that explicitly support Gateways from before + `connect.challenge` existed may use local time only on their no-challenge path. 3. If the Gateway returns structured `PAIRING_REQUIRED` details, show the request ID and pause or retry according to `error.details.recommendedNextStep`. 4. On the Gateway host, review the request with `openclaw devices list`, then diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 15c26dd42d58..7723fa50359c 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -92,6 +92,12 @@ Gateway sends a pre-connect challenge: } ``` +Device-auth clients use the challenge `ts` as `connect.params.device.signedAt`. +For WebSocket challenges, `ts` must be a non-negative integer. Clients that +explicitly support Gateways from before `connect.challenge` existed may use local +time only when no challenge arrives; a received challenge with an absent or +malformed `ts` is invalid. + Client replies with `connect`: ```json @@ -1165,6 +1171,7 @@ Common migration failures: Migration target: - Always wait for `connect.challenge`. +- Use `connect.challenge.payload.ts` as `connect.params.device.signedAt`. - Sign the v2 payload that includes the server nonce. - Send the same nonce in `connect.params.device.nonce`. - Preferred signature payload is `v3` diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.js b/extensions/browser/chrome-extension/modules/copilot-gateway.js index f56bebfc4d35..fb1fa0b077be 100644 --- a/extensions/browser/chrome-extension/modules/copilot-gateway.js +++ b/extensions/browser/chrome-extension/modules/copilot-gateway.js @@ -158,7 +158,7 @@ export class CopilotGatewayClient { const protocol = new GatewayProtocolClient({ createSocket: (handlers) => createBrowserSocket(gatewayScope, handlers, this.WebSocketImpl), createRequestId: () => crypto.randomUUID(), - buildConnectPlan: ({ nonce }) => + buildConnectPlan: ({ nonce, challengeTs }) => lifecycle.buildPlan({ client: { id: CLIENT_ID, @@ -170,6 +170,7 @@ export class CopilotGatewayClient { role: ROLE, defaultScopes: SCOPES, nonce, + challengeTs, }), buildConnectParams: (plan) => ({ minProtocol: MIN_CLIENT_PROTOCOL_VERSION, diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts b/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts index 0ceeb6f754b3..d6fc770bd659 100644 --- a/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts +++ b/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts @@ -273,7 +273,7 @@ describe("browser copilot Gateway custody", () => { first?.message({ type: "event", event: "connect.challenge", - payload: { nonce: "first-nonce" }, + payload: { nonce: "first-nonce", ts: 1_777_777_777_000 }, }); await vi.waitFor(() => expect(first?.sent).toHaveLength(1)); const firstConnect = first?.sent[0] as { @@ -303,7 +303,7 @@ describe("browser copilot Gateway custody", () => { second?.message({ type: "event", event: "connect.challenge", - payload: { nonce: "second-nonce" }, + payload: { nonce: "second-nonce", ts: 1_777_777_778_000 }, }); await vi.waitFor(() => expect(second?.sent).toHaveLength(1)); const secondConnect = second?.sent[0] as { params?: { auth?: { token?: string } } }; @@ -351,6 +351,37 @@ describe("browser copilot Gateway custody", () => { } }); + it("rejects a device challenge with a malformed Gateway timestamp", async () => { + FakeWebSocket.instances = []; + vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); + vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); + const client = new CopilotGatewayClient({ + storage: storageArea(), + WebSocketImpl: FakeWebSocket as never, + }); + + try { + client.start("ws://127.0.0.1:28789/"); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + const socket = FakeWebSocket.instances[0]; + socket?.message({ + type: "event", + event: "connect.challenge", + payload: { nonce: "invalid-time", ts: "not-a-number" }, + }); + await vi.waitFor(() => + expect(socket?.closeCalls).toContainEqual({ + code: 4008, + reason: "connect failed", + }), + ); + expect(socket?.sent).toHaveLength(0); + } finally { + client.stop(); + vi.unstubAllGlobals(); + } + }); + it("closes and reconnects when the browser socket never opens", async () => { vi.useFakeTimers(); FakeWebSocket.instances = []; diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.js b/extensions/browser/chrome-extension/modules/copilot-runtime.js index c758a6518e54..847f54416cdd 100644 --- a/extensions/browser/chrome-extension/modules/copilot-runtime.js +++ b/extensions/browser/chrome-extension/modules/copilot-runtime.js @@ -1 +1 @@ -function normalizeDeviceMetadataForAuth(value){if(typeof value!="string")return"";let trimmed=value.trim();return trimmed?trimmed.replace(/[A-Z]/g,char=>String.fromCharCode(char.charCodeAt(0)+32)):""}function buildDeviceAuthPayloadV3(params){let scopes=params.scopes.join(","),token=params.token??"",platform=normalizeDeviceMetadataForAuth(params.platform),deviceFamily=normalizeDeviceMetadataForAuth(params.deviceFamily);return["v3",params.deviceId,params.clientId,params.clientMode,params.role,scopes,String(params.signedAtMs),token,params.nonce,platform,deviceFamily].join("|")}function normalized(value){return typeof value=="string"&&value.trim()||void 0}function selectGatewayConnectAuth(params){let authToken=normalized(params.token),bootstrapToken=normalized(params.bootstrapToken),explicitDeviceToken=normalized(params.deviceToken),authPassword=normalized(params.password),storedToken=normalized(params.storedToken),stored={storedToken,storedScopes:params.storedScopes};if(params.preferBootstrapToken&&bootstrapToken)return{authBootstrapToken:bootstrapToken,authPassword,...stored};let useRetryToken=params.pendingDeviceTokenRetry===!0&&!explicitDeviceToken&&!!(authToken&&storedToken&¶ms.trustedDeviceTokenRetry),resolvedDeviceToken=explicitDeviceToken??(useRetryToken||!(authToken||authPassword)&&(!bootstrapToken||storedToken)?storedToken:void 0),usingStoredDeviceToken=!!(resolvedDeviceToken&&!explicitDeviceToken&&storedToken)&&resolvedDeviceToken===storedToken,selectedToken=authToken??resolvedDeviceToken,authBootstrapToken=!authToken&&!resolvedDeviceToken&&!authPassword?bootstrapToken:void 0;return{authToken:selectedToken,authBootstrapToken,authDeviceToken:useRetryToken?storedToken:void 0,authPassword,authApprovalRuntimeToken:normalized(params.approvalRuntimeToken),authAgentRuntimeIdentityToken:normalized(params.agentRuntimeIdentityToken),signatureToken:selectedToken??authBootstrapToken,resolvedDeviceToken,usingStoredDeviceToken,...stored}}function buildGatewayConnectAuth(selected){let auth={token:selected.authToken,bootstrapToken:selected.authBootstrapToken,deviceToken:selected.authDeviceToken??selected.resolvedDeviceToken,password:selected.authPassword,approvalRuntimeToken:selected.authApprovalRuntimeToken,agentRuntimeIdentityToken:selected.authAgentRuntimeIdentityToken};return Object.values(auth).some(Boolean)?auth:void 0}function resolveGatewayConnectScopes(params){return params.requestedScopes??(params.usingStoredDeviceToken&¶ms.storedScopes?.length?params.storedScopes:[...params.defaultScopes])}var GatewayBrowserDeviceAuthLifecycle=class{constructor(deps){this.deps=deps}async buildPlan(params){let identity=await this.deps.loadIdentity(),stored=identity?await this.deps.tokenStore.load({clientId:params.client.id,deviceId:identity.deviceId,role:params.role}):null,storedValue=stored?.token,selectedAuth=selectGatewayConnectAuth({token:params.token,bootstrapToken:params.bootstrapToken,password:params.password,storedToken:storedValue,storedScopes:stored?.scopes,pendingDeviceTokenRetry:params.pendingDeviceTokenRetry,trustedDeviceTokenRetry:params.trustedDeviceTokenRetry,preferBootstrapToken:params.preferBootstrapToken}),{usingStoredDeviceToken}=selectedAuth,scopes=resolveGatewayConnectScopes({requestedScopes:selectedAuth.authBootstrapToken&¶ms.bootstrapScopes?[...params.bootstrapScopes]:void 0,usingStoredDeviceToken,storedScopes:selectedAuth.storedScopes,defaultScopes:params.defaultScopes});if(!identity)return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth)};let signedAtMs=this.deps.nowMs?.()??Date.now(),nonce=params.nonce??"",{authBootstrapToken:primary,signatureToken:signed}=selectedAuth,token=null;primary?token=primary:signed&&(token=signed);let payload=buildDeviceAuthPayloadV3({deviceId:identity.deviceId,clientId:params.client.id,clientMode:params.client.mode,role:params.role,scopes,signedAtMs,token,nonce,platform:params.client.platform,deviceFamily:params.client.deviceFamily});return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth),device:{id:identity.deviceId,publicKey:identity.publicKey,signature:await identity.sign(payload),signedAt:signedAtMs,nonce}}}async acceptHello(hello,plan){let token=hello.auth?.deviceToken?.trim();!token||!plan.identity||await this.deps.tokenStore.store({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:hello.auth?.role??plan.role,token,scopes:hello.auth?.scopes??[]})}async clearStoredToken(plan){plan.identity&&await this.deps.tokenStore.clear({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:plan.role})}};function isRecord(value){return!!value&&typeof value=="object"&&!Array.isArray(value)}function isNonEmptyString(value){return typeof value=="string"&&value.length>0}function isNonNegativeInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=0}function isGatewayErrorShape(value){return!isRecord(value)||!isNonEmptyString(value.code)||!isNonEmptyString(value.message)||value.retryable!==void 0&&typeof value.retryable!="boolean"?!1:value.retryAfterMs===void 0||isNonNegativeInteger(value.retryAfterMs)}function isGatewayEventFrame(value){return!isRecord(value)||value.type!=="event"||!isNonEmptyString(value.event)?!1:value.seq===void 0||isNonNegativeInteger(value.seq)}function isGatewayResponseFrame(value){return!isRecord(value)||value.type!=="res"||!isNonEmptyString(value.id)||typeof value.ok!="boolean"?!1:value.error===void 0||isGatewayErrorShape(value.error)}function computeBackoff(policy,attempt){let base=Math.min(policy.maxMs,policy.initialMs*policy.factor**Math.max(attempt-1,0)),jitter=base*policy.jitter*Math.random();return Math.min(policy.maxMs,Math.round(base+jitter))}async function sleepWithAbort(ms,abortSignal,options={}){if(!Number.isFinite(ms)||ms<=0)return;let delayMs=Math.min(Math.max(Math.floor(ms),1),2147e6);await new Promise((resolve,reject)=>{let settled=!1,timer=null,cleanup=()=>abortSignal?.removeEventListener("abort",onAbort),onAbort=()=>{settled||(settled=!0,timer&&clearTimeout(timer),timer=null,cleanup(),reject(new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")})))};if(abortSignal?.addEventListener("abort",onAbort,{once:!0}),abortSignal?.aborted){onAbort();return}timer=setTimeout(()=>{settled=!0,cleanup(),timer=null,resolve()},delayMs),options.ref===!1&&timer.unref?.(),abortSignal?.aborted&&onAbort()})}var RetrySupervisor=class{constructor(policy,maxAttempts=Number.POSITIVE_INFINITY){this.policy=policy;this.maxAttempts=maxAttempts;this.attempts=0;this.initialMs=policy.initialMs}reset(initialMs=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=initialMs,this.nextDelayOverrideMs=void 0}cancel(reason=new Error("retry cancelled")){this.pendingAbort?.abort(reason),this.pendingAbort=void 0}next(abortSignal){let override=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,override===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let attempt=Math.max(this.attempts,1),delayMs=override??computeBackoff({...this.policy,initialMs:this.initialMs},attempt);this.cancel();let pendingAbort=new AbortController;return this.pendingAbort=pendingAbort,{attempt,delayMs,signal:abortSignal?AbortSignal.any([pendingAbort.signal,abortSignal]):pendingAbort.signal}}},DEFAULT_RETRY_CONFIG={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},defaultSleep=ms=>new Promise(resolve=>{setTimeout(resolve,ms)});function asFiniteNumber(value){return typeof value=="number"&&Number.isFinite(value)?value:void 0}function clampNumber(value,fallback,min,max){let next=asFiniteNumber(value);return next===void 0?fallback:Math.min(Math.max(next,min??Number.NEGATIVE_INFINITY),max??Number.POSITIVE_INFINITY)}function resolveAttemptCount(value,fallback){return Math.max(1,Math.round(asFiniteNumber(value)??fallback))}function resolveRetryDelayMs(value){let finite=value===Number.POSITIVE_INFINITY?2147e6:asFiniteNumber(value)??0;return Math.min(Math.max(Math.round(finite),0),2147e6)}function resolveJitterConfig(value,fallback){if(value==="full")return"full";let fraction=asFiniteNumber(value);return fraction===void 0?fallback:Math.min(Math.max(fraction,0),1)}function resolveRetryConfig(defaults=DEFAULT_RETRY_CONFIG,overrides){let attempts=resolveAttemptCount(overrides?.attempts,defaults.attempts),minDelayMs=resolveRetryDelayMs(clampNumber(overrides?.minDelayMs,defaults.minDelayMs,0)),maxDelayMs=Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(overrides?.maxDelayMs,defaults.maxDelayMs,0)));return{attempts,minDelayMs,maxDelayMs,jitter:resolveJitterConfig(overrides?.jitter,defaults.jitter)}}function applyJitter(delayMs,jitter,mode,random){if(jitter==="full")return mode==="symmetric"?Math.max(0,Math.round(delayMs*(.5+random()*.5))):Math.max(0,Math.ceil(delayMs*(1+random())));if(jitter<=0)return mode==="positive"?Math.ceil(delayMs):delayMs;let fraction=random(),offset=mode==="positive"?fraction*jitter:(fraction*2-1)*jitter,raw=delayMs*(1+offset);return Math.max(0,mode==="positive"?Math.ceil(raw):Math.round(raw))}function toRetryError(value,fallbackMessage="Non-Error thrown"){if(value instanceof Error)return value;if(typeof value=="string")return new Error(value);let error=new Error(fallbackMessage,{cause:value});return(typeof value=="object"&&value!==null||typeof value=="function")&&Object.assign(error,value),error}function createRetryRunner(runtime={}){let runtimeSleep=runtime.sleep??defaultSleep,runtimeRandom=runtime.random??Math.random,createFailure=runtime.createFailure??(errors=>toRetryError(errors.at(-1)??new Error("Retry failed")));return async function(fn,attemptsOrOptions=3,initialDelayMs=300){let attemptErrors=[];if(typeof attemptsOrOptions=="number"){let attempts=resolveAttemptCount(attemptsOrOptions,DEFAULT_RETRY_CONFIG.attempts);for(let index=0;index0?resolved.maxDelayMs:Number.POSITIVE_INFINITY,retryAfterMaxDelayMs=options.retryAfterMaxDelayMs===void 0?maxDelayMs:Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(options.retryAfterMaxDelayMs,maxDelayMs,0))),random=options.random??runtimeRandom,sleep=options.sleep??runtimeSleep,shouldRetry=options.shouldRetry??(()=>!0);for(let attempt=1;attempt<=maxAttempts;attempt+=1)try{return await fn()}catch(err2){if(attemptErrors.push(err2),attempt>=maxAttempts||!shouldRetry(err2,attempt))break;let context={attempt,maxAttempts,err:err2,label:options.label},retryAfterMs=options.retryAfterMs?.(err2),hasRetryAfter=typeof retryAfterMs=="number"&&Number.isFinite(retryAfterMs),configuredDelay=typeof options.delayMs=="function"?options.delayMs(context):options.delayMs,resolvedConfiguredDelay=configuredDelay===void 0?void 0:resolveRetryDelayMs(configuredDelay),baseDelay=hasRetryAfter?Math.max(retryAfterMs,minDelayMs):resolvedConfiguredDelay===void 0?minDelayMs*2**(attempt-1):Math.max(resolvedConfiguredDelay,minDelayMs),delayCap=hasRetryAfter?retryAfterMaxDelayMs:maxDelayMs,delay=Math.min(baseDelay,delayCap),canHonorRetryAfter=hasRetryAfter&&(retryAfterMs??0)<=delayCap,wantsPositiveDraw=resolved.jitter==="full"&&!hasRetryAfter||canHonorRetryAfter;delay=applyJitter(delay,resolved.jitter,wantsPositiveDraw?"positive":"symmetric",random),delay=Math.min(Math.max(delay,minDelayMs),delayCap),await options.onRetry?.({...context,delayMs:delay}),delay>0&&await sleep(delay)}throw createFailure(attemptErrors)}}var retryAsync=createRetryRunner();var GatewayEventListeners=class{constructor(){this.listeners=new Map}add(listener){let subscription=this.listeners.get(listener)??{};return this.listeners.set(listener,subscription),()=>{this.listeners.get(listener)===subscription&&this.listeners.delete(listener)}}snapshot(){return[...this.listeners]}isCurrent(listener,subscription){return this.listeners.get(listener)===subscription}};var DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS=15e3;function startGatewayConnectTimeout(onTimeout){let timer=setTimeout(onTimeout,DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);return timer.unref?.(),timer}function clearGatewayConnectTimeout(timer){return timer!==null&&clearTimeout(timer),null}var GatewayProtocolRequestError=class extends Error{constructor(error){super(error.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=error.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=error.details,this.retryable=error.retryable===!0,this.retryAfterMs=error.retryAfterMs}},GatewayProtocolClient=class{constructor(opts){this.opts=opts;this.socket=null;this.pending=new Map;this.listeners=new GatewayEventListeners;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.reconnectSignal=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new RetrySupervisor({initialMs:opts.reconnect.initialMs,maxMs:opts.reconnect.maxMs,factor:opts.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(pending=>pending.unbounded)}start(){this.socket||this.reconnectSignal||(this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect())}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSignal=null,this.reconnectSupervisor.reset();let socket=this.socket;socket&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),socket?.close()}request(method,params,options){let socket=this.socket;if(!socket?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof method!="string"||method.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let id=this.opts.createRequestId(),timeoutMs=options?.timeoutMs===null?void 0:options?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((resolve,reject)=>{let timeout,pending={resolve:value=>resolve(value),reject,expectFinal:options?.expectFinal===!0,acceptedNotified:!1,onAccepted:options?.onAccepted,unbounded:timeoutMs===void 0,method,startedAtMs:this.nowMs()},onAbort=()=>{this.pending.delete(id),timeout&&clearTimeout(timeout),this.finishRequestTiming(id,pending,!1,"CLIENT_ABORTED"),reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`))},cleanup=()=>{timeout&&clearTimeout(timeout),options?.signal?.removeEventListener("abort",onAbort)};if(options?.signal?.aborted){reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`));return}pending.cleanup=cleanup,timeoutMs!==void 0&&timeoutMs>=0&&(timeout=setTimeout(()=>{this.pending.delete(id),options?.signal?.removeEventListener("abort",onAbort),this.finishRequestTiming(id,pending,!1,"CLIENT_TIMEOUT"),reject(this.opts.createRequestTimeoutError?.(method,timeoutMs)??new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`))},timeoutMs),timeout.unref?.()),options?.signal?.addEventListener("abort",onAbort,{once:!0}),this.pending.set(id,pending);try{socket.send(JSON.stringify({type:"req",id,method,params})),this.invoke("sent",()=>options?.onSent?.())}catch(error){this.pending.delete(id),cleanup(),this.finishRequestTiming(id,pending,!1,"CLIENT_SEND_ERROR"),reject(error instanceof Error?error:new Error(String(error)))}})}addEventListener(listener){return this.listeners.add(listener)}closeSocket(code,reason){this.socket?.close(code,reason)}resetReconnectBackoff(initialMs){this.reconnectSignal=null,this.reconnectSupervisor.reset(initialMs)}recordTiming(phase,generation,plan,detail){let now=this.nowMs(),state=this.connectTiming;!state||state.generation!==generation||(state.hasChallenge||=phase==="challenge",state.usedFallback||=phase==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase,generation,durationMs:Math.max(0,now-state.startedAtMs),phaseDurationMs:Math.max(0,now-state.lastAtMs),hasChallenge:state.hasChallenge,usedFallback:state.usedFallback,plan,detail})),state.lastAtMs=now,(phase==="hello"||phase==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let generation=this.generation+1;this.lastSeq=null,this.connectNonce=null,this.connectSent=this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let socket;try{socket=this.opts.createSocket({open:()=>this.handleOpen(socket,generation),message:data=>this.handleMessage(socket,generation,data),close:(code,reason)=>this.handleClose(socket,generation,code,reason),error:error=>this.handleSocketError(socket,generation,error)})}catch(error){let normalized2=error instanceof Error?error:new Error(String(error));if(this.opts.onSocketFactoryError?.(normalized2),this.opts.onConnectError?.(normalized2),this.opts.rethrowSocketFactoryError?.(normalized2))throw normalized2;this.opts.shouldRetrySocketFactoryError?.(normalized2)&&!this.stopped&&!this.socket&&!this.reconnectSignal&&this.scheduleReconnect();return}this.generation=generation,this.socket=socket;let now=this.nowMs();this.connectTiming={generation,startedAtMs:now,lastAtMs:now,hasChallenge:!1,usedFallback:!1}}handleOpen(socket,generation){if(this.isActive(socket,generation)){if(this.socketOpened=!0,this.recordTiming("socket-open",generation),this.connectNonce){this.sendConnect(socket,generation);return}this.armHandshakeTimer(socket,generation)}}armHandshakeTimer(socket,generation){this.clearHandshakeTimer();let armedAt=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(socket,generation)||this.connectSent||!socket.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",generation),this.sendConnect(socket,generation);return}let elapsedMs=Date.now()-armedAt,error=new Error(this.opts.handshake.timeoutMessage?.(elapsedMs)??`gateway connect challenge timeout after ${elapsedMs}ms`);this.opts.onConnectError?.(error),socket.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(socket,generation){if(!this.isActive(socket,generation)||!socket.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer(),this.handshakeTimer=startGatewayConnectTimeout(()=>{this.isActive(socket,generation)&&!this.helloReceived&&socket.close(4e3,"connect timeout")});let planOrPromise;try{planOrPromise=this.opts.buildConnectPlan({nonce:this.connectNonce,generation})}catch(error){this.handleConnectPlanError(socket,generation,error);return}if(planOrPromise instanceof Promise){planOrPromise.then(plan=>this.sendConnectPlan(socket,generation,plan)).catch(error=>this.handleConnectPlanError(socket,generation,error));return}this.sendConnectPlan(socket,generation,planOrPromise)}handleConnectPlanError(socket,generation,error){if(!this.isActive(socket,generation))return;let normalized2=error instanceof Error?error:new Error(String(error)),outcome=this.opts.onConnectPlanError?.(normalized2)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(outcome.error??normalized2),outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)}sendConnectPlan(socket,generation,plan){if(!this.isActive(socket,generation)||!socket.isOpen())return;let context={generation,nonce:this.connectNonce,plan};this.recordTiming("connect-plan-ready",generation,plan),this.recordTiming("request-sent",generation,plan),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(plan)).then(hello=>{this.isActive(socket,generation)&&(this.helloReceived=!0,this.clearHandshakeTimer(),this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",generation,plan),this.opts.onConnectHello?.(hello,context),this.invoke("hello",()=>this.opts.onHello?.(hello)))}).catch(error=>{if(!this.isActive(socket,generation))return;let requestError=error instanceof GatewayProtocolRequestError?error:new GatewayProtocolRequestError({message:String(error)}),outcome=this.opts.onConnectFailure?.(requestError,context)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:requestError,reconnectDelayMs:outcome.reconnectDelayMs},outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)})}handleMessage(socket,generation,raw){if(!this.isActive(socket,generation))return;let parsed;try{parsed=JSON.parse(raw)}catch(error){this.opts.onParseError?.(error);return}if(isGatewayEventFrame(parsed)){if(this.opts.onActivity?.(),parsed.event==="connect.challenge"){let payload=parsed.payload,nonce=typeof payload?.nonce=="string"?payload.nonce.trim():"";if(!nonce){if(this.opts.handshake.mode==="require-challenge"){let error=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(error),socket.close(1008,"connect challenge missing nonce")}return}this.connectNonce=nonce,this.recordTiming("challenge",generation),this.sendConnect(socket,generation);return}let seq=typeof parsed.seq=="number"?parsed.seq:null;if(seq!==null){if(this.lastSeq!==null&&seq>this.lastSeq+1){let expected=this.lastSeq+1;if(this.invoke("gap",()=>this.opts.onGap?.({expected,received:seq})),!this.isActive(socket,generation))return}this.lastSeq=seq}let listeners=this.listeners.snapshot();this.invoke("event",()=>this.opts.onEvent?.(parsed));for(let[listener,subscription]of listeners){if(!this.isActive(socket,generation))return;this.listeners.isCurrent(listener,subscription)&&this.invoke("event listener",()=>listener(parsed))}return}isGatewayResponseFrame(parsed)&&(this.opts.onActivity?.(),this.handleResponse(parsed))}handleResponse(frame){let pending=this.pending.get(frame.id);if(!pending)return;let status=frame.payload?.status;if(pending.expectFinal&&status==="accepted"){pending.acceptedNotified||(pending.acceptedNotified=!0,this.invoke("accepted",()=>pending.onAccepted?.(frame.payload)));return}if(this.pending.delete(frame.id),pending.cleanup?.(),frame.ok){this.finishRequestTiming(frame.id,pending,!0),pending.resolve(frame.payload);return}this.finishRequestTiming(frame.id,pending,!1,frame.error?.code),pending.reject(this.opts.createRequestError?.(frame.error??{})??new GatewayProtocolRequestError(frame.error??{}))}handleClose(socket,generation,code,reason){if(this.socket!==socket){if(this.stoppedSocket?.socket===socket){let context2={...this.stoppedSocket.context,code,reason};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(context2,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let context={...this.closeContext(),code,reason,generation};this.connectFailure=void 0;let decision=this.opts.resolveClose(context);this.flushRequests(decision.pendingError??context.connectFailure?.error??new Error(`gateway closed (${code}): ${reason}`)),this.invoke("close",()=>this.opts.onClose?.(context,decision)),decision.retry&&!this.stopped&&this.scheduleReconnect(decision.reconnectDelayMs??context.connectFailure?.reconnectDelayMs)}handleSocketError(socket,generation,error){!this.isActive(socket,generation)||this.connectSent||this.opts.onConnectError?.(error)}flushRequests(error){for(let[id,pending]of this.pending)this.finishRequestTiming(id,pending,!1,"CLIENT_CLOSED"),pending.cleanup?.(),pending.reject(error);this.pending.clear()}finishRequestTiming(id,pending,ok,errorCode){let endedAtMs=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id,method:pending.method,ok,durationMs:Math.max(0,endedAtMs-pending.startedAtMs),startedAtMs:pending.startedAtMs,endedAtMs,errorCode}))}scheduleReconnect(overrideMs){overrideMs!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=overrideMs);let retry=this.reconnectSupervisor.next();retry&&(this.reconnectSignal=retry.signal,sleepWithAbort(retry.delayMs,retry.signal).then(()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null,this.connect())},()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null)}))}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(socket,generation){return!this.stopped&&this.socket===socket&&this.generation===generation}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer=clearGatewayConnectTimeout(this.handshakeTimer)}invoke(label,callback){try{callback()}catch(error){this.opts.onCallbackError?.(label,error)}}};var GATEWAY_CLIENT_IDS={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",LINUX_APP:"openclaw-linux",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var GATEWAY_CLIENT_MODES={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},GATEWAY_CLIENT_CAPS={AGENT_KIND:"agent-kind",APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},GATEWAY_CLIENT_ID_SET=new Set(Object.values(GATEWAY_CLIENT_IDS)),GATEWAY_CLIENT_MODE_SET=new Set(Object.values(GATEWAY_CLIENT_MODES));var PROTOCOL_VERSION=4,MIN_CLIENT_PROTOCOL_VERSION=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var ed25519_CURVE=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:P,n:N,Gx,Gy,a:_a,d:_d,h}=ed25519_CURVE,L=32,captureTrace=(...args)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...args)},err=(message="")=>{let e=new Error(message);throw captureTrace(e,err),e},isBig=n=>typeof n=="bigint",isStr=s=>typeof s=="string",isBytes=a=>a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in a&&a.BYTES_PER_ELEMENT===1,abytes=(value,length,title="")=>{let bytes=isBytes(value),len=value?.length,needsLen=length!==void 0;if(!bytes||needsLen&&len!==length){let prefix=title&&`"${title}" `,ofLen=needsLen?` of length ${length}`:"",got=bytes?`length=${len}`:`type=${typeof value}`,msg=prefix+"expected Uint8Array"+ofLen+", got "+got;throw bytes?new RangeError(msg):new TypeError(msg)}return value},u8n=len=>new Uint8Array(len),u8fr=buf=>Uint8Array.from(buf),padh=(n,pad)=>n.toString(16).padStart(pad,"0"),bytesToHex=b=>Array.from(abytes(b)).map(e=>padh(e,2)).join(""),C={_0:48,_9:57,A:65,F:70,a:97,f:102},_ch=ch=>{if(ch>=C._0&&ch<=C._9)return ch-C._0;if(ch>=C.A&&ch<=C.F)return ch-(C.A-10);if(ch>=C.a&&ch<=C.f)return ch-(C.a-10)},hexToBytes=hex=>{let e="hex invalid";if(!isStr(hex))return err(e);let hl=hex.length,al=hl/2;if(hl%2)return err(e);let array=u8n(al);for(let ai=0,hi=0;aiglobalThis?.crypto,subtle=()=>cr()?.subtle??err("crypto.subtle must be defined, consider polyfill"),concatBytes=(...arrs)=>{let len=0;for(let a of arrs)len+=abytes(a).length;let r=u8n(len),pad=0;return arrs.forEach(a=>{r.set(a,pad),pad+=a.length}),r},randomBytes=(len=L)=>cr().getRandomValues(u8n(len)),big=BigInt,assertRange=(n,min,max,msg="bad number: out of range")=>{if(!isBig(n))throw new TypeError(msg);if(min<=n&&n{let r=a%b;return r>=0n?r:b+r},P_MASK=(1n<<255n)-1n,modP=num=>{num<0n&&err("negative coordinate");let r=(num>>255n)*19n+(num&P_MASK);return r=(r>>255n)*19n+(r&P_MASK),r%P},modN=a=>M(a,N),invert=(num,md)=>{(num===0n||md<=0n)&&err("no inverse n="+num+" mod="+md);let a=M(num,md),b=md,x=0n,y=1n,u=1n,v=0n;for(;a!==0n;){let q=b/a,r=b%a,m=x-u*q,n=y-v*q;b=a,a=r,x=u,y=v,u=m,v=n}return b===1n?M(x,md):err("no inverse")},callHash=name=>{let fn=hashes[name];return typeof fn!="function"&&err("hashes."+name+" not set"),fn},checkDigest=value=>abytes(value,64,"digest");var apoint=p=>p instanceof Point?p:err("Point expected"),B256=2n**256n,Point=class _Point{static BASE;static ZERO;X;Y;Z;T;constructor(X,Y,Z,T){let max=B256;this.X=assertRange(X,0n,max),this.Y=assertRange(Y,0n,max),this.Z=assertRange(Z,1n,max),this.T=assertRange(T,0n,max),Object.freeze(this)}static CURVE(){return ed25519_CURVE}static fromAffine(p){return new _Point(p.x,p.y,1n,modP(p.x*p.y))}static fromBytes(hex,zip215=!1){let d=_d,normed=u8fr(abytes(hex,L)),lastByte=hex[31];normed[31]=lastByte&-129;let y=bytesToNumberLE(normed);assertRange(y,0n,zip215?B256:P);let y2=modP(y*y),u=M(y2-1n),v=modP(d*y2+1n),{isValid,value:x}=uvRatio(u,v);isValid||err("bad point: y not sqrt");let isXOdd=(x&1n)===1n,isLastByteOdd=(lastByte&128)!==0;return!zip215&&x===0n&&isLastByteOdd&&err("bad point: x==0, isLastByteOdd"),isLastByteOdd!==isXOdd&&(x=M(-x)),new _Point(x,y,1n,modP(x*y))}static fromHex(hex,zip215){return _Point.fromBytes(hexToBytes(hex),zip215)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let a=_a,d=_d,p=this;if(p.is0())return err("bad point: ZERO");let{X,Y,Z,T}=p,X2=modP(X*X),Y2=modP(Y*Y),Z2=modP(Z*Z),Z4=modP(Z2*Z2),aX2=modP(X2*a),left=modP(Z2*(aX2+Y2)),right=M(Z4+modP(d*modP(X2*Y2)));if(left!==right)return err("bad point: equation left != right (1)");let XY=modP(X*Y),ZT=modP(Z*T);return XY!==ZT?err("bad point: equation left != right (2)"):this}equals(other){let{X:X1,Y:Y1,Z:Z1}=this,{X:X2,Y:Y2,Z:Z2}=apoint(other),X1Z2=modP(X1*Z2),X2Z1=modP(X2*Z1),Y1Z2=modP(Y1*Z2),Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(I)}negate(){return new _Point(M(-this.X),this.Y,this.Z,M(-this.T))}double(){let{X:X1,Y:Y1,Z:Z1}=this,a=_a,A=modP(X1*X1),B=modP(Y1*Y1),C2=modP(2n*Z1*Z1),D=modP(a*A),x1y1=M(X1+Y1),E=M(modP(x1y1*x1y1)-A-B),G2=M(D+B),F=M(G2-C2),H=M(D-B),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}add(other){let{X:X1,Y:Y1,Z:Z1,T:T1}=this,{X:X2,Y:Y2,Z:Z2,T:T2}=apoint(other),a=_a,d=_d,A=modP(X1*X2),B=modP(Y1*Y2),C2=modP(modP(T1*d)*T2),D=modP(Z1*Z2),E=M(modP(M(X1+Y1)*M(X2+Y2))-A-B),F=M(D-C2),G2=M(D+C2),H=M(B-modP(a*A)),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}subtract(other){return this.add(apoint(other).negate())}multiply(n,safe=!0){if(!safe&&n===0n||(assertRange(n,1n,N),!safe&&this.is0()))return I;if(n===1n)return this;if(this.equals(G))return wNAF(n).p;let p=I,f=G;for(let d=this;n>0n;d=d.double(),n>>=1n)n&1n?p=p.add(d):safe&&(f=f.add(d));return p}multiplyUnsafe(scalar){return this.multiply(scalar,!1)}toAffine(){let{X,Y,Z}=this;if(this.equals(I))return{x:0n,y:1n};let iz=invert(Z,P);modP(Z*iz)!==1n&&err("invalid inverse");let x=modP(X*iz),y=modP(Y*iz);return{x,y}}toBytes(){let{x,y}=this.toAffine(),b=numTo32bLE(y);return b[31]|=x&1n?128:0,b}toHex(){return bytesToHex(this.toBytes())}clearCofactor(){return this.multiply(big(h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let p=this.multiply(N/2n,!1).double();return N%2n&&(p=p.add(this)),p.is0()}},G=new Point(Gx,Gy,1n,M(Gx*Gy)),I=new Point(0n,1n,1n,0n);Point.BASE=G;Point.ZERO=I;var numTo32bLE=num=>hexToBytes(padh(assertRange(num,0n,B256),64)).reverse(),bytesToNumberLE=b=>big("0x"+bytesToHex(u8fr(abytes(b)).reverse())),pow2=(x,power)=>{let r=x;for(;power-- >0n;)r=modP(r*r);return r},pow_2_252_3=x=>{let x2=modP(x*x),b2=modP(x2*x),b4=modP(pow2(b2,2n)*b2),b5=modP(pow2(b4,1n)*x),b10=modP(pow2(b5,5n)*b5),b20=modP(pow2(b10,10n)*b10),b40=modP(pow2(b20,20n)*b20),b80=modP(pow2(b40,40n)*b40),b160=modP(pow2(b80,80n)*b80),b240=modP(pow2(b160,80n)*b80),b250=modP(pow2(b240,10n)*b10);return{pow_p_5_8:modP(pow2(b250,2n)*x),b2}},RM1=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,uvRatio=(u,v)=>{let v3=modP(v*modP(v*v)),v7=modP(modP(v3*v3)*v),pow=pow_2_252_3(modP(u*v7)).pow_p_5_8,x=modP(u*modP(v3*pow)),vx2=modP(v*modP(x*x)),root1=x,root2=modP(x*RM1),useRoot1=vx2===u,useRoot2=vx2===M(-u),noRoot=vx2===M(-u*RM1);return useRoot1&&(x=root1),(useRoot2||noRoot)&&(x=root2),(M(x)&1n)===1n&&(x=M(-x)),{isValid:useRoot1||useRoot2,value:x}},modL_LE=hash=>modN(bytesToNumberLE(hash)),sha512a=(...m)=>Promise.resolve(callHash("sha512Async")(concatBytes(...m))).then(checkDigest),sha512s=(...m)=>checkDigest(callHash("sha512")(concatBytes(...m))),hash2extK=hashed=>{let copy=u8fr(hashed),head=copy.slice(0,32);head[0]&=248,head[31]&=127,head[31]|=64;let prefix=copy.slice(32,64),scalar=modL_LE(head),point=G.multiply(scalar),pointBytes=point.toBytes();return{head,prefix,scalar,point,pointBytes}},getExtendedPublicKeyAsync=secretKey=>sha512a(abytes(secretKey,L)).then(hash2extK),getExtendedPublicKey=secretKey=>hash2extK(sha512s(abytes(secretKey,L))),getPublicKeyAsync=secretKey=>getExtendedPublicKeyAsync(secretKey).then(p=>p.pointBytes);var hashFinishA=res=>sha512a(res.hashable).then(res.finish);var _sign=(e,rBytes,msg)=>{let{pointBytes:P2,scalar:s}=e,r=modL_LE(rBytes),R=G.multiply(r).toBytes();return{hashable:concatBytes(R,P2,msg),finish:hashed=>{let S=modN(r+modL_LE(hashed)*s);return abytes(concatBytes(R,numTo32bLE(S)),64)}}},signAsync=async(message,secretKey)=>{let m=abytes(message),e=await getExtendedPublicKeyAsync(secretKey),rBytes=await sha512a(e.prefix,m);return hashFinishA(_sign(e,rBytes,m))};var hashes={sha512Async:async message=>{let s=subtle(),m=concatBytes(message);return u8n(await s.digest("SHA-512",m.buffer))},sha512:void 0},randomSecretKey=seed=>(seed=seed===void 0?randomBytes(L):seed,abytes(seed,L));var utils=Object.freeze({getExtendedPublicKeyAsync,getExtendedPublicKey,randomSecretKey}),W=8,scalarBits=256,pwindows=Math.ceil(scalarBits/W)+1,pwindowSize=2**(W-1),precompute=()=>{let points=[],p=G,b=p;for(let w=0;w{let n=p.negate();return cnd?n:p},wNAF=n=>{let comp=Gpows||(Gpows=precompute()),p=I,f=G,pow_2_w=2**W,maxNum=pow_2_w,mask=big(pow_2_w-1),shiftBy=big(W);for(let w=0;w>=shiftBy,wbits>pwindowSize&&(wbits-=maxNum,n+=1n);let off=w*pwindowSize,offF=off,offP=off+Math.abs(wbits)-1,isEven=w%2!==0,isNeg=wbits<0;wbits===0?f=f.add(ctneg(isEven,comp[offF])):p=p.add(ctneg(isNeg,comp[offP]))}return n!==0n&&err("invalid wnaf"),{p,f}};export{GATEWAY_CLIENT_CAPS,GATEWAY_CLIENT_IDS,GATEWAY_CLIENT_MODES,GatewayBrowserDeviceAuthLifecycle,GatewayProtocolClient,GatewayProtocolRequestError,MIN_CLIENT_PROTOCOL_VERSION,PROTOCOL_VERSION,utils as ed25519Utils,getPublicKeyAsync,signAsync}; +function normalizeDeviceMetadataForAuth(value){if(typeof value!="string")return"";let trimmed=value.trim();return trimmed?trimmed.replace(/[A-Z]/g,char=>String.fromCharCode(char.charCodeAt(0)+32)):""}function buildDeviceAuthPayloadV3(params){let scopes=params.scopes.join(","),token=params.token??"",platform=normalizeDeviceMetadataForAuth(params.platform),deviceFamily=normalizeDeviceMetadataForAuth(params.deviceFamily);return["v3",params.deviceId,params.clientId,params.clientMode,params.role,scopes,String(params.signedAtMs),token,params.nonce,platform,deviceFamily].join("|")}function normalized(value){return typeof value=="string"&&value.trim()||void 0}function selectGatewayConnectAuth(params){let authToken=normalized(params.token),bootstrapToken=normalized(params.bootstrapToken),explicitDeviceToken=normalized(params.deviceToken),authPassword=normalized(params.password),storedToken=normalized(params.storedToken),stored={storedToken,storedScopes:params.storedScopes};if(params.preferBootstrapToken&&bootstrapToken)return{authBootstrapToken:bootstrapToken,authPassword,...stored};let useRetryToken=params.pendingDeviceTokenRetry===!0&&!explicitDeviceToken&&!!(authToken&&storedToken&¶ms.trustedDeviceTokenRetry),resolvedDeviceToken=explicitDeviceToken??(useRetryToken||!(authToken||authPassword)&&(!bootstrapToken||storedToken)?storedToken:void 0),usingStoredDeviceToken=!!(resolvedDeviceToken&&!explicitDeviceToken&&storedToken)&&resolvedDeviceToken===storedToken,selectedToken=authToken??resolvedDeviceToken,authBootstrapToken=!authToken&&!resolvedDeviceToken&&!authPassword?bootstrapToken:void 0;return{authToken:selectedToken,authBootstrapToken,authDeviceToken:useRetryToken?storedToken:void 0,authPassword,authApprovalRuntimeToken:normalized(params.approvalRuntimeToken),authAgentRuntimeIdentityToken:normalized(params.agentRuntimeIdentityToken),signatureToken:selectedToken??authBootstrapToken,resolvedDeviceToken,usingStoredDeviceToken,...stored}}function buildGatewayConnectAuth(selected){let auth={token:selected.authToken,bootstrapToken:selected.authBootstrapToken,deviceToken:selected.authDeviceToken??selected.resolvedDeviceToken,password:selected.authPassword,approvalRuntimeToken:selected.authApprovalRuntimeToken,agentRuntimeIdentityToken:selected.authAgentRuntimeIdentityToken};return Object.values(auth).some(Boolean)?auth:void 0}function resolveGatewayConnectScopes(params){return params.requestedScopes??(params.usingStoredDeviceToken&¶ms.storedScopes?.length?params.storedScopes:[...params.defaultScopes])}var GatewayBrowserDeviceAuthLifecycle=class{constructor(deps){this.deps=deps}async buildPlan(params){let identity=await this.deps.loadIdentity(),stored=identity?await this.deps.tokenStore.load({clientId:params.client.id,deviceId:identity.deviceId,role:params.role}):null,storedValue=stored?.token,selectedAuth=selectGatewayConnectAuth({token:params.token,bootstrapToken:params.bootstrapToken,password:params.password,storedToken:storedValue,storedScopes:stored?.scopes,pendingDeviceTokenRetry:params.pendingDeviceTokenRetry,trustedDeviceTokenRetry:params.trustedDeviceTokenRetry,preferBootstrapToken:params.preferBootstrapToken}),{usingStoredDeviceToken}=selectedAuth,scopes=resolveGatewayConnectScopes({requestedScopes:selectedAuth.authBootstrapToken&¶ms.bootstrapScopes?[...params.bootstrapScopes]:void 0,usingStoredDeviceToken,storedScopes:selectedAuth.storedScopes,defaultScopes:params.defaultScopes});if(!identity)return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth)};let signedAtMs=params.challengeTs===void 0?this.deps.nowMs?.()??Date.now():params.challengeTs;if(typeof signedAtMs!="number"||!Number.isSafeInteger(signedAtMs)||signedAtMs<0)throw new Error("gateway connect challenge timestamp invalid");let nonce=params.nonce??"",{authBootstrapToken:primary,signatureToken:signed}=selectedAuth,token=null;primary?token=primary:signed&&(token=signed);let payload=buildDeviceAuthPayloadV3({deviceId:identity.deviceId,clientId:params.client.id,clientMode:params.client.mode,role:params.role,scopes,signedAtMs,token,nonce,platform:params.client.platform,deviceFamily:params.client.deviceFamily});return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth),device:{id:identity.deviceId,publicKey:identity.publicKey,signature:await identity.sign(payload),signedAt:signedAtMs,nonce}}}async acceptHello(hello,plan){let token=hello.auth?.deviceToken?.trim();!token||!plan.identity||await this.deps.tokenStore.store({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:hello.auth?.role??plan.role,token,scopes:hello.auth?.scopes??[]})}async clearStoredToken(plan){plan.identity&&await this.deps.tokenStore.clear({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:plan.role})}};function isRecord(value){return!!value&&typeof value=="object"&&!Array.isArray(value)}function isNonEmptyString(value){return typeof value=="string"&&value.length>0}function isNonNegativeInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=0}function isGatewayErrorShape(value){return!isRecord(value)||!isNonEmptyString(value.code)||!isNonEmptyString(value.message)||value.retryable!==void 0&&typeof value.retryable!="boolean"?!1:value.retryAfterMs===void 0||isNonNegativeInteger(value.retryAfterMs)}function isGatewayEventFrame(value){return!isRecord(value)||value.type!=="event"||!isNonEmptyString(value.event)?!1:value.seq===void 0||isNonNegativeInteger(value.seq)}function isGatewayResponseFrame(value){return!isRecord(value)||value.type!=="res"||!isNonEmptyString(value.id)||typeof value.ok!="boolean"?!1:value.error===void 0||isGatewayErrorShape(value.error)}function computeBackoff(policy,attempt){let base=Math.min(policy.maxMs,policy.initialMs*policy.factor**Math.max(attempt-1,0)),jitter=base*policy.jitter*Math.random();return Math.min(policy.maxMs,Math.round(base+jitter))}async function sleepWithAbort(ms,abortSignal,options={}){if(!Number.isFinite(ms)||ms<=0)return;let delayMs=Math.min(Math.max(Math.floor(ms),1),2147e6);await new Promise((resolve,reject)=>{let settled=!1,timer=null,cleanup=()=>abortSignal?.removeEventListener("abort",onAbort),onAbort=()=>{settled||(settled=!0,timer&&clearTimeout(timer),timer=null,cleanup(),reject(new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")})))};if(abortSignal?.addEventListener("abort",onAbort,{once:!0}),abortSignal?.aborted){onAbort();return}timer=setTimeout(()=>{settled=!0,cleanup(),timer=null,resolve()},delayMs),options.ref===!1&&timer.unref?.(),abortSignal?.aborted&&onAbort()})}var RetrySupervisor=class{constructor(policy,maxAttempts=Number.POSITIVE_INFINITY){this.policy=policy;this.maxAttempts=maxAttempts;this.attempts=0;this.initialMs=policy.initialMs}reset(initialMs=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=initialMs,this.nextDelayOverrideMs=void 0}cancel(reason=new Error("retry cancelled")){this.pendingAbort?.abort(reason),this.pendingAbort=void 0}next(abortSignal){let override=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,override===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let attempt=Math.max(this.attempts,1),delayMs=override??computeBackoff({...this.policy,initialMs:this.initialMs},attempt);this.cancel();let pendingAbort=new AbortController;return this.pendingAbort=pendingAbort,{attempt,delayMs,signal:abortSignal?AbortSignal.any([pendingAbort.signal,abortSignal]):pendingAbort.signal}}},DEFAULT_RETRY_CONFIG={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},defaultSleep=ms=>new Promise(resolve=>{setTimeout(resolve,ms)});function asFiniteNumber(value){return typeof value=="number"&&Number.isFinite(value)?value:void 0}function clampNumber(value,fallback,min,max){let next=asFiniteNumber(value);return next===void 0?fallback:Math.min(Math.max(next,min??Number.NEGATIVE_INFINITY),max??Number.POSITIVE_INFINITY)}function resolveAttemptCount(value,fallback){return Math.max(1,Math.round(asFiniteNumber(value)??fallback))}function resolveRetryDelayMs(value){let finite=value===Number.POSITIVE_INFINITY?2147e6:asFiniteNumber(value)??0;return Math.min(Math.max(Math.round(finite),0),2147e6)}function resolveJitterConfig(value,fallback){if(value==="full")return"full";let fraction=asFiniteNumber(value);return fraction===void 0?fallback:Math.min(Math.max(fraction,0),1)}function resolveRetryConfig(defaults=DEFAULT_RETRY_CONFIG,overrides){let attempts=resolveAttemptCount(overrides?.attempts,defaults.attempts),minDelayMs=resolveRetryDelayMs(clampNumber(overrides?.minDelayMs,defaults.minDelayMs,0)),maxDelayMs=Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(overrides?.maxDelayMs,defaults.maxDelayMs,0)));return{attempts,minDelayMs,maxDelayMs,jitter:resolveJitterConfig(overrides?.jitter,defaults.jitter)}}function applyJitter(delayMs,jitter,mode,random){if(jitter==="full")return mode==="symmetric"?Math.max(0,Math.round(delayMs*(.5+random()*.5))):Math.max(0,Math.ceil(delayMs*(1+random())));if(jitter<=0)return mode==="positive"?Math.ceil(delayMs):delayMs;let fraction=random(),offset=mode==="positive"?fraction*jitter:(fraction*2-1)*jitter,raw=delayMs*(1+offset);return Math.max(0,mode==="positive"?Math.ceil(raw):Math.round(raw))}function toRetryError(value,fallbackMessage="Non-Error thrown"){if(value instanceof Error)return value;if(typeof value=="string")return new Error(value);let error=new Error(fallbackMessage,{cause:value});return(typeof value=="object"&&value!==null||typeof value=="function")&&Object.assign(error,value),error}function createRetryRunner(runtime={}){let runtimeSleep=runtime.sleep??defaultSleep,runtimeRandom=runtime.random??Math.random,createFailure=runtime.createFailure??(errors=>toRetryError(errors.at(-1)??new Error("Retry failed")));return async function(fn,attemptsOrOptions=3,initialDelayMs=300){let attemptErrors=[];if(typeof attemptsOrOptions=="number"){let attempts=resolveAttemptCount(attemptsOrOptions,DEFAULT_RETRY_CONFIG.attempts);for(let index=0;index0?resolved.maxDelayMs:Number.POSITIVE_INFINITY,retryAfterMaxDelayMs=options.retryAfterMaxDelayMs===void 0?maxDelayMs:Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(options.retryAfterMaxDelayMs,maxDelayMs,0))),random=options.random??runtimeRandom,sleep=options.sleep??runtimeSleep,shouldRetry=options.shouldRetry??(()=>!0);for(let attempt=1;attempt<=maxAttempts;attempt+=1)try{return await fn()}catch(err2){if(attemptErrors.push(err2),attempt>=maxAttempts||!shouldRetry(err2,attempt))break;let context={attempt,maxAttempts,err:err2,label:options.label},retryAfterMs=options.retryAfterMs?.(err2),hasRetryAfter=typeof retryAfterMs=="number"&&Number.isFinite(retryAfterMs),configuredDelay=typeof options.delayMs=="function"?options.delayMs(context):options.delayMs,resolvedConfiguredDelay=configuredDelay===void 0?void 0:resolveRetryDelayMs(configuredDelay),baseDelay=hasRetryAfter?Math.max(retryAfterMs,minDelayMs):resolvedConfiguredDelay===void 0?minDelayMs*2**(attempt-1):Math.max(resolvedConfiguredDelay,minDelayMs),delayCap=hasRetryAfter?retryAfterMaxDelayMs:maxDelayMs,delay=Math.min(baseDelay,delayCap),canHonorRetryAfter=hasRetryAfter&&(retryAfterMs??0)<=delayCap,wantsPositiveDraw=resolved.jitter==="full"&&!hasRetryAfter||canHonorRetryAfter;delay=applyJitter(delay,resolved.jitter,wantsPositiveDraw?"positive":"symmetric",random),delay=Math.min(Math.max(delay,minDelayMs),delayCap),await options.onRetry?.({...context,delayMs:delay}),delay>0&&await sleep(delay)}throw createFailure(attemptErrors)}}var retryAsync=createRetryRunner();var GatewayEventListeners=class{constructor(){this.listeners=new Map}add(listener){let subscription=this.listeners.get(listener)??{};return this.listeners.set(listener,subscription),()=>{this.listeners.get(listener)===subscription&&this.listeners.delete(listener)}}snapshot(){return[...this.listeners]}isCurrent(listener,subscription){return this.listeners.get(listener)===subscription}};var GatewayProtocolRequestError=class extends Error{constructor(error){super(error.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=error.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=error.details,this.retryable=error.retryable===!0,this.retryAfterMs=error.retryAfterMs}};var DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS=15e3;function startGatewayConnectTimeout(onTimeout){let timer=setTimeout(onTimeout,DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);return timer.unref?.(),timer}function clearGatewayConnectTimeout(timer){return timer!==null&&clearTimeout(timer),null}var GatewayProtocolClient=class{constructor(opts){this.opts=opts;this.socket=null;this.pending=new Map;this.listeners=new GatewayEventListeners;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.reconnectSignal=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new RetrySupervisor({initialMs:opts.reconnect.initialMs,maxMs:opts.reconnect.maxMs,factor:opts.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(pending=>pending.unbounded)}start(){this.socket||this.reconnectSignal||(this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect())}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSignal=null,this.reconnectSupervisor.reset();let socket=this.socket;socket&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),socket?.close()}request(method,params,options){let socket=this.socket;if(!socket?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof method!="string"||method.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let id=this.opts.createRequestId(),timeoutMs=options?.timeoutMs===null?void 0:options?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((resolve,reject)=>{let timeout,pending={resolve:value=>resolve(value),reject,expectFinal:options?.expectFinal===!0,acceptedNotified:!1,onAccepted:options?.onAccepted,unbounded:timeoutMs===void 0,method,startedAtMs:this.nowMs()},onAbort=()=>{this.pending.delete(id),timeout&&clearTimeout(timeout),this.finishRequestTiming(id,pending,!1,"CLIENT_ABORTED"),reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`))},cleanup=()=>{timeout&&clearTimeout(timeout),options?.signal?.removeEventListener("abort",onAbort)};if(options?.signal?.aborted){reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`));return}pending.cleanup=cleanup,timeoutMs!==void 0&&timeoutMs>=0&&(timeout=setTimeout(()=>{this.pending.delete(id),options?.signal?.removeEventListener("abort",onAbort),this.finishRequestTiming(id,pending,!1,"CLIENT_TIMEOUT"),reject(this.opts.createRequestTimeoutError?.(method,timeoutMs)??new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`))},timeoutMs),timeout.unref?.()),options?.signal?.addEventListener("abort",onAbort,{once:!0}),this.pending.set(id,pending);try{socket.send(JSON.stringify({type:"req",id,method,params})),this.invoke("sent",()=>options?.onSent?.())}catch(error){this.pending.delete(id),cleanup(),this.finishRequestTiming(id,pending,!1,"CLIENT_SEND_ERROR"),reject(error instanceof Error?error:new Error(String(error)))}})}addEventListener(listener){return this.listeners.add(listener)}closeSocket(code,reason){this.socket?.close(code,reason)}resetReconnectBackoff(initialMs){this.reconnectSignal=null,this.reconnectSupervisor.reset(initialMs)}recordTiming(phase,generation,plan,detail){let now=this.nowMs(),state=this.connectTiming;!state||state.generation!==generation||(state.hasChallenge||=phase==="challenge",state.usedFallback||=phase==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase,generation,durationMs:Math.max(0,now-state.startedAtMs),phaseDurationMs:Math.max(0,now-state.lastAtMs),hasChallenge:state.hasChallenge,usedFallback:state.usedFallback,plan,detail})),state.lastAtMs=now,(phase==="hello"||phase==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let generation=this.generation+1;this.lastSeq=null,this.connectNonce=null,this.connectChallengeTs=void 0,this.connectSent=this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let socket;try{socket=this.opts.createSocket({open:()=>this.handleOpen(socket,generation),message:data=>this.handleMessage(socket,generation,data),close:(code,reason)=>this.handleClose(socket,generation,code,reason),error:error=>this.handleSocketError(socket,generation,error)})}catch(error){let normalized2=error instanceof Error?error:new Error(String(error));if(this.opts.onSocketFactoryError?.(normalized2),this.opts.onConnectError?.(normalized2),this.opts.rethrowSocketFactoryError?.(normalized2))throw normalized2;this.opts.shouldRetrySocketFactoryError?.(normalized2)&&!this.stopped&&!this.socket&&!this.reconnectSignal&&this.scheduleReconnect();return}this.generation=generation,this.socket=socket;let now=this.nowMs();this.connectTiming={generation,startedAtMs:now,lastAtMs:now,hasChallenge:!1,usedFallback:!1}}handleOpen(socket,generation){if(this.isActive(socket,generation)){if(this.socketOpened=!0,this.recordTiming("socket-open",generation),this.connectNonce){this.sendConnect(socket,generation);return}this.armHandshakeTimer(socket,generation)}}armHandshakeTimer(socket,generation){this.clearHandshakeTimer();let armedAt=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(socket,generation)||this.connectSent||!socket.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",generation),this.sendConnect(socket,generation);return}let elapsedMs=Date.now()-armedAt,error=new Error(this.opts.handshake.timeoutMessage?.(elapsedMs)??`gateway connect challenge timeout after ${elapsedMs}ms`);this.opts.onConnectError?.(error),socket.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(socket,generation){if(!this.isActive(socket,generation)||!socket.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer(),this.handshakeTimer=startGatewayConnectTimeout(()=>{this.isActive(socket,generation)&&!this.helloReceived&&socket.close(4e3,"connect timeout")});let planOrPromise;try{planOrPromise=this.opts.buildConnectPlan({nonce:this.connectNonce,challengeTs:this.connectChallengeTs,generation})}catch(error){this.handleConnectPlanError(socket,generation,error);return}if(planOrPromise instanceof Promise){planOrPromise.then(plan=>this.sendConnectPlan(socket,generation,plan)).catch(error=>this.handleConnectPlanError(socket,generation,error));return}this.sendConnectPlan(socket,generation,planOrPromise)}handleConnectPlanError(socket,generation,error){if(!this.isActive(socket,generation))return;let normalized2=error instanceof Error?error:new Error(String(error)),outcome=this.opts.onConnectPlanError?.(normalized2)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(outcome.error??normalized2),outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)}sendConnectPlan(socket,generation,plan){if(!this.isActive(socket,generation)||!socket.isOpen())return;let context={generation,nonce:this.connectNonce,challengeTs:this.connectChallengeTs,plan};this.recordTiming("connect-plan-ready",generation,plan),this.recordTiming("request-sent",generation,plan),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(plan)).then(hello=>{this.isActive(socket,generation)&&(this.helloReceived=!0,this.clearHandshakeTimer(),this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",generation,plan),this.opts.onConnectHello?.(hello,context),this.invoke("hello",()=>this.opts.onHello?.(hello)))}).catch(error=>{if(!this.isActive(socket,generation))return;let requestError=error instanceof GatewayProtocolRequestError?error:new GatewayProtocolRequestError({message:String(error)}),outcome=this.opts.onConnectFailure?.(requestError,context)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:requestError,reconnectDelayMs:outcome.reconnectDelayMs},outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)})}handleMessage(socket,generation,raw){if(!this.isActive(socket,generation))return;let parsed;try{parsed=JSON.parse(raw)}catch(error){this.opts.onParseError?.(error);return}if(isGatewayEventFrame(parsed)){if(this.opts.onActivity?.(),parsed.event==="connect.challenge"){let payload=parsed.payload,nonce=typeof payload?.nonce=="string"?payload.nonce.trim():"";if(!nonce){if(this.opts.handshake.mode==="require-challenge"){let error=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(error),socket.close(1008,"connect challenge missing nonce")}return}this.connectNonce=nonce;let challengeTs=payload?.ts;this.connectChallengeTs=typeof challengeTs=="number"&&Number.isSafeInteger(challengeTs)&&challengeTs>=0?challengeTs:null,this.recordTiming("challenge",generation),this.sendConnect(socket,generation);return}let seq=typeof parsed.seq=="number"?parsed.seq:null;if(seq!==null){if(this.lastSeq!==null&&seq>this.lastSeq+1){let expected=this.lastSeq+1;if(this.invoke("gap",()=>this.opts.onGap?.({expected,received:seq})),!this.isActive(socket,generation))return}this.lastSeq=seq}let listeners=this.listeners.snapshot();this.invoke("event",()=>this.opts.onEvent?.(parsed));for(let[listener,subscription]of listeners){if(!this.isActive(socket,generation))return;this.listeners.isCurrent(listener,subscription)&&this.invoke("event listener",()=>listener(parsed))}return}isGatewayResponseFrame(parsed)&&(this.opts.onActivity?.(),this.handleResponse(parsed))}handleResponse(frame){let pending=this.pending.get(frame.id);if(!pending)return;let status=frame.payload?.status;if(pending.expectFinal&&status==="accepted"){pending.acceptedNotified||(pending.acceptedNotified=!0,this.invoke("accepted",()=>pending.onAccepted?.(frame.payload)));return}if(this.pending.delete(frame.id),pending.cleanup?.(),frame.ok){this.finishRequestTiming(frame.id,pending,!0),pending.resolve(frame.payload);return}this.finishRequestTiming(frame.id,pending,!1,frame.error?.code),pending.reject(this.opts.createRequestError?.(frame.error??{})??new GatewayProtocolRequestError(frame.error??{}))}handleClose(socket,generation,code,reason){if(this.socket!==socket){if(this.stoppedSocket?.socket===socket){let context2={...this.stoppedSocket.context,code,reason};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(context2,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let context={...this.closeContext(),code,reason,generation};this.connectFailure=void 0;let decision=this.opts.resolveClose(context);this.flushRequests(decision.pendingError??context.connectFailure?.error??new Error(`gateway closed (${code}): ${reason}`)),this.invoke("close",()=>this.opts.onClose?.(context,decision)),decision.retry&&!this.stopped&&this.scheduleReconnect(decision.reconnectDelayMs??context.connectFailure?.reconnectDelayMs)}handleSocketError(socket,generation,error){!this.isActive(socket,generation)||this.connectSent||this.opts.onConnectError?.(error)}flushRequests(error){for(let[id,pending]of this.pending)this.finishRequestTiming(id,pending,!1,"CLIENT_CLOSED"),pending.cleanup?.(),pending.reject(error);this.pending.clear()}finishRequestTiming(id,pending,ok,errorCode){let endedAtMs=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id,method:pending.method,ok,durationMs:Math.max(0,endedAtMs-pending.startedAtMs),startedAtMs:pending.startedAtMs,endedAtMs,errorCode}))}scheduleReconnect(overrideMs){overrideMs!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=overrideMs);let retry=this.reconnectSupervisor.next();retry&&(this.reconnectSignal=retry.signal,sleepWithAbort(retry.delayMs,retry.signal).then(()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null,this.connect())},()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null)}))}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(socket,generation){return!this.stopped&&this.socket===socket&&this.generation===generation}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer=clearGatewayConnectTimeout(this.handshakeTimer)}invoke(label,callback){try{callback()}catch(error){this.opts.onCallbackError?.(label,error)}}};var GATEWAY_CLIENT_IDS={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",LINUX_APP:"openclaw-linux",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var GATEWAY_CLIENT_MODES={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},GATEWAY_CLIENT_CAPS={AGENT_KIND:"agent-kind",APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},GATEWAY_CLIENT_ID_SET=new Set(Object.values(GATEWAY_CLIENT_IDS)),GATEWAY_CLIENT_MODE_SET=new Set(Object.values(GATEWAY_CLIENT_MODES));var PROTOCOL_VERSION=4,MIN_CLIENT_PROTOCOL_VERSION=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var ed25519_CURVE=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:P,n:N,Gx,Gy,a:_a,d:_d,h}=ed25519_CURVE,L=32,captureTrace=(...args)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...args)},err=(message="")=>{let e=new Error(message);throw captureTrace(e,err),e},isBig=n=>typeof n=="bigint",isStr=s=>typeof s=="string",isBytes=a=>a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in a&&a.BYTES_PER_ELEMENT===1,abytes=(value,length,title="")=>{let bytes=isBytes(value),len=value?.length,needsLen=length!==void 0;if(!bytes||needsLen&&len!==length){let prefix=title&&`"${title}" `,ofLen=needsLen?` of length ${length}`:"",got=bytes?`length=${len}`:`type=${typeof value}`,msg=prefix+"expected Uint8Array"+ofLen+", got "+got;throw bytes?new RangeError(msg):new TypeError(msg)}return value},u8n=len=>new Uint8Array(len),u8fr=buf=>Uint8Array.from(buf),padh=(n,pad)=>n.toString(16).padStart(pad,"0"),bytesToHex=b=>Array.from(abytes(b)).map(e=>padh(e,2)).join(""),C={_0:48,_9:57,A:65,F:70,a:97,f:102},_ch=ch=>{if(ch>=C._0&&ch<=C._9)return ch-C._0;if(ch>=C.A&&ch<=C.F)return ch-(C.A-10);if(ch>=C.a&&ch<=C.f)return ch-(C.a-10)},hexToBytes=hex=>{let e="hex invalid";if(!isStr(hex))return err(e);let hl=hex.length,al=hl/2;if(hl%2)return err(e);let array=u8n(al);for(let ai=0,hi=0;aiglobalThis?.crypto,subtle=()=>cr()?.subtle??err("crypto.subtle must be defined, consider polyfill"),concatBytes=(...arrs)=>{let len=0;for(let a of arrs)len+=abytes(a).length;let r=u8n(len),pad=0;return arrs.forEach(a=>{r.set(a,pad),pad+=a.length}),r},randomBytes=(len=L)=>cr().getRandomValues(u8n(len)),big=BigInt,assertRange=(n,min,max,msg="bad number: out of range")=>{if(!isBig(n))throw new TypeError(msg);if(min<=n&&n{let r=a%b;return r>=0n?r:b+r},P_MASK=(1n<<255n)-1n,modP=num=>{num<0n&&err("negative coordinate");let r=(num>>255n)*19n+(num&P_MASK);return r=(r>>255n)*19n+(r&P_MASK),r%P},modN=a=>M(a,N),invert=(num,md)=>{(num===0n||md<=0n)&&err("no inverse n="+num+" mod="+md);let a=M(num,md),b=md,x=0n,y=1n,u=1n,v=0n;for(;a!==0n;){let q=b/a,r=b%a,m=x-u*q,n=y-v*q;b=a,a=r,x=u,y=v,u=m,v=n}return b===1n?M(x,md):err("no inverse")},callHash=name=>{let fn=hashes[name];return typeof fn!="function"&&err("hashes."+name+" not set"),fn},checkDigest=value=>abytes(value,64,"digest");var apoint=p=>p instanceof Point?p:err("Point expected"),B256=2n**256n,Point=class _Point{static BASE;static ZERO;X;Y;Z;T;constructor(X,Y,Z,T){let max=B256;this.X=assertRange(X,0n,max),this.Y=assertRange(Y,0n,max),this.Z=assertRange(Z,1n,max),this.T=assertRange(T,0n,max),Object.freeze(this)}static CURVE(){return ed25519_CURVE}static fromAffine(p){return new _Point(p.x,p.y,1n,modP(p.x*p.y))}static fromBytes(hex,zip215=!1){let d=_d,normed=u8fr(abytes(hex,L)),lastByte=hex[31];normed[31]=lastByte&-129;let y=bytesToNumberLE(normed);assertRange(y,0n,zip215?B256:P);let y2=modP(y*y),u=M(y2-1n),v=modP(d*y2+1n),{isValid,value:x}=uvRatio(u,v);isValid||err("bad point: y not sqrt");let isXOdd=(x&1n)===1n,isLastByteOdd=(lastByte&128)!==0;return!zip215&&x===0n&&isLastByteOdd&&err("bad point: x==0, isLastByteOdd"),isLastByteOdd!==isXOdd&&(x=M(-x)),new _Point(x,y,1n,modP(x*y))}static fromHex(hex,zip215){return _Point.fromBytes(hexToBytes(hex),zip215)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let a=_a,d=_d,p=this;if(p.is0())return err("bad point: ZERO");let{X,Y,Z,T}=p,X2=modP(X*X),Y2=modP(Y*Y),Z2=modP(Z*Z),Z4=modP(Z2*Z2),aX2=modP(X2*a),left=modP(Z2*(aX2+Y2)),right=M(Z4+modP(d*modP(X2*Y2)));if(left!==right)return err("bad point: equation left != right (1)");let XY=modP(X*Y),ZT=modP(Z*T);return XY!==ZT?err("bad point: equation left != right (2)"):this}equals(other){let{X:X1,Y:Y1,Z:Z1}=this,{X:X2,Y:Y2,Z:Z2}=apoint(other),X1Z2=modP(X1*Z2),X2Z1=modP(X2*Z1),Y1Z2=modP(Y1*Z2),Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(I)}negate(){return new _Point(M(-this.X),this.Y,this.Z,M(-this.T))}double(){let{X:X1,Y:Y1,Z:Z1}=this,a=_a,A=modP(X1*X1),B=modP(Y1*Y1),C2=modP(2n*Z1*Z1),D=modP(a*A),x1y1=M(X1+Y1),E=M(modP(x1y1*x1y1)-A-B),G2=M(D+B),F=M(G2-C2),H=M(D-B),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}add(other){let{X:X1,Y:Y1,Z:Z1,T:T1}=this,{X:X2,Y:Y2,Z:Z2,T:T2}=apoint(other),a=_a,d=_d,A=modP(X1*X2),B=modP(Y1*Y2),C2=modP(modP(T1*d)*T2),D=modP(Z1*Z2),E=M(modP(M(X1+Y1)*M(X2+Y2))-A-B),F=M(D-C2),G2=M(D+C2),H=M(B-modP(a*A)),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}subtract(other){return this.add(apoint(other).negate())}multiply(n,safe=!0){if(!safe&&n===0n||(assertRange(n,1n,N),!safe&&this.is0()))return I;if(n===1n)return this;if(this.equals(G))return wNAF(n).p;let p=I,f=G;for(let d=this;n>0n;d=d.double(),n>>=1n)n&1n?p=p.add(d):safe&&(f=f.add(d));return p}multiplyUnsafe(scalar){return this.multiply(scalar,!1)}toAffine(){let{X,Y,Z}=this;if(this.equals(I))return{x:0n,y:1n};let iz=invert(Z,P);modP(Z*iz)!==1n&&err("invalid inverse");let x=modP(X*iz),y=modP(Y*iz);return{x,y}}toBytes(){let{x,y}=this.toAffine(),b=numTo32bLE(y);return b[31]|=x&1n?128:0,b}toHex(){return bytesToHex(this.toBytes())}clearCofactor(){return this.multiply(big(h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let p=this.multiply(N/2n,!1).double();return N%2n&&(p=p.add(this)),p.is0()}},G=new Point(Gx,Gy,1n,M(Gx*Gy)),I=new Point(0n,1n,1n,0n);Point.BASE=G;Point.ZERO=I;var numTo32bLE=num=>hexToBytes(padh(assertRange(num,0n,B256),64)).reverse(),bytesToNumberLE=b=>big("0x"+bytesToHex(u8fr(abytes(b)).reverse())),pow2=(x,power)=>{let r=x;for(;power-- >0n;)r=modP(r*r);return r},pow_2_252_3=x=>{let x2=modP(x*x),b2=modP(x2*x),b4=modP(pow2(b2,2n)*b2),b5=modP(pow2(b4,1n)*x),b10=modP(pow2(b5,5n)*b5),b20=modP(pow2(b10,10n)*b10),b40=modP(pow2(b20,20n)*b20),b80=modP(pow2(b40,40n)*b40),b160=modP(pow2(b80,80n)*b80),b240=modP(pow2(b160,80n)*b80),b250=modP(pow2(b240,10n)*b10);return{pow_p_5_8:modP(pow2(b250,2n)*x),b2}},RM1=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,uvRatio=(u,v)=>{let v3=modP(v*modP(v*v)),v7=modP(modP(v3*v3)*v),pow=pow_2_252_3(modP(u*v7)).pow_p_5_8,x=modP(u*modP(v3*pow)),vx2=modP(v*modP(x*x)),root1=x,root2=modP(x*RM1),useRoot1=vx2===u,useRoot2=vx2===M(-u),noRoot=vx2===M(-u*RM1);return useRoot1&&(x=root1),(useRoot2||noRoot)&&(x=root2),(M(x)&1n)===1n&&(x=M(-x)),{isValid:useRoot1||useRoot2,value:x}},modL_LE=hash=>modN(bytesToNumberLE(hash)),sha512a=(...m)=>Promise.resolve(callHash("sha512Async")(concatBytes(...m))).then(checkDigest),sha512s=(...m)=>checkDigest(callHash("sha512")(concatBytes(...m))),hash2extK=hashed=>{let copy=u8fr(hashed),head=copy.slice(0,32);head[0]&=248,head[31]&=127,head[31]|=64;let prefix=copy.slice(32,64),scalar=modL_LE(head),point=G.multiply(scalar),pointBytes=point.toBytes();return{head,prefix,scalar,point,pointBytes}},getExtendedPublicKeyAsync=secretKey=>sha512a(abytes(secretKey,L)).then(hash2extK),getExtendedPublicKey=secretKey=>hash2extK(sha512s(abytes(secretKey,L))),getPublicKeyAsync=secretKey=>getExtendedPublicKeyAsync(secretKey).then(p=>p.pointBytes);var hashFinishA=res=>sha512a(res.hashable).then(res.finish);var _sign=(e,rBytes,msg)=>{let{pointBytes:P2,scalar:s}=e,r=modL_LE(rBytes),R=G.multiply(r).toBytes();return{hashable:concatBytes(R,P2,msg),finish:hashed=>{let S=modN(r+modL_LE(hashed)*s);return abytes(concatBytes(R,numTo32bLE(S)),64)}}},signAsync=async(message,secretKey)=>{let m=abytes(message),e=await getExtendedPublicKeyAsync(secretKey),rBytes=await sha512a(e.prefix,m);return hashFinishA(_sign(e,rBytes,m))};var hashes={sha512Async:async message=>{let s=subtle(),m=concatBytes(message);return u8n(await s.digest("SHA-512",m.buffer))},sha512:void 0},randomSecretKey=seed=>(seed=seed===void 0?randomBytes(L):seed,abytes(seed,L));var utils=Object.freeze({getExtendedPublicKeyAsync,getExtendedPublicKey,randomSecretKey}),W=8,scalarBits=256,pwindows=Math.ceil(scalarBits/W)+1,pwindowSize=2**(W-1),precompute=()=>{let points=[],p=G,b=p;for(let w=0;w{let n=p.negate();return cnd?n:p},wNAF=n=>{let comp=Gpows||(Gpows=precompute()),p=I,f=G,pow_2_w=2**W,maxNum=pow_2_w,mask=big(pow_2_w-1),shiftBy=big(W);for(let w=0;w>=shiftBy,wbits>pwindowSize&&(wbits-=maxNum,n+=1n);let off=w*pwindowSize,offF=off,offP=off+Math.abs(wbits)-1,isEven=w%2!==0,isNeg=wbits<0;wbits===0?f=f.add(ctneg(isEven,comp[offF])):p=p.add(ctneg(isNeg,comp[offP]))}return n!==0n&&err("invalid wnaf"),{p,f}};export{GATEWAY_CLIENT_CAPS,GATEWAY_CLIENT_IDS,GATEWAY_CLIENT_MODES,GatewayBrowserDeviceAuthLifecycle,GatewayProtocolClient,GatewayProtocolRequestError,MIN_CLIENT_PROTOCOL_VERSION,PROTOCOL_VERSION,utils as ed25519Utils,getPublicKeyAsync,signAsync}; diff --git a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts index 0b0fc4c2bec2..118e7a92f709 100644 --- a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts +++ b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts @@ -205,7 +205,7 @@ async function createGatewayHarness(): Promise { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "browser-copilot-e2e-nonce" }, + payload: { nonce: "browser-copilot-e2e-nonce", ts: 1_777_777_777_000 }, }), ); socket.on("message", (data) => { diff --git a/packages/gateway-client/README.md b/packages/gateway-client/README.md index 67a313d20b1a..d7dfbca5722a 100644 --- a/packages/gateway-client/README.md +++ b/packages/gateway-client/README.md @@ -88,6 +88,7 @@ The host is responsible for: - creating a `GatewayProtocolSocket` adapter around the browser WebSocket; - loading and storing browser device identity and issued device tokens; - signing the challenge-bound device payload; +- using the Gateway challenge `ts` as the device proof's `signedAt` value; - supplying the client identity, role, scopes, and authentication selection; - choosing close and reconnect behavior for product-specific errors. diff --git a/packages/gateway-client/src/browser-device-auth.test.ts b/packages/gateway-client/src/browser-device-auth.test.ts index 69056495aa03..77237de45c56 100644 --- a/packages/gateway-client/src/browser-device-auth.test.ts +++ b/packages/gateway-client/src/browser-device-auth.test.ts @@ -28,6 +28,7 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => { role: "operator", defaultScopes: ["operator.read", "operator.write"], nonce: "nonce", + challengeTs: 456, }); expect(plan.auth).toEqual({ @@ -40,7 +41,7 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => { }); expect(plan.scopes).toEqual(["operator.read"]); expect(sign).toHaveBeenCalledWith( - "v3|device|openclaw-browser-copilot|ui|operator|operator.read|123|test-token-placeholder|nonce|chrome|extension", + "v3|device|openclaw-browser-copilot|ui|operator|operator.read|456|test-token-placeholder|nonce|chrome|extension", ); await lifecycle.acceptHello( @@ -56,6 +57,46 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => { }); }); + it("rejects the protocol's malformed-timestamp signal", async () => { + const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ + loadIdentity: async () => ({ + deviceId: "device", + publicKey: "public", + sign: async () => "signature", + }), + tokenStore: { load: () => null, store: vi.fn(), clear: vi.fn() }, + nowMs: () => 123, + }); + + await expect( + lifecycle.buildPlan({ + client, + role: "operator", + defaultScopes: ["operator.read"], + nonce: "nonce", + challengeTs: null, + }), + ).rejects.toThrow("gateway connect challenge timestamp invalid"); + }); + + it("keeps the local-clock fallback for callers that received no challenge", async () => { + const sign = vi.fn(async () => "signature"); + const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ + loadIdentity: async () => ({ deviceId: "device", publicKey: "public", sign }), + tokenStore: { load: () => null, store: vi.fn(), clear: vi.fn() }, + nowMs: () => 123, + }); + + const plan = await lifecycle.buildPlan({ + client, + role: "operator", + defaultScopes: ["operator.read"], + nonce: "nonce", + }); + + expect(plan.device?.signedAt).toBe(123); + }); + it("never persists bootstrap or shared-secret credentials", async () => { const store = vi.fn(); const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ diff --git a/packages/gateway-client/src/browser-device-auth.ts b/packages/gateway-client/src/browser-device-auth.ts index d79435fcf625..7e85bff65d31 100644 --- a/packages/gateway-client/src/browser-device-auth.ts +++ b/packages/gateway-client/src/browser-device-auth.ts @@ -68,6 +68,7 @@ export class GatewayBrowserDeviceAuthLifecycle { trustedDeviceTokenRetry?: boolean; preferBootstrapToken?: boolean; nonce: string | null; + challengeTs?: number | null; }): Promise { const identity = await this.deps.loadIdentity(); const stored = identity @@ -109,7 +110,12 @@ export class GatewayBrowserDeviceAuthLifecycle { auth: buildGatewayConnectAuth(selectedAuth), }; } - const signedAtMs = this.deps.nowMs?.() ?? Date.now(); + // Undefined is reserved for an explicit no-challenge fallback; a received invalid challenge is null. + const signedAtMs = + params.challengeTs === undefined ? (this.deps.nowMs?.() ?? Date.now()) : params.challengeTs; + if (typeof signedAtMs !== "number" || !Number.isSafeInteger(signedAtMs) || signedAtMs < 0) { + throw new Error("gateway connect challenge timestamp invalid"); + } const nonce = params.nonce ?? ""; const { authBootstrapToken: primary, signatureToken: signed } = selectedAuth; let token: string | null = null; diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index d75a22631086..20e0367827ea 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -404,11 +404,18 @@ export class GatewayClient { createRequestError: (error) => new GatewayClientRequestError(error), createRequestTimeoutError: (method) => new Error(`gateway request timeout for ${method}`), createRequestAbortError: createGatewayRequestAbortError, - buildConnectPlan: ({ nonce }) => { + buildConnectPlan: ({ nonce, challengeTs }) => { if (!nonce) { throw new Error("gateway connect challenge missing nonce"); } - return this.assembleConnectParams({ role: this.opts.role ?? "operator", nonce }); + if (this.opts.deviceIdentity && challengeTs == null) { + throw new Error("gateway connect challenge timestamp invalid"); + } + return this.assembleConnectParams({ + role: this.opts.role ?? "operator", + nonce, + signedAtMs: challengeTs ?? Date.now(), + }); }, buildConnectParams: (assembled) => assembled.params, onConnectPlanError: (error) => { @@ -715,8 +722,12 @@ export class GatewayClient { this.deps.logError(this.deps.redactForLog(message)); } - private assembleConnectParams(params: { role: string; nonce: string }): AssembledConnect { - const { role, nonce } = params; + private assembleConnectParams(params: { + role: string; + nonce: string; + signedAtMs: number; + }): AssembledConnect { + const { role, nonce, signedAtMs } = params; // Auth selection is intentionally centralized: retry decisions depend on // whether a token was explicit, cached, or compatibility-derived. const selectedAuth = this.selectConnectAuth(role); @@ -736,7 +747,6 @@ export class GatewayClient { } const auth = buildGatewayConnectAuth(selectedAuth); - const signedAtMs = Date.now(); const scopes = resolveGatewayConnectScopes({ requestedScopes: this.opts.scopes, usingStoredDeviceToken, diff --git a/packages/gateway-client/src/client.watchdog.test.ts b/packages/gateway-client/src/client.watchdog.test.ts index 04645953a592..5a14647d5f6f 100644 --- a/packages/gateway-client/src/client.watchdog.test.ts +++ b/packages/gateway-client/src/client.watchdog.test.ts @@ -181,7 +181,7 @@ function completeSyntheticGatewayProtocolHandshake( JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "synthetic-nonce" }, + payload: { nonce: "synthetic-nonce", ts: 1_777_777_777_000 }, }), ); const connectFrame = JSON.parse(String(connection.send.mock.calls[0]?.[0])) as { @@ -660,7 +660,7 @@ describe("GatewayClient", () => { type: "event", event: "connect.challenge", seq: connectionNumber, - payload: { nonce: `nonce-${connectionNumber}` }, + payload: { nonce: `nonce-${connectionNumber}`, ts: 1_777_777_777_000 }, }), ); socket.on("message", (data) => { diff --git a/packages/gateway-client/src/protocol-client.handshake.test.ts b/packages/gateway-client/src/protocol-client.handshake.test.ts index 5413a3b055d5..5b8183bc0daf 100644 --- a/packages/gateway-client/src/protocol-client.handshake.test.ts +++ b/packages/gateway-client/src/protocol-client.handshake.test.ts @@ -34,13 +34,13 @@ function createHandshakeClient( return { client, connections }; } -function receiveConnectChallenge(connection: HandshakeConnection): void { +function receiveConnectChallenge(connection: HandshakeConnection, ts = 1_800_000_000_000): void { connection.handlers.open(); connection.handlers.message( JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "synthetic-nonce" }, + payload: { nonce: "synthetic-nonce", ts }, }), ); } @@ -68,6 +68,73 @@ describe("GatewayProtocolClient connect handshake", () => { client.stop(); }); + it("passes the Gateway challenge timestamp into connect planning", () => { + const buildConnectPlan = vi.fn(() => ({})); + const { client, connections } = createHandshakeClient(buildConnectPlan); + client.start(); + const connection = connections[0]; + expect(connection).toBeDefined(); + if (!connection) { + return; + } + + receiveConnectChallenge(connection, 1_700_000_000_123); + + expect(buildConnectPlan).toHaveBeenCalledWith({ + nonce: "synthetic-nonce", + challengeTs: 1_700_000_000_123, + generation: 1, + }); + client.stop(); + }); + + it("marks omitted and malformed challenge timestamps as invalid", () => { + const buildConnectPlan = vi.fn(() => ({})); + const { client, connections } = createHandshakeClient(buildConnectPlan); + client.start(); + const first = connections[0]; + expect(first).toBeDefined(); + if (!first) { + return; + } + first.handlers.open(); + first.handlers.message( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "legacy-nonce" }, + }), + ); + expect(buildConnectPlan).toHaveBeenLastCalledWith({ + nonce: "legacy-nonce", + challengeTs: null, + generation: 1, + }); + + client.stop(); + const secondClient = createHandshakeClient(buildConnectPlan); + secondClient.client.start(); + const second = secondClient.connections[0]; + expect(second).toBeDefined(); + if (!second) { + return; + } + second.handlers.open(); + second.handlers.message( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "malformed-nonce", ts: "not-a-number" }, + }), + ); + expect(buildConnectPlan).toHaveBeenLastCalledWith({ + nonce: "malformed-nonce", + challengeTs: null, + generation: 1, + }); + secondClient.client.stop(); + }); + it("retires device preparation that outlives the connect handshake", async () => { vi.useFakeTimers(); let resolvePlan: (plan: Record) => void = () => undefined; diff --git a/packages/gateway-client/src/protocol-client.ts b/packages/gateway-client/src/protocol-client.ts index 244ffa34791d..6cfe0b56cb63 100644 --- a/packages/gateway-client/src/protocol-client.ts +++ b/packages/gateway-client/src/protocol-client.ts @@ -6,8 +6,14 @@ import { import { RetrySupervisor, sleepWithAbort } from "@openclaw/retry"; import { GatewayEventListeners } from "./event-listeners.js"; import type { GatewayPendingRequest } from "./pending-request.js"; +import { + GatewayProtocolRequestError, + type GatewayProtocolRequestOptions, +} from "./protocol-request.js"; import { clearGatewayConnectTimeout, startGatewayConnectTimeout } from "./timeouts.js"; +export { GatewayProtocolRequestError, type GatewayProtocolRequestOptions }; + export type GatewayProtocolSocket = { isOpen: () => boolean; send: (data: string) => void; @@ -19,16 +25,10 @@ export type GatewayProtocolSocketHandlers = { close: (code: number, reason: string) => void; error: (error: Error) => void; }; -export type GatewayProtocolRequestOptions = { - timeoutMs?: number | null; - expectFinal?: boolean; - onSent?: () => void; - onAccepted?: (payload: unknown) => void; - signal?: AbortSignal; -}; type GatewayProtocolConnectContext = { generation: number; nonce: string | null; + challengeTs: number | null | undefined; plan: TPlan; }; export type GatewayProtocolCloseContext = { @@ -88,6 +88,7 @@ type GatewayProtocolClientOptions = { createRequestAbortError?: (method: string) => Error; buildConnectPlan: (params: { nonce: string | null; + challengeTs: number | null | undefined; generation: number; }) => TPlan | Promise; buildConnectParams: (plan: TPlan) => unknown; @@ -123,24 +124,6 @@ type GatewayProtocolClientOptions = { shouldRetrySocketFactoryError?: (error: Error) => boolean; rethrowSocketFactoryError?: (error: Error) => boolean; }; -export class GatewayProtocolRequestError extends Error { - readonly code: string; - readonly gatewayCode: string; - readonly details?: unknown; - readonly retryable: boolean; - readonly retryAfterMs?: number; - - constructor(error: Partial) { - super(error.message ?? "request failed"); - this.name = "GatewayProtocolRequestError"; - this.code = error.code ?? "UNAVAILABLE"; - this.gatewayCode = this.code; - this.details = error.details; - this.retryable = error.retryable === true; - this.retryAfterMs = error.retryAfterMs; - } -} - type ConnectTimingState = { generation: number; startedAtMs: number; @@ -162,6 +145,7 @@ export class GatewayProtocolClient { private generation = 0; private lastSeq: number | null = null; private connectNonce: string | null = null; + private connectChallengeTs: number | null | undefined; private connectSent = false; private connectRequestSent = false; private handshakeTimer: ReturnType | null = null; @@ -354,6 +338,7 @@ export class GatewayProtocolClient { const generation = this.generation + 1; this.lastSeq = null; // Outer event sequences belong to one WebSocket generation. this.connectNonce = null; + this.connectChallengeTs = undefined; this.connectSent = this.connectRequestSent = false; this.socketOpened = false; this.helloReceived = false; @@ -450,6 +435,7 @@ export class GatewayProtocolClient { try { planOrPromise = this.opts.buildConnectPlan({ nonce: this.connectNonce, + challengeTs: this.connectChallengeTs, generation, }); } catch (error) { @@ -489,7 +475,12 @@ export class GatewayProtocolClient { if (!this.isActive(socket, generation) || !socket.isOpen()) { return; } - const context = { generation, nonce: this.connectNonce, plan }; + const context = { + generation, + nonce: this.connectNonce, + challengeTs: this.connectChallengeTs, + plan, + }; this.recordTiming("connect-plan-ready", generation, plan); this.recordTiming("request-sent", generation, plan); this.connectRequestSent = true; @@ -543,7 +534,7 @@ export class GatewayProtocolClient { if (isGatewayEventFrame(parsed)) { this.opts.onActivity?.(); if (parsed.event === "connect.challenge") { - const payload = parsed.payload as { nonce?: unknown } | undefined; + const payload = parsed.payload as { nonce?: unknown; ts?: unknown } | undefined; const nonce = typeof payload?.nonce === "string" ? payload.nonce.trim() : ""; if (!nonce) { if (this.opts.handshake.mode === "require-challenge") { @@ -554,6 +545,11 @@ export class GatewayProtocolClient { return; } this.connectNonce = nonce; + const challengeTs = payload?.ts; + this.connectChallengeTs = + typeof challengeTs === "number" && Number.isSafeInteger(challengeTs) && challengeTs >= 0 + ? challengeTs + : null; this.recordTiming("challenge", generation); this.sendConnect(socket, generation); return; diff --git a/packages/gateway-client/src/protocol-request.ts b/packages/gateway-client/src/protocol-request.ts new file mode 100644 index 000000000000..b11a7391600c --- /dev/null +++ b/packages/gateway-client/src/protocol-request.ts @@ -0,0 +1,27 @@ +import type { ErrorShape } from "@openclaw/gateway-protocol"; + +export type GatewayProtocolRequestOptions = { + timeoutMs?: number | null; + expectFinal?: boolean; + onSent?: () => void; + onAccepted?: (payload: unknown) => void; + signal?: AbortSignal; +}; + +export class GatewayProtocolRequestError extends Error { + readonly code: string; + readonly gatewayCode: string; + readonly details?: unknown; + readonly retryable: boolean; + readonly retryAfterMs?: number; + + constructor(error: Partial) { + super(error.message ?? "request failed"); + this.name = "GatewayProtocolRequestError"; + this.code = error.code ?? "UNAVAILABLE"; + this.gatewayCode = this.code; + this.details = error.details; + this.retryable = error.retryable === true; + this.retryAfterMs = error.retryAfterMs; + } +} diff --git a/packages/sdk/src/index.e2e.test.ts b/packages/sdk/src/index.e2e.test.ts index 7ad602c184c6..a08d29be1787 100644 --- a/packages/sdk/src/index.e2e.test.ts +++ b/packages/sdk/src/index.e2e.test.ts @@ -58,7 +58,7 @@ async function createFakeGateway(port = 0): Promise { type: "event", event: "connect.challenge", seq: seq++, - payload: { nonce: "sdk-e2e-nonce" }, + payload: { nonce: "sdk-e2e-nonce", ts: Date.now() }, }); socket.on("message", (raw) => { diff --git a/src/cli/acp-cli-exit.process.test.ts b/src/cli/acp-cli-exit.process.test.ts index 1e617cf8b21f..774187520e5a 100644 --- a/src/cli/acp-cli-exit.process.test.ts +++ b/src/cli/acp-cli-exit.process.test.ts @@ -163,7 +163,7 @@ describe("ACP CLI process exit", () => { type: "event", event: "connect.challenge", seq: 1, - payload: { nonce: "acp-process-test" }, + payload: { nonce: "acp-process-test", ts: Date.now() }, }), ); socket.on("message", (data) => { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 9efc525fd1bc..e3b6c3062d99 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -589,7 +589,7 @@ describe("GatewayClient request errors", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-1" }, + payload: { nonce: "nonce-1", ts: 1_777_777_777_000 }, }), ); const connectFrame = JSON.parse( @@ -657,7 +657,7 @@ describe("GatewayClient request errors", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-1" }, + payload: { nonce: "nonce-1", ts: 1_777_777_777_000 }, }), ); const connectFrame = JSON.parse( @@ -869,7 +869,7 @@ describe("GatewayClient close handling", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-1" }, + payload: { nonce: "nonce-1", ts: 1_777_777_777_000 }, }), ); expect(firstWs.sent.some((frame) => frame.includes('"method":"connect"'))).toBe(true); @@ -897,7 +897,7 @@ describe("GatewayClient close handling", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-2" }, + payload: { nonce: "nonce-2", ts: 1_777_777_778_000 }, }), ); const connectFrame = JSON.parse( @@ -942,7 +942,7 @@ describe("GatewayClient close handling", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-1" }, + payload: { nonce: "nonce-1", ts: 1_777_777_777_000 }, }), ); firstWs.emitClose(1000, ""); @@ -961,7 +961,7 @@ describe("GatewayClient close handling", () => { JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-2" }, + payload: { nonce: "nonce-2", ts: 1_777_777_778_000 }, }), ); secondWs.emitClose(1000, ""); @@ -1168,6 +1168,9 @@ describe("GatewayClient connect auth payload", () => { approvalRuntimeToken?: string; agentRuntimeIdentityToken?: string; }; + device?: { + signedAt?: number; + }; }; }; @@ -1216,12 +1219,79 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); - function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") { + it("signs device proof with Gateway time instead of client wall-clock time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2040-01-01T00:00:00.000Z")); + const client = createClientWithIdentity("device-gateway-time", vi.fn()); + const challengeTs = 1_700_000_000_123; + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws, "nonce-clock-skew", challengeTs); + const connect = connectRequestFrom(ws); + + expect(connect.params?.device?.signedAt).toBe(challengeTs); + client.stop(); + vi.useRealTimers(); + }); + + it("fails closed when a device challenge omits its Gateway timestamp", () => { + const onConnectError = vi.fn(); + const client = createClientWithIdentity("device-missing-challenge-time", vi.fn(), { + onConnectError, + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); ws.emitMessage( JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce }, + payload: { nonce: "nonce-missing-time" }, + }), + ); + + expect(ws.sent.some((frame) => frame.includes('"method":"connect"'))).toBe(false); + expect(firstMockArg(onConnectError, "connect error")).toMatchObject({ + message: "gateway connect challenge timestamp invalid", + }); + expect(ws.lastClose).toEqual({ code: 1008, reason: "connect failed" }); + client.stop(); + }); + + it("fails closed when a device challenge timestamp is malformed", () => { + const onConnectError = vi.fn(); + const client = createClientWithIdentity("device-invalid-challenge-time", vi.fn(), { + onConnectError, + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + ws.emitMessage( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-invalid-time", ts: "not-a-number" }, + }), + ); + + expect(ws.sent.some((frame) => frame.includes('"method":"connect"'))).toBe(false); + expect(firstMockArg(onConnectError, "connect error")).toMatchObject({ + message: "gateway connect challenge timestamp invalid", + }); + expect(ws.lastClose).toEqual({ code: 1008, reason: "connect failed" }); + client.stop(); + }); + + function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1", ts = 1_800_000_000_000) { + ws.emitMessage( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce, ts }, }), ); } diff --git a/src/gateway/minimal-gateway.test-helpers.ts b/src/gateway/minimal-gateway.test-helpers.ts index 58e9bbbe5e0c..680691c6458d 100644 --- a/src/gateway/minimal-gateway.test-helpers.ts +++ b/src/gateway/minimal-gateway.test-helpers.ts @@ -28,7 +28,7 @@ export function sendMinimalGatewayConnectChallenge(ws: WebSocket, nonce = "test- JSON.stringify({ type: "event", event: "connect.challenge", - payload: { nonce }, + payload: { nonce, ts: Date.now() }, }), ); } diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts index d9b0968a138f..9fe009247ac4 100644 --- a/src/gateway/watch-node-http.test.ts +++ b/src/gateway/watch-node-http.test.ts @@ -51,12 +51,13 @@ function makeConnectParams(params: { permissions?: ConnectParams["permissions"]; minProtocol?: number; maxProtocol?: number; + signedAt?: number; }): ConnectParams { const publicKey = publicKeyRawBase64UrlFromPem(params.identity.publicKeyPem); const auth = params.deviceToken ? { deviceToken: params.deviceToken } : { bootstrapToken: params.bootstrapToken }; - const signedAt = Date.now(); + const signedAt = params.signedAt ?? Date.now(); const client: ConnectParams["client"] = { id: GATEWAY_CLIENT_IDS.WATCHOS_APP, displayName: "Test Watch", @@ -106,6 +107,7 @@ async function startRuntime( rateLimiter?: AuthRateLimiter; abortConnectResponse?: boolean; config?: OpenClawConfig; + now?: () => number; }, ) { const nodeRegistry = new NodeRegistry({ @@ -130,6 +132,7 @@ async function startRuntime( onNodeConnected: (session) => connectedNodes.push(session.nodeId), onNodeDisconnected: (nodeId, reason) => disconnectedNodes.push({ nodeId, reason }), ...(options?.rateLimiter ? { rateLimiter: options.rateLimiter } : {}), + ...(options?.now ? { now: options.now } : {}), }); let resolveConnectHandled: () => void = () => undefined; const connectHandled = new Promise((resolve) => { @@ -210,6 +213,7 @@ async function connectWatchNode(params: { makeConnectParams({ identity: params.identity, nonce: String(challenge.nonce), + signedAt: Number(challenge.ts), bootstrapToken: params.bootstrapToken, deviceToken: params.deviceToken, permissions: params.permissions, @@ -257,6 +261,24 @@ async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Pr } describe("watch node HTTP transport", () => { + it("uses Gateway time for skew-independent device proof", async () => { + const now = vi.fn(() => 1_700_000_000_123); + const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture( + "openclaw-watch-node-challenge-time-", + { now }, + ); + + const response = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, + }); + + expect(response.status).toBe(200); + expect(now).toHaveBeenCalled(); + runtime.close(); + }); + it("rejects capabilities and identities outside the bounded watch surface", async () => { const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture( "openclaw-watch-node-surface-", diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index dd6017e47b60..2b67be638e90 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -264,7 +264,7 @@ function createChallengeStore() { const nonce = randomBytes(24).toString("base64url"); const expiresAtMs = current + CHALLENGE_TTL_MS; challenges.set(nonce, { clientKey, expiresAtMs }); - return { nonce, expiresAtMs }; + return { nonce, ts: current, expiresAtMs }; }, consume: (nonce: string, clientKey: string, current: number) => { const challenge = challenges.get(nonce); diff --git a/src/tui/gateway-chat.scopes.test.ts b/src/tui/gateway-chat.scopes.test.ts index 11e999e1df46..f62ca10b7cd6 100644 --- a/src/tui/gateway-chat.scopes.test.ts +++ b/src/tui/gateway-chat.scopes.test.ts @@ -137,7 +137,7 @@ describe("GatewayChatClient operator scopes", () => { socket.receive({ type: "event", event: "connect.challenge", - payload: { nonce }, + payload: { nonce, ts: Date.now() }, }); const connect = socket.sent.find((frame) => frame.method === "connect"); if (!connect) { diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index ee6c82cc5a09..cc2d27625a93 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -148,6 +148,9 @@ type ConnectFrame = { minProtocol?: number; caps?: string[]; scopes?: string[]; + device?: { + signedAt?: number; + }; }; }; @@ -222,7 +225,7 @@ function requireFirstSignCall(): [privateKey: string, payload: string] { function expectSignedPayloadFields( payload: string | undefined, - params: { scopes: string[]; token: string; nonce: string }, + params: { scopes: string[]; token: string; nonce: string; signedAtMs?: number }, ) { expect(payload?.split("|")).toEqual([ "v2", @@ -231,7 +234,7 @@ function expectSignedPayloadFields( "webchat", "operator", params.scopes.join(","), - expect.stringMatching(/^\d+$/), + params.signedAtMs === undefined ? expect.stringMatching(/^\d+$/) : String(params.signedAtMs), params.token, params.nonce, ]); @@ -300,12 +303,16 @@ function parseLatestConnectFrame(ws: MockWebSocket): ConnectFrame { return JSON.parse(ws.sent.at(-1) ?? "{}") as ConnectFrame; } -async function continueConnect(ws: MockWebSocket, nonce = "nonce-1") { +async function continueConnect( + ws: MockWebSocket, + nonce = "nonce-1", + challengeTs = 1_800_000_000_000, +) { ws.emitOpen(); ws.emitMessage({ type: "event", event: "connect.challenge", - payload: { nonce }, + payload: { nonce, ts: challengeTs }, }); if (vi.isFakeTimers()) { await vi.advanceTimersByTimeAsync(0); @@ -436,6 +443,73 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); }); + it("signs device proof with Gateway time instead of browser wall-clock time", async () => { + useNodeFakeTimers(); + vi.setSystemTime(new Date("2040-01-01T00:00:00.000Z")); + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + client.start(); + + const challengeTs = 1_700_000_000_123; + const { connectFrame } = await continueConnect( + getLatestWebSocket(), + "nonce-clock-skew", + challengeTs, + ); + + expect(connectFrame.params?.device?.signedAt).toBe(challengeTs); + const signedPayload = signDevicePayloadMock.mock.calls.at(-1)?.[1]; + expectSignedPayloadFields(signedPayload, { + scopes: [...CONTROL_UI_OPERATOR_SCOPES], + token: "shared-auth-token", + nonce: "nonce-clock-skew", + signedAtMs: challengeTs, + }); + client.stop(); + }); + + it("fails closed when a secure device challenge omits its Gateway timestamp", async () => { + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-missing-time" }, + }); + + await expectSocketClosed(ws); + expect(ws.sent).toHaveLength(0); + expect(ws.lastClose).toEqual({ code: 4008, reason: "connect failed" }); + client.stop(); + }); + + it("fails closed when a secure device challenge timestamp is malformed", async () => { + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-invalid-time", ts: "not-a-number" }, + }); + + await expectSocketClosed(ws); + expect(ws.sent).toHaveLength(0); + expect(ws.lastClose).toEqual({ code: 4008, reason: "connect failed" }); + client.stop(); + }); + it("requests handoff scopes with bootstrap token auth", async () => { const client = new GatewayBrowserClient({ url: "wss://gateway.example", @@ -866,6 +940,7 @@ describe("GatewayBrowserClient", () => { ws.emitOpen(); await vi.advanceTimersByTimeAsync(750); + expect(parseLatestConnectFrame(ws).params?.device?.signedAt).toBe(Date.now()); expect(connectTimingPayloads(onConnectTiming).map((payload) => payload.phase)).toContain( "fallback", ); @@ -1528,7 +1603,7 @@ describe("GatewayBrowserClient", () => { firstWs.emitMessage({ type: "event", event: "connect.challenge", - payload: { nonce: "nonce-stale" }, + payload: { nonce: "nonce-stale", ts: 1_777_777_777_000 }, }); await vi.advanceTimersByTimeAsync(0); expect(firstWs.sent).toHaveLength(0); diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 018c534d6e7c..271cccc783d5 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -271,12 +271,17 @@ async function buildGatewayConnectDevice(params: { scopes: string[]; authToken?: string; connectNonce: string | null; + connectChallengeTs: number | null | undefined; }): Promise { const { deviceIdentity } = params; if (!deviceIdentity) { return undefined; } - const signedAtMs = Date.now(); + if (params.connectChallengeTs === null) { + throw new Error("gateway connect challenge timestamp invalid"); + } + // The Control UI alone supports pre-challenge Gateways; that timeout fallback has no server time. + const signedAtMs = params.connectChallengeTs ?? Date.now(); const nonce = params.connectNonce ?? ""; const payload = buildDeviceAuthPayload({ deviceId: deviceIdentity.deviceId, @@ -319,7 +324,8 @@ export class GatewayBrowserClient { retryable: error.retryable, retryAfterMs: error.retryAfterMs, }), - buildConnectPlan: ({ nonce, generation }) => this.buildConnectPlan(nonce, generation), + buildConnectPlan: ({ nonce, challengeTs, generation }) => + this.buildConnectPlan(nonce, challengeTs, generation), buildConnectParams: (plan) => plan.params, onConnectHello: (hello, context) => this.handleConnectHello(hello, context.plan), onHello: (hello) => this.opts.onHello?.(hello), @@ -416,6 +422,7 @@ export class GatewayBrowserClient { private async buildConnectPlan( connectNonce: string | null, + connectChallengeTs: number | null | undefined, generation: number, ): Promise { this.recoveryScopeTracker.begin(generation); @@ -466,6 +473,7 @@ export class GatewayBrowserClient { scopes, authToken: selectedAuth.authBootstrapToken ?? selectedAuth.authToken, connectNonce, + connectChallengeTs, }); const plan: ConnectPlan = { generation, diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 1f3732148710..a63f23702c84 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -1430,7 +1430,7 @@ function installControlUiMockGateway( this.dispatchEvent(new Event("open")); this.deliver({ event: "connect.challenge", - payload: { nonce: "control-ui-e2e-nonce" }, + payload: { nonce: "control-ui-e2e-nonce", ts: Date.now() }, type: "event", }); } From fbf5f3f2e0968e1d98f45beeea2e78af03034f06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 01:48:12 -0700 Subject: [PATCH 035/239] fix(agents): resume interrupted turns past progress commentary (#116725) --- .../message-visibility.ts | 28 ++++ ...ion-restart-recovery-resume-policy.test.ts | 140 ++++++++++++++++++ ...-session-restart-recovery-resume-policy.ts | 12 +- .../main-session-restart-recovery.test.ts | 65 ++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/agents/main-session-restart-recovery-resume-policy.test.ts diff --git a/src/agents/embedded-agent-runner/message-visibility.ts b/src/agents/embedded-agent-runner/message-visibility.ts index 40dde7bedeae..4eb4d1c4869f 100644 --- a/src/agents/embedded-agent-runner/message-visibility.ts +++ b/src/agents/embedded-agent-runner/message-visibility.ts @@ -3,6 +3,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN, } from "../../auto-reply/tokens.js"; +import { resolveAssistantMessagePhase } from "../../shared/chat-message-content.js"; type AgentPayloadLike = { text?: unknown; @@ -184,6 +185,33 @@ export function isMeaningfulTranscriptMessage(message: unknown): boolean { return Boolean(role && role !== "system"); } +/** Recognizes persisted progress without mistaking an ordinary assistant answer for completion. */ +export function isIntermediateAssistantTranscriptMessage(message: unknown): boolean { + if ( + !message || + typeof message !== "object" || + getTranscriptMessageRole(message) !== "assistant" + ) { + return false; + } + const record = message as Record; + if (record.stopReason !== undefined && record.stopReason !== "stop") { + return false; + } + const phase = resolveAssistantMessagePhase(message); + if (phase !== undefined) { + return phase === "commentary"; + } + const fallback = record.openclawStreamFallback; + if (!fallback || typeof fallback !== "object" || Array.isArray(fallback)) { + return false; + } + const { itemId, source } = fallback as { itemId?: unknown; source?: unknown }; + // Keyed segments are durable progress items; unkeyed/current fallbacks can + // become the final answer and must never bypass restart completion checks. + return source === "segment" && typeof itemId === "string" && itemId.trim().length > 0; +} + /** Returns whether a stopped assistant turn contains only reasoning and a silent marker. */ export function isTerminalSilentAssistantMessage(message: unknown): boolean { if ( diff --git a/src/agents/main-session-restart-recovery-resume-policy.test.ts b/src/agents/main-session-restart-recovery-resume-policy.test.ts new file mode 100644 index 000000000000..de0282b0853d --- /dev/null +++ b/src/agents/main-session-restart-recovery-resume-policy.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveMainSessionResumePolicy } from "./main-session-restart-recovery-resume-policy.js"; + +vi.mock("./code-mode-control-tools.js", () => ({ + CODE_MODE_EXEC_TOOL_NAME: "exec", + CODE_MODE_WAIT_TOOL_NAME: "wait", +})); + +vi.mock("./tool-replay-safety.js", () => ({ + isAgentToolReplaySafe: ({ name }: { name?: string }) => name === "read", +})); + +vi.mock("./run-termination.js", () => ({ + AGENT_RUN_RESTART_ABORT_ERROR: "agent run aborted for restart", + AGENT_RUN_RESTART_ABORT_ERROR_CODE: "OPENCLAW_RESTART_ABORT", +})); + +function progressMessage(text: string, itemId: string): Record { + return { + role: "assistant", + content: [{ type: "text", text }], + stopReason: "stop", + openclawStreamFallback: { + replacementText: text, + source: "segment", + itemId, + }, + }; +} + +describe("resolveMainSessionResumePolicy progress tails", () => { + it("resumes when keyed progress messages arrive after the recovery mark", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + progressMessage("Checking the owner boundary.", "progress-1"), + progressMessage("Still tracing the restart lifecycle.", "progress-2"), + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); + }); + + it("resumes explicit commentary without making completed answers resumable", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + role: "assistant", + phase: "commentary", + content: [{ type: "text", text: "Checking the workspace." }], + stopReason: "stop", + }, + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); + + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { role: "assistant", content: [{ type: "text", text: "The work is complete." }] }, + progressMessage("A later progress item.", "progress-late"), + ]), + ).toEqual({ action: "fail", reason: "transcript tail is not resumable" }); + }); + + it("recognizes the existing provider text-signature commentary contract", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Checking the workspace.", + textSignature: JSON.stringify({ v: 1, id: "progress-signed", phase: "commentary" }), + }, + ], + stopReason: "stop", + }, + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); + }); + + it("keeps restart abort artifacts effective when progress arrives on either side", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + progressMessage("One last update before cancellation.", "progress-before-abort"), + { + role: "assistant", + content: [], + stopReason: "aborted", + errorMessage: "agent run aborted for restart", + }, + progressMessage("One delayed update after cancellation.", "progress-after-abort"), + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); + }); + + it("retains replay restrictions when progress follows a side-effecting tool call", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + role: "assistant", + stopReason: "toolUse", + content: [ + { type: "toolCall", id: "call-bash", name: "bash", arguments: { command: "true" } }, + ], + }, + progressMessage("Waiting for the command.", "progress-exec"), + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: true }); + }); + + it("never treats unkeyed stream fallbacks as authoritative progress", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + role: "assistant", + content: [{ type: "text", text: "Possibly final output." }], + stopReason: "stop", + openclawStreamFallback: { replacementText: "Possibly final output.", source: "current" }, + }, + ]), + ).toEqual({ action: "fail", reason: "transcript tail is not resumable" }); + }); + + it("keeps explicit final-answer phase authoritative over keyed fallback metadata", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + ...progressMessage("The work is complete.", "final-item"), + phase: "final_answer", + }, + ]), + ).toEqual({ action: "fail", reason: "transcript tail is not resumable" }); + }); +}); diff --git a/src/agents/main-session-restart-recovery-resume-policy.ts b/src/agents/main-session-restart-recovery-resume-policy.ts index fa21d5f1f588..1cbc1063ab77 100644 --- a/src/agents/main-session-restart-recovery-resume-policy.ts +++ b/src/agents/main-session-restart-recovery-resume-policy.ts @@ -3,6 +3,7 @@ import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js"; import { getTranscriptMessageRole as getMessageRole, + isIntermediateAssistantTranscriptMessage, isMeaningfulTranscriptMessage, readTerminalSourceReplyDeliveryMirror, } from "./embedded-agent-runner/message-visibility.js"; @@ -403,7 +404,16 @@ export function resolveMainSessionResumePolicy( } // `admitted` means no optional hook started. The dispatch boundary reloads // the current hook set before it permits this transcript to resume. - const meaningfulMessages = messages.toReversed().filter(isMeaningfulTranscriptMessage); + // Progress can commit after the recovery mark while the old run is winding + // down. It is not a terminal turn boundary; preserve it in the transcript + // while classifying the actual user/tool/assistant boundary beneath it. + const meaningfulMessages = messages + .toReversed() + .filter( + (message) => + isMeaningfulTranscriptMessage(message) && + !isIntermediateAssistantTranscriptMessage(message), + ); // A restart abort tail without tool calls is lifecycle noise whether or not // partial streamed text was persisted with it; the partial output stays in // the transcript for the continuation, and the message beneath decides diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 7ff4efd9f911..32fd9fa0695e 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -863,6 +863,71 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:main"]?.abortedLastRun).toBe(false); }); + it("resumes when durable commentary is mirrored after the restart recovery mark", async () => { + const sessionsDir = await makeSessionsDir(); + const sessionKey = "agent:main:main"; + await writeStore(sessionsDir, { + [sessionKey]: runningSessionEntry("main-session"), + }); + await writeTranscript(sessionsDir, "main-session", [ + { role: "user", content: "finish the interrupted long-running turn" }, + ]); + + await expect( + markRestartAbortedMainSessions({ + stateDir: tmpDir, + sessionKeys: [sessionKey], + reason: "gateway restart drain", + }), + ).resolves.toEqual({ marked: 1, skipped: 0 }); + + await writeTranscript(sessionsDir, "main-session", [ + { + role: "assistant", + content: [{ type: "text", text: "Checking the remaining background task." }], + stopReason: "stop", + openclawStreamFallback: { + replacementText: "Checking the remaining background task.", + source: "segment", + itemId: "progress-after-recovery-mark", + }, + }, + { + role: "assistant", + content: [{ type: "text", text: "The restart handoff is in progress." }], + stopReason: "stop", + openclawStreamFallback: { + replacementText: "The restart handoff is in progress.", + source: "segment", + itemId: "progress-after-recovery-mark-2", + }, + }, + ]); + + await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }); + expect(callGateway).toHaveBeenCalledOnce(); + expect(gatewayParams().sessionKey).toBe(sessionKey); + expect(readStore(path.join(sessionsDir, "sessions.json"))[sessionKey]).toMatchObject({ + status: "running", + abortedLastRun: false, + }); + + const transcript = await loadTestTranscript( + sessionKey, + path.join(sessionsDir, "sessions.json"), + ); + expect( + transcript + .map((event) => event.message) + .filter( + (message) => + message?.role === "assistant" && + (message as { openclawStreamFallback?: { source?: unknown } }).openclawStreamFallback + ?.source === "segment", + ), + ).toHaveLength(2); + }); + it.each([ { label: "same-process lifecycle rotation", From a2679ca314e3435ad54c585a8a3dd6bc1a6ddfd5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:03:06 +0800 Subject: [PATCH 036/239] fix(ui): close superseded Talk allocations Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> --- ui/src/pages/chat/realtime-talk.test.ts | 99 +++++++++++++++++++++++++ ui/src/pages/chat/realtime-talk.ts | 34 ++++++--- 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk.test.ts b/ui/src/pages/chat/realtime-talk.test.ts index 29f0b22f0b88..34133d9f5ef5 100644 --- a/ui/src/pages/chat/realtime-talk.test.ts +++ b/ui/src/pages/chat/realtime-talk.test.ts @@ -47,6 +47,14 @@ function transportContext(transport: object | undefined): RealtimeTalkTransportC return (transport as { ctx: RealtimeTalkTransportContext }).ctx; } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("RealtimeTalkSession", () => { beforeEach(() => { googleStart.mockClear(); @@ -209,6 +217,97 @@ describe("RealtimeTalkSession", () => { expect(webRtcInstances).toHaveLength(0); }); + it("closes a Gateway relay allocated after the session stops", async () => { + const create = createDeferred<{ + provider: string; + transport: "gateway-relay"; + relaySessionId: string; + audio: { + inputEncoding: "pcm16"; + inputSampleRateHz: number; + outputEncoding: "pcm16"; + outputSampleRateHz: number; + }; + }>(); + const request = vi.fn((method: string) => { + if (method === "talk.client.create") { + return create.promise; + } + if (method === "talk.session.close") { + return Promise.resolve({ ok: true }); + } + throw new Error(`Unexpected request: ${method}`); + }); + const session = new RealtimeTalkSession({ request } as never, "main"); + + const starting = session.start(); + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("talk.client.create", expect.anything()), + ); + session.stop(); + create.resolve({ + provider: "openai", + transport: "gateway-relay", + relaySessionId: "relay-stale", + audio: { + inputEncoding: "pcm16", + inputSampleRateHz: 24_000, + outputEncoding: "pcm16", + outputSampleRateHz: 24_000, + }, + }); + await starting; + + expect(request).toHaveBeenCalledWith("talk.session.close", { sessionId: "relay-stale" }); + expect(relayInstances).toHaveLength(0); + }); + + it("closes a superseded client-owned allocation without replacing the active call", async () => { + const creates: Array>> = []; + const request = vi.fn((method: string) => { + if (method === "talk.client.create") { + const create = createDeferred(); + creates.push(create); + return create.promise; + } + if (method === "talk.client.close") { + return Promise.resolve({ ok: true }); + } + throw new Error(`Unexpected request: ${method}`); + }); + const session = new RealtimeTalkSession({ request } as never, "main"); + + const firstStart = session.start(); + await vi.waitFor(() => expect(creates).toHaveLength(1)); + session.stop(); + const secondStart = session.start(); + await vi.waitFor(() => expect(creates).toHaveLength(2)); + creates[1]!.resolve({ + provider: "openai", + transport: "webrtc", + voiceSessionId: "voice-current", + clientSecret: "secret", + }); + await secondStart; + creates[0]!.resolve({ + provider: "openai", + transport: "webrtc", + voiceSessionId: "voice-stale", + clientSecret: "secret", + }); + await firstStart; + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("talk.client.close", { + sessionKey: "main", + voiceSessionId: "voice-stale", + }), + ); + expect(webRtcInstances).toHaveLength(1); + expect(webRtcStart).toHaveBeenCalledTimes(1); + session.stop(); + }); + it("falls back to talk.session.create when gateway-relay is rejected by talk.client.create", async () => { const request = vi .fn() diff --git a/ui/src/pages/chat/realtime-talk.ts b/ui/src/pages/chat/realtime-talk.ts index d4de594fa822..20633051545c 100644 --- a/ui/src/pages/chat/realtime-talk.ts +++ b/ui/src/pages/chat/realtime-talk.ts @@ -61,7 +61,7 @@ type RealtimeTalkLaunchTransport = NonNullable; }; @@ -137,6 +137,7 @@ function compactLaunchParams( export class RealtimeTalkSession { private transport: RealtimeTalkTransport | null = null; private closed = false; + private lifecycleGeneration = 0; private videoEnabled = false; private videoOperation = 0; private voiceSessionId: string | undefined; @@ -155,10 +156,11 @@ export class RealtimeTalkSession { ) {} async start(): Promise { + const lifecycleGeneration = ++this.lifecycleGeneration; this.closed = false; this.callbacks.onStatus?.("connecting"); const providerVideoCapable = await this.resolveVideoCapability(); - if (this.closed) { + if (this.closed || lifecycleGeneration !== this.lifecycleGeneration) { return; } // Declaring voice-transcript arms the server-side spoken-confirmation gate; @@ -182,16 +184,13 @@ export class RealtimeTalkSession { if (!voiceSessionId) { throw new Error("Realtime Talk session did not return a voice session id"); } + if (this.closed || lifecycleGeneration !== this.lifecycleGeneration) { + this.closeUnadoptedVoiceSession(voiceSessionId, transport); + return; + } this.voiceSessionId = voiceSessionId; this.acceptingTranscripts = true; this.serverOwnedVoiceSession = transport === "gateway-relay"; - if (this.closed) { - const detached = this.detachVoiceSession(); - if (detached) { - this.closeLogicalVoiceSession(detached); - } - return; - } this.transportGeneration += 1; const callbacks = transport === "gateway-relay" @@ -300,6 +299,7 @@ export class RealtimeTalkSession { } stop(): void { + this.lifecycleGeneration += 1; this.closed = true; this.videoOperation += 1; this.videoEnabled = false; @@ -313,6 +313,22 @@ export class RealtimeTalkSession { } } + private closeUnadoptedVoiceSession(voiceSessionId: string, transport: string): void { + // A stopped or superseded create still owns the allocation returned to it. + // Close at the provider boundary without installing a stale transport. + if (transport === "gateway-relay") { + void this.client + .request("talk.session.close", { sessionId: voiceSessionId }) + .catch(() => undefined); + return; + } + this.closeLogicalVoiceSession({ + voiceSessionId, + serverOwned: false, + transcriptWrites: Promise.resolve(), + }); + } + private clientOwnedTranscriptCallbacks( owningVoiceSessionId: string, owningGeneration: number, From 85b4a4a612632add0b0a88ba344047051346d61e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:03:14 +0800 Subject: [PATCH 037/239] test(ui): cover the Talk relay start-stop race Co-authored-by: shaoohh <150606856+shaoohh@users.noreply.github.com> --- .../e2e/browser-talk-start-stop.e2e.test.ts | 275 ++++++++---------- .../e2e/browser-talk-start-stop.fixtures.ts | 149 ++++++++++ 2 files changed, 275 insertions(+), 149 deletions(-) create mode 100644 ui/src/e2e/browser-talk-start-stop.fixtures.ts diff --git a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts index 3f79d6d1b2ac..19ea6da0fe08 100644 --- a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts +++ b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts @@ -1,7 +1,5 @@ // Control UI E2E tests cover browser Talk start and stop through a real page. -import { mkdir } from "node:fs/promises"; -import path from "node:path"; -import { chromium, type Browser, type Page } from "playwright"; +import { chromium, type Browser } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { canRunPlaywrightChromium, @@ -10,6 +8,14 @@ import { startControlUiE2eServer, type ControlUiE2eServer, } from "../test-helpers/control-ui-e2e.ts"; +import { + captureComposerProof, + captureVideoTalkProof, + installBlockedMicrophoneFixture, + installBlockedVideoTalkFixture, + installTalkBrowserFixtures, + videoTalkCatalog, +} from "./browser-talk-start-stop.fixtures.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); @@ -20,152 +26,6 @@ let server: ControlUiE2eServer; // Browser contexts preserve test isolation; keep one process warm for this file. let browser: Browser; -function videoTalkCatalog(activeProvider: "google" | "openai") { - return { - realtime: { - activeProvider, - providers: [{ id: activeProvider, label: activeProvider, supportsVideoFrames: true }], - }, - }; -} - -async function installTalkBrowserFixtures(page: Page) { - await page.addInitScript(() => { - type InputProcessor = { - onaudioprocess: - | ((event: { inputBuffer: { getChannelData: () => Float32Array } }) => void) - | null; - }; - const state = { - audioContextsClosed: 0, - tracksStopped: 0, - constraints: [] as unknown[], - inputProcessor: null as InputProcessor | null, - meterLevel: 0, - }; - const track = { stop: () => (state.tracksStopped += 1) }; - Object.defineProperty(navigator, "mediaDevices", { - configurable: true, - value: { - enumerateDevices: async () => [ - { kind: "audioinput", deviceId: "built-in", label: "Built-in Microphone" }, - { kind: "audioinput", deviceId: "usb", label: "USB Audio Interface" }, - { kind: "videoinput", deviceId: "camera", label: "Camera" }, - ], - getUserMedia: async (constraints: unknown) => { - state.constraints.push(constraints); - return { getTracks: () => [track] }; - }, - }, - }); - - class MockAudioContext { - readonly currentTime = 0; - readonly destination = {}; - readonly sampleRate: number; - - constructor(options?: { sampleRate?: number }) { - this.sampleRate = options?.sampleRate ?? 24_000; - } - - createMediaStreamSource() { - return { connect() {}, disconnect() {} }; - } - - createGain() { - return { connect() {}, disconnect() {}, gain: { value: 1 } }; - } - - createScriptProcessor() { - const processor = { connect() {}, disconnect() {}, onaudioprocess: null }; - state.inputProcessor = processor; - return processor; - } - - createAnalyser() { - return { - fftSize: 0, - smoothingTimeConstant: 0, - disconnect() {}, - getFloatTimeDomainData(samples: Float32Array) { - samples.fill(state.meterLevel); - }, - }; - } - - async close() { - state.audioContextsClosed += 1; - } - } - - Object.defineProperty(window, "AudioContext", { - configurable: true, - value: MockAudioContext, - }); - Object.defineProperty(window, "openclawTalkE2eState", { - configurable: true, - value: state, - }); - }); -} - -async function captureComposerProof(page: Page, fileName: string) { - const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls"); - await mkdir(artifactDir, { recursive: true }); - await page - .locator(".agent-chat__composer-shell") - .screenshot({ path: path.join(artifactDir, fileName) }); -} - -async function captureVideoTalkProof(page: Page, fileName: string) { - const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "video-talk"); - await mkdir(artifactDir, { recursive: true }); - await page - .locator(".agent-chat__composer-shell") - .screenshot({ path: path.join(artifactDir, fileName) }); -} - -async function installBlockedMicrophoneFixture(page: Page) { - await page.addInitScript(() => { - Object.defineProperty(navigator, "mediaDevices", { - configurable: true, - value: { - enumerateDevices: async () => [], - getUserMedia: async () => { - throw new DOMException("Permission denied", "NotAllowedError"); - }, - }, - }); - }); -} - -async function installBlockedVideoTalkFixture(page: Page) { - await page.addInitScript(() => { - const getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); - Object.defineProperty(navigator, "mediaDevices", { - configurable: true, - value: { - getUserMedia: async (constraints: MediaStreamConstraints) => { - if (constraints.video) { - throw new DOMException("Permission denied", "NotAllowedError"); - } - return getUserMedia(constraints); - }, - }, - }); - class FakePeerConnection extends EventTarget { - connectionState = "new"; - close() { - this.connectionState = "closed"; - } - } - Object.defineProperty(window, "RTCPeerConnection", { - configurable: true, - value: FakePeerConnection, - }); - }); -} - describeControlUiE2e("Control UI browser Talk", () => { beforeAll(async () => { browser = await chromium.launch({ @@ -1004,6 +864,123 @@ describeControlUiE2e("Control UI browser Talk", () => { } }); + it("closes a stale relay when stop and restart race its create response", async () => { + const context = await browser.newContext({ locale: "en-US", permissions: ["microphone"] }); + const page = await context.newPage(); + const currentRelaySessionId = "relay-current-e2e"; + const staleRelaySessionId = "relay-stale-e2e"; + const gateway = await installMockGateway(page, { + methodResponses: { + "talk.client.create": { + provider: "openai", + transport: "gateway-relay", + relaySessionId: currentRelaySessionId, + audio: { + inputEncoding: "pcm16", + inputSampleRateHz: 16_000, + outputEncoding: "pcm16", + outputSampleRateHz: 24_000, + }, + }, + "talk.session.appendAudio": {}, + "talk.session.close": {}, + }, + }); + await installTalkBrowserFixtures(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.deferNext("talk.client.create"); + + await page.getByRole("button", { name: "Start voice input" }).click(); + await expect + .poll(() => gateway.getRequests("talk.client.create").then((requests) => requests.length)) + .toBe(1); + await page.getByRole("button", { name: "Stop voice input" }).click(); + await page.getByRole("button", { name: "Start voice input" }).click(); + await expect + .poll(() => gateway.getRequests("talk.client.create").then((requests) => requests.length)) + .toBe(2); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + openclawTalkE2eState?: { constraints: unknown[] }; + } + ).openclawTalkE2eState?.constraints.length, + ), + ) + .toBe(1); + await gateway.emitGatewayEvent("talk.event", { + relaySessionId: currentRelaySessionId, + type: "ready", + }); + await expect + .poll(() => page.locator('.agent-chat__voice-activity[data-status="listening"]').count()) + .toBe(1); + + await gateway.resolveDeferred("talk.client.create", { + provider: "openai", + transport: "gateway-relay", + relaySessionId: staleRelaySessionId, + audio: { + inputEncoding: "pcm16", + inputSampleRateHz: 16_000, + outputEncoding: "pcm16", + outputSampleRateHz: 24_000, + }, + }); + await expect + .poll(() => gateway.getRequests("talk.session.close")) + .toEqual([ + expect.objectContaining({ + params: { sessionId: staleRelaySessionId }, + }), + ]); + + await page.evaluate(() => { + const state = ( + window as Window & { + openclawTalkE2eState?: { + inputProcessor?: { + onaudioprocess?: (event: { + inputBuffer: { getChannelData: () => Float32Array }; + }) => void; + }; + }; + } + ).openclawTalkE2eState; + state?.inputProcessor?.onaudioprocess?.({ + inputBuffer: { getChannelData: () => new Float32Array(4096).fill(0.1) }, + }); + }); + await expect + .poll(() => gateway.getRequests("talk.session.appendAudio")) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ sessionId: currentRelaySessionId }), + }), + ]); + await expect + .poll(() => page.getByRole("button", { name: "Stop voice input" }).isVisible()) + .toBe(true); + + await page.getByRole("button", { name: "Stop voice input" }).click(); + await expect + .poll(() => + gateway + .getRequests("talk.session.close") + .then((requests) => requests.map((request) => request.params)), + ) + .toEqual([{ sessionId: staleRelaySessionId }, { sessionId: currentRelaySessionId }]); + } finally { + await context.close(); + } + }); + it("keeps blocked microphone guidance readable in a narrow viewport", async () => { const context = await browser.newContext(); const page = await context.newPage(); diff --git a/ui/src/e2e/browser-talk-start-stop.fixtures.ts b/ui/src/e2e/browser-talk-start-stop.fixtures.ts new file mode 100644 index 000000000000..60a8baa3fbe0 --- /dev/null +++ b/ui/src/e2e/browser-talk-start-stop.fixtures.ts @@ -0,0 +1,149 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "playwright"; + +export function videoTalkCatalog(activeProvider: "google" | "openai") { + return { + realtime: { + activeProvider, + providers: [{ id: activeProvider, label: activeProvider, supportsVideoFrames: true }], + }, + }; +} + +export async function installTalkBrowserFixtures(page: Page) { + await page.addInitScript(() => { + type InputProcessor = { + onaudioprocess: + | ((event: { inputBuffer: { getChannelData: () => Float32Array } }) => void) + | null; + }; + const state = { + audioContextsClosed: 0, + tracksStopped: 0, + constraints: [] as unknown[], + inputProcessor: null as InputProcessor | null, + meterLevel: 0, + }; + const track = { stop: () => (state.tracksStopped += 1) }; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: async () => [ + { kind: "audioinput", deviceId: "built-in", label: "Built-in Microphone" }, + { kind: "audioinput", deviceId: "usb", label: "USB Audio Interface" }, + { kind: "videoinput", deviceId: "camera", label: "Camera" }, + ], + getUserMedia: async (constraints: unknown) => { + state.constraints.push(constraints); + return { getTracks: () => [track] }; + }, + }, + }); + + class MockAudioContext { + readonly currentTime = 0; + readonly destination = {}; + readonly sampleRate: number; + + constructor(options?: { sampleRate?: number }) { + this.sampleRate = options?.sampleRate ?? 24_000; + } + + createMediaStreamSource() { + return { connect() {}, disconnect() {} }; + } + + createGain() { + return { connect() {}, disconnect() {}, gain: { value: 1 } }; + } + + createScriptProcessor() { + const processor = { connect() {}, disconnect() {}, onaudioprocess: null }; + state.inputProcessor = processor; + return processor; + } + + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect() {}, + getFloatTimeDomainData(samples: Float32Array) { + samples.fill(state.meterLevel); + }, + }; + } + + async close() { + state.audioContextsClosed += 1; + } + } + + Object.defineProperty(window, "AudioContext", { + configurable: true, + value: MockAudioContext, + }); + Object.defineProperty(window, "openclawTalkE2eState", { + configurable: true, + value: state, + }); + }); +} + +export async function captureComposerProof(page: Page, fileName: string) { + const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls"); + await mkdir(artifactDir, { recursive: true }); + await page + .locator(".agent-chat__composer-shell") + .screenshot({ path: path.join(artifactDir, fileName) }); +} + +export async function captureVideoTalkProof(page: Page, fileName: string) { + const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "video-talk"); + await mkdir(artifactDir, { recursive: true }); + await page + .locator(".agent-chat__composer-shell") + .screenshot({ path: path.join(artifactDir, fileName) }); +} + +export async function installBlockedMicrophoneFixture(page: Page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: async () => [], + getUserMedia: async () => { + throw new DOMException("Permission denied", "NotAllowedError"); + }, + }, + }); + }); +} + +export async function installBlockedVideoTalkFixture(page: Page) { + await page.addInitScript(() => { + const getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: async (constraints: MediaStreamConstraints) => { + if (constraints.video) { + throw new DOMException("Permission denied", "NotAllowedError"); + } + return getUserMedia(constraints); + }, + }, + }); + class FakePeerConnection extends EventTarget { + connectionState = "new"; + close() { + this.connectionState = "closed"; + } + } + Object.defineProperty(window, "RTCPeerConnection", { + configurable: true, + value: FakePeerConnection, + }); + }); +} From a5c8c5b3ecc598ffbb9e4d000c73e82de6f27866 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:07:34 -0700 Subject: [PATCH 038/239] fix(googlechat): record canonical receipt thread (#116717) * fix(googlechat): preserve receipt thread identity * test(googlechat): prove receipt thread mirroring --- extensions/googlechat/src/channel.adapters.ts | 10 ++++- extensions/googlechat/src/channel.test.ts | 43 ++++++++++++++++++ .../outbound/source-reply-mirror.test.ts | 44 ++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/extensions/googlechat/src/channel.adapters.ts b/extensions/googlechat/src/channel.adapters.ts index ef279426134e..b77c98f22c87 100644 --- a/extensions/googlechat/src/channel.adapters.ts +++ b/extensions/googlechat/src/channel.adapters.ts @@ -48,6 +48,7 @@ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( function createGoogleChatSendReceipt(params: { messageId?: string; chatId: string; + threadId?: string; kind: MessageReceiptPartKind; }) { const messageId = params.messageId?.trim(); @@ -62,7 +63,7 @@ function createGoogleChatSendReceipt(params: { }, ] : [], - threadId: params.chatId, + threadId: params.threadId, kind: params.kind, }); } @@ -251,7 +252,12 @@ export const googlechatOutboundAdapter = { return { messageId, chatId: space, - receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }), + receipt: createGoogleChatSendReceipt({ + messageId, + chatId: space, + threadId: result?.threadName ?? thread, + kind: "text", + }), }; }, }, diff --git a/extensions/googlechat/src/channel.test.ts b/extensions/googlechat/src/channel.test.ts index 40d2fd8b482a..9de34f913420 100644 --- a/extensions/googlechat/src/channel.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -229,6 +229,49 @@ describe("googlechatPlugin outbound", () => { ]); }); + it("records the API thread separately from the containing space", async () => { + const cfg = createGoogleChatCfg(); + sendGoogleChatMessageMock.mockResolvedValueOnce({ + messageName: "spaces/AAA/messages/msg-canonical", + threadName: "spaces/AAA/threads/canonical", + }); + + const canonical = await googlechatOutboundAdapter.attachedResults.sendText({ + cfg, + to: "spaces/AAA", + text: "canonical", + threadId: "threads/requested", + }); + + expect(canonical.receipt.threadId).toBe("spaces/AAA/threads/canonical"); + expect(canonical.receipt.parts[0]?.threadId).toBe("spaces/AAA/threads/canonical"); + expect(canonical.receipt.raw?.[0]).toMatchObject({ + chatId: "spaces/AAA", + conversationId: "spaces/AAA", + }); + + sendGoogleChatMessageMock.mockResolvedValueOnce({ + messageName: "spaces/AAA/messages/msg-fallback", + }); + const fallback = await googlechatOutboundAdapter.attachedResults.sendText({ + cfg, + to: "spaces/AAA", + text: "fallback", + threadId: "threads/requested", + }); + expect(fallback.receipt.threadId).toBe("threads/requested"); + + sendGoogleChatMessageMock.mockResolvedValueOnce({ + messageName: "spaces/AAA/messages/msg-top-level", + }); + const topLevel = await googlechatOutboundAdapter.attachedResults.sendText({ + cfg, + to: "spaces/AAA", + text: "top level", + }); + expect(topLevel.receipt.threadId).toBeUndefined(); + }); + it("renders and chunks outbound text without requiring Google Chat runtime initialization", () => { const chunker = googlechatOutboundAdapter.base.chunker; diff --git a/src/infra/outbound/source-reply-mirror.test.ts b/src/infra/outbound/source-reply-mirror.test.ts index 45eaf9000230..1ff1916be57a 100644 --- a/src/infra/outbound/source-reply-mirror.test.ts +++ b/src/infra/outbound/source-reply-mirror.test.ts @@ -1,16 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { reconcileTerminalSourceReplyDelivery } from "./source-reply-mirror.js"; +import { + isDeliveredCurrentSourceReply, + reconcileTerminalSourceReplyDelivery, +} from "./source-reply-mirror.js"; const receiptMocks = vi.hoisted(() => ({ cancel: vi.fn(), complete: vi.fn(), })); +const channelPluginMocks = vi.hoisted(() => ({ + getChannelPlugin: vi.fn(), + getLoadedChannelPlugin: vi.fn(), +})); vi.mock("../../config/sessions/restart-recovery-receipt.js", () => ({ beginRestartRecoveryTerminalDelivery: vi.fn(), cancelRestartRecoveryTerminalDelivery: receiptMocks.cancel, completeRestartRecoveryTerminalDelivery: receiptMocks.complete, })); +vi.mock("../../channels/plugins/index.js", () => channelPluginMocks); describe("reconcileTerminalSourceReplyDelivery", () => { const receipt = { @@ -30,6 +38,8 @@ describe("reconcileTerminalSourceReplyDelivery", () => { beforeEach(() => { receiptMocks.cancel.mockReset(); receiptMocks.complete.mockReset(); + channelPluginMocks.getChannelPlugin.mockReset(); + channelPluginMocks.getLoadedChannelPlugin.mockReset(); }); it("cancels a receipt after an unambiguous explicit failure", async () => { @@ -59,3 +69,35 @@ describe("reconcileTerminalSourceReplyDelivery", () => { expect(receiptMocks.complete).not.toHaveBeenCalled(); }); }); + +describe("isDeliveredCurrentSourceReply", () => { + it("matches a canonical Google Chat thread receipt to its inbound source thread", () => { + const params = { + action: "send", + channel: "googlechat", + actionParams: { target: "spaces/AAA", message: "answer" }, + cfg: {}, + sessionKey: "agent:main:googlechat:channel:spaces/AAA", + toolContext: { + currentChannelProvider: "googlechat", + currentChannelId: "spaces/AAA", + currentThreadTs: "spaces/AAA/threads/canonical", + }, + }; + + expect( + isDeliveredCurrentSourceReply({ + ...params, + deliveredPayload: { + receipt: { threadId: "spaces/AAA/threads/canonical" }, + }, + }), + ).toBe(true); + expect( + isDeliveredCurrentSourceReply({ + ...params, + deliveredPayload: { receipt: { threadId: "spaces/AAA" } }, + }), + ).toBe(false); + }); +}); From b54e0049c5f4bb2850bdbcef5e79801eb90036fc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:56:09 +0800 Subject: [PATCH 039/239] fix(ui): bound realtime Talk PCM playback ownership --- ui/src/pages/chat/realtime-talk-audio.test.ts | 149 +++++++++++++++++- ui/src/pages/chat/realtime-talk-audio.ts | 50 +++++- 2 files changed, 190 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index a1e1ccad9e07..b0db4684e9b6 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -1,6 +1,52 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; -import { RealtimeTalkMediaStreamMeter } from "./realtime-talk-audio.ts"; +import { + bytesToBase64, + RealtimeTalkMediaStreamMeter, + RealtimeTalkPcmOutputQueue, +} from "./realtime-talk-audio.ts"; + +class MockAudioBufferSource { + buffer: unknown = null; + readonly connect = vi.fn(); + readonly start = vi.fn(); + readonly stop = vi.fn(); + private ended: (() => void) | null = null; + + addEventListener(type: string, handler: () => void): void { + if (type === "ended") { + this.ended = handler; + } + } + + emitEnded(): void { + this.ended?.(); + } +} + +class MockOutputAudioContext { + currentTime = 0; + readonly destination = {}; + readonly sources: MockAudioBufferSource[] = []; + + createBuffer(_channels: number, length: number, sampleRate: number) { + const channel = new Float32Array(length); + return { + duration: length / sampleRate, + getChannelData: () => channel, + }; + } + + createBufferSource(): MockAudioBufferSource { + const source = new MockAudioBufferSource(); + this.sources.push(source); + return source; + } +} + +function silentPcmBase64(sampleCount: number): string { + return bytesToBase64(new Uint8Array(sampleCount * 2)); +} describe("RealtimeTalkMediaStreamMeter", () => { afterEach(() => { @@ -66,3 +112,104 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(onLevel).toHaveBeenLastCalledWith(0); }); }); + +describe("RealtimeTalkPcmOutputQueue", () => { + it("preserves ordered playback while the AudioContext advances normally", () => { + const context = new MockOutputAudioContext(); + context.currentTime = 1; + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play(silentPcmBase64(100), context as unknown as AudioContext, 100)).toBe( + "queued", + ); + context.currentTime = 1.5; + expect(queue.play(silentPcmBase64(50), context as unknown as AudioContext, 100)).toBe("queued"); + + expect(context.sources.map((source) => source.start.mock.calls[0]?.[0])).toEqual([1, 2]); + expect(queue.queuedUntil).toBe(2.5); + expect(queue.isPlaying).toBe(true); + }); + + it("bounds a frozen AudioContext by queued seconds before allocating another source", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play(silentPcmBase64(600), context as unknown as AudioContext, 100)).toBe( + "queued", + ); + expect(queue.play(silentPcmBase64(500), context as unknown as AudioContext, 100)).toBe( + "overflow", + ); + + expect(context.sources).toHaveLength(1); + expect(queue.queuedUntil).toBe(6); + }); + + it("rejects an oversized frame before base64 decoding", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play("!".repeat(3_000), context as unknown as AudioContext, 100)).toBe("overflow"); + expect(context.sources).toHaveLength(0); + }); + + it("hard-caps source ownership across ten thousand suspended-context chunks", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + let queued = 0; + let overflowed = 0; + + for (let index = 0; index < 10_000; index += 1) { + const result = queue.play(silentPcmBase64(1), context as unknown as AudioContext, 48_000); + if (result === "queued") { + queued += 1; + } else if (result === "overflow") { + overflowed += 1; + } + } + + expect(queued).toBe(320); + expect(overflowed).toBe(9_680); + expect(context.sources).toHaveLength(320); + }); + + it("releases source ownership on ended", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + const chunk = silentPcmBase64(1); + + for (let index = 0; index < 320; index += 1) { + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("queued"); + } + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("overflow"); + + context.sources[0]?.emitEnded(); + + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("queued"); + expect(context.sources).toHaveLength(321); + }); + + it("stops idempotently and isolates late ended events from replacement playback", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + const chunk = silentPcmBase64(100); + + expect(queue.play(chunk, context as unknown as AudioContext, 100)).toBe("queued"); + const oldSource = context.sources[0]; + context.currentTime = 0.25; + queue.stop(context as unknown as AudioContext); + queue.stop(context as unknown as AudioContext); + + expect(oldSource?.stop).toHaveBeenCalledOnce(); + expect(queue.isPlaying).toBe(false); + expect(queue.queuedUntil).toBe(0.25); + + expect(queue.play(chunk, context as unknown as AudioContext, 100)).toBe("queued"); + const replacementSource = context.sources[1]; + oldSource?.emitEnded(); + + expect(queue.isPlaying).toBe(true); + expect(queue.queuedUntil).toBe(1.25); + expect(replacementSource?.stop).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index bdb7aef45d81..32590083661b 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -214,6 +214,16 @@ function pcm16ToFloat(bytes: Uint8Array): Float32Array { return samples; } +function base64DecodedByteLength(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((value.length * 3) / 4) - padding); +} + +const REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS = 10; +const REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES = 320; + +type RealtimeTalkPcmOutputQueuePlayResult = "queued" | "ignored" | "overflow"; + export class RealtimeTalkPcmOutputQueue { private playhead = 0; private readonly sources = new Set(); @@ -226,13 +236,34 @@ export class RealtimeTalkPcmOutputQueue { return this.sources.size > 0; } - play(base64: string, outputContext: AudioContext | null, outputSampleRateHz: number): void { + play( + base64: string, + outputContext: AudioContext | null, + outputSampleRateHz: number, + ): RealtimeTalkPcmOutputQueuePlayResult { if (!outputContext) { - return; + return "ignored"; + } + const startAt = Math.max(outputContext.currentTime, this.playhead); + const queuedSeconds = Math.max(0, startAt - outputContext.currentTime); + const remainingSeconds = REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS - queuedSeconds; + const decodedByteLength = base64DecodedByteLength(base64); + const sampleCount = Math.floor(decodedByteLength / 2); + if ( + this.sources.size >= REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES || + remainingSeconds <= 0 || + sampleCount / outputSampleRateHz > remainingSeconds + ) { + return "overflow"; } const samples = pcm16ToFloat(base64ToBytes(base64)); if (samples.length === 0) { - return; + return "ignored"; + } + const duration = samples.length / outputSampleRateHz; + const nextPlayhead = startAt + duration; + if (nextPlayhead - outputContext.currentTime > REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS) { + return "overflow"; } const buffer = outputContext.createBuffer(1, samples.length, outputSampleRateHz); buffer.getChannelData(0).set(samples); @@ -241,18 +272,21 @@ export class RealtimeTalkPcmOutputQueue { source.addEventListener("ended", () => this.sources.delete(source)); source.buffer = buffer; source.connect(outputContext.destination); - const startAt = Math.max(outputContext.currentTime, this.playhead); source.start(startAt); - this.playhead = startAt + buffer.duration; + this.playhead = nextPlayhead; + return "queued"; } stop(outputContext: AudioContext | null): void { - for (const source of this.sources) { + // Release ownership first so synchronous or late `ended` events from stopped + // sources cannot affect audio queued by a replacement playback turn. + const sources = [...this.sources]; + this.sources.clear(); + this.playhead = outputContext?.currentTime ?? 0; + for (const source of sources) { try { source.stop(); } catch {} } - this.sources.clear(); - this.playhead = outputContext?.currentTime ?? 0; } } From a33ae0b05b1801e3f7bb755992504de9e5ac58b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:56:12 +0800 Subject: [PATCH 040/239] fix(ui): cancel overflowing realtime Talk playback --- .../chat/realtime-talk-gateway-relay.test.ts | 94 +++++++++++++++++-- .../pages/chat/realtime-talk-gateway-relay.ts | 23 ++++- .../chat/realtime-talk-google-live.test.ts | 50 ++++++++++ .../pages/chat/realtime-talk-google-live.ts | 25 ++++- 4 files changed, 180 insertions(+), 12 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index c9c206720cac..80f41d8833a1 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -26,9 +26,18 @@ const inputSinks: Array<{ disconnect: ReturnType; gain: { value: number }; }> = []; +const createdSources: MockAudioBufferSource[] = []; let getUserMedia: ReturnType; let audioCurrentTime = 0; +class MockAudioBufferSource { + buffer: unknown = null; + readonly addEventListener = vi.fn(); + readonly connect = vi.fn(); + readonly start = vi.fn(); + readonly stop = vi.fn(); +} + class MockAudioContext { get currentTime(): number { return audioCurrentTime; @@ -81,13 +90,9 @@ class MockAudioContext { } createBufferSource() { - return { - addEventListener: vi.fn(), - buffer: null, - connect: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; + const source = new MockAudioBufferSource(); + createdSources.push(source); + return source; } } @@ -162,6 +167,7 @@ describe("GatewayRelayRealtimeTalkTransport", () => { listeners.clear(); processors.length = 0; inputSinks.length = 0; + createdSources.length = 0; audioCurrentTime = 0; vi.stubGlobal("AudioContext", MockAudioContext); getUserMedia = vi.fn(async () => ({ @@ -181,6 +187,7 @@ describe("GatewayRelayRealtimeTalkTransport", () => { listeners.clear(); processors.length = 0; inputSinks.length = 0; + createdSources.length = 0; }); it("preserves audio processing while selecting the exact microphone", async () => { @@ -325,6 +332,79 @@ describe("GatewayRelayRealtimeTalkTransport", () => { transport.stop(); }); + it("cancels overflowing playback and ignores late audio until provider clear", async () => { + const client = createClient(); + const transport = createTransport({ client }); + + await transport.start(); + for (let index = 0; index < 321; index += 1) { + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + } + + await waitForFast(() => + expect(requestCallsFor(client, "talk.session.cancelOutput")).toEqual([ + [ + "talk.session.cancelOutput", + { + sessionId: "relay-1", + reason: "playback-overflow", + }, + ], + ]), + ); + expect(createdSources).toHaveLength(320); + expect(createdSources.every((source) => source.stop.mock.calls.length === 1)).toBe(true); + + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + expect(createdSources).toHaveLength(320); + + emitTalkEvent({ relaySessionId: "relay-1", type: "clear" }); + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + expect(createdSources).toHaveLength(321); + expect(createdSources.at(-1)?.start).toHaveBeenCalledOnce(); + + transport.stop(); + }); + + it("cancels provider output when the first audio chunk exceeds the time budget", async () => { + const client = createClient(); + const transport = createTransport({ client }); + + await transport.start(); + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: zeroPcmBase64(24000 * 11), + }); + + await waitForFast(() => + expect(requestCallsFor(client, "talk.session.cancelOutput")).toEqual([ + [ + "talk.session.cancelOutput", + { + sessionId: "relay-1", + reason: "playback-overflow", + }, + ], + ]), + ); + expect(createdSources).toHaveLength(0); + + transport.stop(); + }); + it("acknowledges provider marks only after the local playback queue drains", async () => { vi.useFakeTimers(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.ts index b71189dfedf1..b9b71775bc9c 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.ts @@ -42,6 +42,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport private readonly delayedToolResults = new Set(); private readonly markAckTimers = new Set(); private cancelRequestedForPlayback = false; + private playbackOverflowed = false; private pendingOutputCancellations = 0; private speechFramesDuringPlayback = 0; private lastRelayError: string | undefined; @@ -120,6 +121,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.abortConsults(); this.media?.getTracks().forEach((track) => track.stop()); this.media = null; + this.playbackOverflowed = false; this.stopOutput(); void this.inputContext?.close(); this.inputContext = null; @@ -196,13 +198,14 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.ctx.callbacks.onStatus?.("listening"); return; case "audio": - if (event.audioBase64) { + if (event.audioBase64 && !this.playbackOverflowed) { this.cancelRequestedForPlayback = false; this.speechFramesDuringPlayback = 0; this.playPcm16(event.audioBase64); } return; case "clear": + this.playbackOverflowed = false; this.stopOutput({ releaseDelayedToolResults: this.pendingOutputCancellations === 0 }); if (event.talkEvent?.type === "turn.cancelled") { this.abortConsults(); @@ -251,7 +254,15 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport } private playPcm16(base64: string): void { - this.outputQueue.play(base64, this.outputContext, this.session.audio.outputSampleRateHz); + const result = this.outputQueue.play( + base64, + this.outputContext, + this.session.audio.outputSampleRateHz, + ); + if (result === "overflow") { + this.playbackOverflowed = true; + this.cancelOutput("playback-overflow", false); + } } private stopOutput(options: { releaseDelayedToolResults?: boolean } = {}): void { @@ -493,7 +504,11 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport } private cancelOutputForBargeIn(): void { - if (!this.outputQueue.isPlaying || this.cancelRequestedForPlayback) { + this.cancelOutput("barge-in"); + } + + private cancelOutput(reason: string, requirePlayback = true): void { + if ((requirePlayback && !this.outputQueue.isPlaying) || this.cancelRequestedForPlayback) { return; } this.cancelRequestedForPlayback = true; @@ -505,7 +520,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport void this.ctx.client .request("talk.session.cancelOutput", { sessionId: this.session.relaySessionId, - reason: "barge-in", + reason, }) .then( () => { diff --git a/ui/src/pages/chat/realtime-talk-google-live.test.ts b/ui/src/pages/chat/realtime-talk-google-live.test.ts index 044a0339438a..f71a36e2ea5e 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.test.ts @@ -405,6 +405,56 @@ describe("GoogleLiveRealtimeTalkTransport", () => { expect(cancelledEvent?.payload).toStrictEqual({ reason: "provider-interrupted" }); }); + it("closes an overflowing playback response and ignores late provider audio", async () => { + const onStatus = vi.fn(); + const onTalkEvent = vi.fn(); + const transport = createTransport({ onStatus, onTalkEvent }); + await transport.start(); + const ws = latestWebSocket(); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: Array.from({ length: 321 }, () => ({ + inlineData: { data: "AAAA", mimeType: "audio/pcm;rate=24000" }, + })), + }, + }, + }), + ); + + await waitForFast(() => + expect(onStatus).toHaveBeenCalledWith( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ), + ); + expect(createdSources).toHaveLength(320); + expect(createdSources.every((source) => source.stop.mock.calls.length === 1)).toBe(true); + expect(ws.readyState).toBe(3); + expect( + onTalkEvent.mock.calls.some( + ([event]) => + event.type === "turn.cancelled" && + event.final === true && + event.payload?.reason === "playback-overflow", + ), + ).toBe(true); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: [{ inlineData: { data: "AAAA", mimeType: "audio/pcm;rate=24000" } }], + }, + }, + }), + ); + await flushMicrotasks(); + expect(createdSources).toHaveLength(320); + }); + it("emits common Talk events for Google Live transcript and audio frames", async () => { const onTranscript = vi.fn(); const onTalkEvent = vi.fn(); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index 12b6ef7bac9b..2cb07fd784b3 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -369,7 +369,30 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { } private playPcm16(base64: string): void { - this.outputQueue.play(base64, this.outputContext, this.session.audio.outputSampleRateHz); + if (this.closed) { + return; + } + const result = this.outputQueue.play( + base64, + this.outputContext, + this.session.audio.outputSampleRateHz, + ); + if (result !== "overflow") { + return; + } + this.stopOutput(); + this.emitTalkEvent({ + type: "turn.cancelled", + final: true, + payload: { reason: "playback-overflow" }, + }); + this.ctx.callbacks.onStatus?.( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ); + // Google Live exposes server-driven interruption but no client response-cancel + // frame, so closing the session is the only deterministic provider-side stop. + this.stop(); } private stopOutput(): void { From 2838a2a0ea5c2437c915979ccc58c1ee44d21b51 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:58 +0800 Subject: [PATCH 041/239] fix(ui): reject oversized Talk audio before decoding --- ui/src/pages/chat/realtime-talk-audio.ts | 6 ++-- .../chat/realtime-talk-google-live.test.ts | 33 +++++++++++++++++++ .../pages/chat/realtime-talk-google-live.ts | 7 ++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index 32590083661b..8b4511d69ecd 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -9,7 +9,7 @@ export function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } -export function base64ToBytes(value: string): Uint8Array { +function base64ToBytes(value: string): Uint8Array { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { @@ -214,7 +214,7 @@ function pcm16ToFloat(bytes: Uint8Array): Float32Array { return samples; } -function base64DecodedByteLength(value: string): number { +export function estimateBase64DecodedByteLength(value: string): number { const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; return Math.max(0, Math.floor((value.length * 3) / 4) - padding); } @@ -247,7 +247,7 @@ export class RealtimeTalkPcmOutputQueue { const startAt = Math.max(outputContext.currentTime, this.playhead); const queuedSeconds = Math.max(0, startAt - outputContext.currentTime); const remainingSeconds = REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS - queuedSeconds; - const decodedByteLength = base64DecodedByteLength(base64); + const decodedByteLength = estimateBase64DecodedByteLength(base64); const sampleCount = Math.floor(decodedByteLength / 2); if ( this.sources.size >= REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES || diff --git a/ui/src/pages/chat/realtime-talk-google-live.test.ts b/ui/src/pages/chat/realtime-talk-google-live.test.ts index f71a36e2ea5e..8e122f11dbb6 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.test.ts @@ -455,6 +455,39 @@ describe("GoogleLiveRealtimeTalkTransport", () => { expect(createdSources).toHaveLength(320); }); + it("rejects an oversized first frame before decoding provider audio", async () => { + const onStatus = vi.fn(); + const transport = createTransport({ onStatus }); + await transport.start(); + const ws = latestWebSocket(); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: [ + { + inlineData: { + data: "!".repeat(700_000), + mimeType: "audio/pcm;rate=24000", + }, + }, + ], + }, + }, + }), + ); + + await waitForFast(() => + expect(onStatus).toHaveBeenCalledWith( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ), + ); + expect(createdSources).toHaveLength(0); + expect(ws.readyState).toBe(3); + }); + it("emits common Talk events for Google Live transcript and audio frames", async () => { const onTranscript = vi.fn(); const onTalkEvent = vi.fn(); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index 2cb07fd784b3..34c6efd0dd6c 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -1,8 +1,8 @@ // Control UI chat module implements realtime talk google live behavior. import { REALTIME_VOICE_DESCRIBE_VIEW_TOOL_NAME } from "../../../../src/talk/describe-view-tool.js"; import { - base64ToBytes, bytesToBase64, + estimateBase64DecodedByteLength, floatToPcm16, RealtimeTalkMediaStreamMeter, RealtimeTalkPcmInputPump, @@ -340,11 +340,14 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { this.emitTalkEvent({ type: "output.audio.delta", payload: { - byteLength: base64ToBytes(part.inlineData.data).byteLength, + byteLength: estimateBase64DecodedByteLength(part.inlineData.data), mimeType: part.inlineData.mimeType, }, }); this.playPcm16(part.inlineData.data); + if (this.closed) { + return; + } } else if (!part.thought && typeof part.text === "string" && part.text.trim()) { this.ctx.callbacks.onTranscript?.({ role: "assistant", From 07a352ac95681f036853eaa32215d6f54aec4c70 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:10:40 +0800 Subject: [PATCH 042/239] fix(openai): narrow idle lifecycle cancellation --- extensions/openai/realtime-voice-lifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index 5f136ca16587..1bfe25ef5556 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -89,7 +89,7 @@ export class OpenAIRealtimeVoiceLifecycle { if (state.phase === "terminal") { return false; } - if (state.phase === "idle") { + if (!("controller" in state)) { this.state = { phase: "terminal", terminalOutcome: "completed", From 4b0c77b256a250059e7dd4afb78d8f3df7d5ecca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:13:54 -0700 Subject: [PATCH 043/239] fix(gateway): admit standalone MCP app work (#116727) --- .../server-http.mcp-app-admission.test.ts | 166 ++++++++++++++++++ src/gateway/server-http.ts | 15 +- 2 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/gateway/server-http.mcp-app-admission.test.ts diff --git a/src/gateway/server-http.mcp-app-admission.test.ts b/src/gateway/server-http.mcp-app-admission.test.ts new file mode 100644 index 000000000000..8b670969f883 --- /dev/null +++ b/src/gateway/server-http.mcp-app-admission.test.ts @@ -0,0 +1,166 @@ +// Proves standalone MCP App HTTP work participates in Gateway suspension admission. +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + getActiveGatewayRootWorkCount, + markGatewayRestartDraining, + resetGatewayWorkAdmission, + tryBeginGatewaySuspendAdmission, + waitForActiveGatewayRootWork, +} from "../process/gateway-work-admission.js"; + +const mocks = vi.hoisted(() => ({ + handleMcpAppStandaloneHttpRequest: vi.fn(), +})); + +vi.mock("./mcp-app-standalone.js", () => ({ + handleMcpAppStandaloneHttpRequest: mocks.handleMcpAppStandaloneHttpRequest, +})); + +import { + AUTH_NONE, + createRequest, + createResponse, + dispatchRequest, + withGatewayServer, +} from "./server-http.test-harness.js"; + +const MCP_APP_PATH = "/__openclaw__/mcp-app"; + +function deferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function mcpAppsConfig(): OpenClawConfig { + return { + gateway: { trustedProxies: [] }, + mcp: { apps: { enabled: true } }, + }; +} + +async function withMcpAppServer( + run: (server: Parameters[0]) => Promise, +): Promise { + await withGatewayServer({ + prefix: "mcp-app-http-admission", + resolvedAuth: AUTH_NONE, + overrides: { getRuntimeConfig: mcpAppsConfig }, + run, + }); +} + +beforeEach(() => { + resetGatewayWorkAdmission(); + mocks.handleMcpAppStandaloneHttpRequest.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + resetGatewayWorkAdmission(); +}); + +describe("standalone MCP App HTTP admission", () => { + it("rejects new requests with the canonical 503 after admission closes", async () => { + mocks.handleMcpAppStandaloneHttpRequest.mockImplementation( + (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 204; + res.end(); + return true; + }, + ); + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension?.commit()).toBe(true); + + await withMcpAppServer(async (server) => { + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: MCP_APP_PATH }), response.res); + + expect(mocks.handleMcpAppStandaloneHttpRequest).not.toHaveBeenCalled(); + expect(response.res.statusCode).toBe(503); + expect(response.setHeader).toHaveBeenCalledWith("Retry-After", "1"); + expect(JSON.parse(response.getBody())).toMatchObject({ + error: { code: "gateway_unavailable" }, + }); + expect(getActiveGatewayRootWorkCount()).toBe(0); + }); + + expect(suspension?.release()).toBe(true); + }); + + it("keeps deferred handler work visible until it settles", async () => { + const started = deferred(); + const finish = deferred(); + mocks.handleMcpAppStandaloneHttpRequest.mockImplementation( + async (_req: IncomingMessage, res: ServerResponse) => { + started.resolve(); + await finish.promise; + res.statusCode = 200; + res.end("ok"); + return true; + }, + ); + + await withMcpAppServer(async (server) => { + const response = createResponse(); + const pending = dispatchRequest(server, createRequest({ path: MCP_APP_PATH }), response.res); + await started.promise; + + try { + expect(getActiveGatewayRootWorkCount()).toBe(1); + markGatewayRestartDraining(); + await expect(waitForActiveGatewayRootWork(0)).resolves.toEqual({ + drained: false, + active: 1, + }); + } finally { + finish.resolve(); + } + await pending; + expect(response.res.statusCode).toBe(200); + // The response mock resolves from res.end(), immediately before the + // admission wrapper's finally block releases the request root. + await vi.waitFor(() => expect(getActiveGatewayRootWorkCount()).toBe(0)); + await expect(waitForActiveGatewayRootWork(0)).resolves.toEqual({ + drained: true, + active: 0, + }); + }); + }); + + it("releases admission when the standalone handler fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.handleMcpAppStandaloneHttpRequest.mockRejectedValue(new Error("standalone failed")); + + await withMcpAppServer(async (server) => { + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: MCP_APP_PATH }), response.res); + + expect(response.res.statusCode).toBe(500); + expect(response.getBody()).toBe("Internal Server Error"); + expect(getActiveGatewayRootWorkCount()).toBe(0); + expect(errorLog).toHaveBeenCalledWith( + "[gateway-http] unhandled error in request handler:", + expect.objectContaining({ message: "standalone failed" }), + ); + }); + }); + + it("releases admission and preserves fallthrough when the handler declines", async () => { + mocks.handleMcpAppStandaloneHttpRequest.mockResolvedValue(false); + + await withMcpAppServer(async (server) => { + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: MCP_APP_PATH }), response.res); + + expect(mocks.handleMcpAppStandaloneHttpRequest).toHaveBeenCalledOnce(); + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); + expect(getActiveGatewayRootWorkCount()).toBe(0); + }); + }); +}); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 9439e68f621c..b1e98833b348 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -876,13 +876,14 @@ export function createGatewayHttpServer(opts: { if (configSnapshot.mcp?.apps?.enabled === true && isMcpAppStandalonePath(scopedRequestPath)) { requestStages.push({ name: "mcp-app-standalone", - run: async () => { - const standalone = await getMcpAppStandaloneModule(); - return await standalone.handleMcpAppStandaloneHttpRequest(req, res, { - sandboxPort: configSnapshot.mcp?.apps?.sandboxPort, - sandboxOrigin: configSnapshot.mcp?.apps?.sandboxOrigin, - }); - }, + run: async () => + await runWithGatewayHttpWorkAdmission(res, async () => { + const standalone = await getMcpAppStandaloneModule(); + return await standalone.handleMcpAppStandaloneHttpRequest(req, res, { + sandboxPort: configSnapshot.mcp?.apps?.sandboxPort, + sandboxOrigin: configSnapshot.mcp?.apps?.sandboxOrigin, + }); + }), }); } // Plugin routes run before the general Control UI SPA catch-all so From f03dc9785e48812ed2e7a5194f8370e59ea7d754 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:14:19 -0700 Subject: [PATCH 044/239] fix(telegram): keep typing alive during active steered tasks (#116721) --- extensions/telegram/src/bot-core.ts | 5 +- .../telegram/src/bot-message-dispatch-turn.ts | 5 ++ ...bot-message-dispatch.pipeline-init.test.ts | 13 +++++ extensions/telegram/src/chat-action-timing.ts | 3 ++ src/auto-reply/reply/agent-runner-run.ts | 9 ++++ .../agent-runner.runreplyagent.e2e.test.ts | 39 +++++++++++++++ src/auto-reply/reply/reply-run-typing.ts | 36 +++++++++++++ src/channels/typing.test.ts | 50 +++++++++++++++++++ src/channels/typing.ts | 4 +- 9 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 extensions/telegram/src/chat-action-timing.ts create mode 100644 src/auto-reply/reply/reply-run-typing.ts diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index c0cf86f3c5f1..e3cb36cf0b71 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -46,6 +46,7 @@ import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.r import type { TelegramBotOptions } from "./bot.types.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { setTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js"; +import { TELEGRAM_CHAT_ACTION_INTERVAL_MS } from "./chat-action-timing.js"; import { asTelegramClientFetch, createTelegramClientFetch, @@ -77,8 +78,6 @@ const DEFAULT_TELEGRAM_BOT_RUNTIME: TelegramBotRuntime = { sequentialize, apiThrottler, }; -const TELEGRAM_TYPING_COALESCE_MS = 4_000; - export function createTelegramBotCore( opts: TelegramBotOptions & { telegramDeps: TelegramBotDeps }, ): TelegramBotInstance { @@ -354,7 +353,7 @@ export function createTelegramBotCore( sendChatActionFn: (chatId, action, threadParams) => bot.api.sendChatAction(chatId, action, threadParams), logger: (message) => logVerbose(`telegram: ${message}`), - minIntervalMs: TELEGRAM_TYPING_COALESCE_MS, + minIntervalMs: TELEGRAM_CHAT_ACTION_INTERVAL_MS, }); const processMessage = createTelegramMessageProcessor({ diff --git a/extensions/telegram/src/bot-message-dispatch-turn.ts b/extensions/telegram/src/bot-message-dispatch-turn.ts index dba60b3a4af5..b2bc25178bd1 100644 --- a/extensions/telegram/src/bot-message-dispatch-turn.ts +++ b/extensions/telegram/src/bot-message-dispatch-turn.ts @@ -19,6 +19,7 @@ import type { TelegramProgressController } from "./bot-message-dispatch-progress import type { TelegramReplyDelivery } from "./bot-message-dispatch-reply.js"; import type { TelegramDispatchTurnState } from "./bot-message-dispatch.types.js"; import type { TelegramStreamMode } from "./bot/types.js"; +import { TELEGRAM_CHAT_ACTION_INTERVAL_MS } from "./chat-action-timing.js"; import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; const TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES = 5; @@ -70,6 +71,10 @@ export async function runTelegramDispatchTurn(params: { accountId: context.route.accountId, typing: { start: context.sendTyping, + keepaliveIntervalMs: TELEGRAM_CHAT_ACTION_INTERVAL_MS, + // ReplyOperation owns terminal cleanup; a per-inbound TTL would kill + // feedback while the same long-running task is still active. + maxDurationMs: 0, maxConsecutiveFailures: TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES, onStartError: (err) => { logTypingFailure({ diff --git a/extensions/telegram/src/bot-message-dispatch.pipeline-init.test.ts b/extensions/telegram/src/bot-message-dispatch.pipeline-init.test.ts index 1b7d3267d197..24c4159c1b38 100644 --- a/extensions/telegram/src/bot-message-dispatch.pipeline-init.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.pipeline-init.test.ts @@ -11,6 +11,19 @@ import type { TelegramMessageContext } from "./bot-message-dispatch.test-harness import { notifyTelegramInboundEventOutboundSuccess } from "./inbound-event-delivery.js"; describeTelegramDispatch("dispatchTelegramMessage pipeline-init", () => { + it("keeps Telegram typing below its client expiry without a per-message cutoff", async () => { + await dispatchWithContext({ context: createContext() }); + + expect(createChannelMessageReplyPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + typing: expect.objectContaining({ + keepaliveIntervalMs: 4_000, + maxDurationMs: 0, + }), + }), + ); + }); + it("cleans delivery correlation when reply-pipeline initialization fails", async () => { const sessionKey = "agent:main:telegram:direct:pipeline-init-failure"; const statusReactionController = createStatusReactionController(); diff --git a/extensions/telegram/src/chat-action-timing.ts b/extensions/telegram/src/chat-action-timing.ts new file mode 100644 index 000000000000..e782c6481bb4 --- /dev/null +++ b/extensions/telegram/src/chat-action-timing.ts @@ -0,0 +1,3 @@ +// Telegram typing expires after five seconds; renew before that without +// fighting the account-scoped sendChatAction coalescing window. +export const TELEGRAM_CHAT_ACTION_INTERVAL_MS = 4_000; diff --git a/src/auto-reply/reply/agent-runner-run.ts b/src/auto-reply/reply/agent-runner-run.ts index 3e03df5ef454..39617776aacd 100644 --- a/src/auto-reply/reply/agent-runner-run.ts +++ b/src/auto-reply/reply/agent-runner-run.ts @@ -57,6 +57,7 @@ import { enqueueFollowupRun, type FollowupRun, scheduleFollowupDrain } from "./q import { createReplyMediaContext } from "./reply-media-paths.js"; import { resolveReplyOperationRunState } from "./reply-operation-run-state.js"; import { type ReplyOperation, replyRunRegistry } from "./reply-run-registry.js"; +import { bindReplyOperationTyping, refreshReplyOperationTyping } from "./reply-run-typing.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js"; import { @@ -331,6 +332,13 @@ export async function runReplyAgent( if (followupRun.currentInboundAudio === true) { activeReplyOperation?.markAcceptedSteeredInboundAudio(); } + if (activeReplyOperation) { + // Steering joins the existing task; its dispatch-local controller is + // disposable, while the task-owned controller must keep its lifetime. + await refreshReplyOperationTyping(activeReplyOperation, { + startIfIdle: typingSignals.shouldStartImmediately, + }); + } await touchActiveSessionEntry(); typing.cleanup(); return undefined; @@ -557,6 +565,7 @@ export async function runReplyAgent( } } } + bindReplyOperationTyping(replyOperation, typing); let runFollowupTurn = queuedRunFollowupTurn; let shouldDrainQueuedFollowupsAfterClear = false; const returnWithQueuedFollowupDrain = (value: T): T => { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index cfe5fefaa52e..01a286de97f0 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -42,6 +42,7 @@ import { } from "./reply-operation-run-state.js"; import { createReplyOperation, type ReplyOperation } from "./reply-run-registry.js"; import { testing as replyRunTesting } from "./reply-run-registry.test-support.js"; +import { bindReplyOperationTyping } from "./reply-run-typing.js"; import { consumeReplyUsageState } from "./reply-usage-state.js"; import { buildChannelSourceTurnId, setChannelSourceTurnId } from "./source-turn-id.js"; import { createMockTypingController } from "./test-helpers.js"; @@ -437,6 +438,44 @@ function requireBuiltChannelSourceTurnId( } describe("runReplyAgent active steering", () => { + it("keeps the continuing Telegram task's typing alive after an accepted steer", async () => { + state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true); + const active = createReplyOperation({ + sessionKey: "main", + sessionId: "session", + resetTriggered: false, + }); + active.setPhase("running"); + const taskTyping = createMockTypingController({ isActive: vi.fn(() => true) }); + bindReplyOperationTyping(active, taskTyping); + const { run, typing } = createMinimalRun({ + isActive: true, + isStreaming: true, + shouldSteer: true, + resolvedQueueMode: "steer", + sessionCtx: { + Provider: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "123", + NativeChannelId: "123", + MessageSid: "steer-telegram", + }, + runOverrides: { agentId: "main", messageProvider: "telegram" }, + }); + + await expect(run()).resolves.toBeUndefined(); + + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); + expect(taskTyping.startTypingLoop).toHaveBeenCalledOnce(); + expect(taskTyping.refreshTypingTtl).toHaveBeenCalledOnce(); + expect(taskTyping.cleanup).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledOnce(); + + active.complete(); + + expect(taskTyping.cleanup).toHaveBeenCalledOnce(); + }); + it("dispatches a declined steer once with its source-turn identity", async () => { const runState: ReplyOperationRunState = {}; state.beforeAgentReplyHasHooksMock.mockImplementation( diff --git a/src/auto-reply/reply/reply-run-typing.ts b/src/auto-reply/reply/reply-run-typing.ts new file mode 100644 index 000000000000..f2ca73edb83f --- /dev/null +++ b/src/auto-reply/reply/reply-run-typing.ts @@ -0,0 +1,36 @@ +import { runAfterReplyOperationClear, type ReplyOperation } from "./reply-run-registry.js"; +import type { TypingController } from "./typing.js"; + +const typingByReplyOperation = new WeakMap(); + +/** Keep one feedback controller attached to the task that owns a reply run. */ +export function bindReplyOperationTyping( + operation: ReplyOperation, + typing: TypingController, +): void { + if (typingByReplyOperation.has(operation)) { + return; + } + typingByReplyOperation.set(operation, typing); + runAfterReplyOperationClear(operation, () => { + if (typingByReplyOperation.get(operation) !== typing) { + return; + } + typingByReplyOperation.delete(operation); + typing.cleanup(); + }); +} + +/** Refresh the continuing task's feedback after it adopts another inbound turn. */ +export async function refreshReplyOperationTyping( + operation: ReplyOperation, + options: { startIfIdle: boolean }, +): Promise { + const typing = typingByReplyOperation.get(operation); + if (!typing || operation.result || (!options.startIfIdle && !typing.isActive())) { + return false; + } + await typing.startTypingLoop(); + typing.refreshTypingTtl(); + return true; +} diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index 7a0bd9424e93..58797915bfec 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -169,6 +169,56 @@ describe("createTypingCallbacks", () => { }); }); + it("preserves the existing keepalive cadence when an active reply starts again", async () => { + await withFakeTimers(async () => { + const { start, callbacks } = createTypingHarness({ keepaliveIntervalMs: 4_000 }); + + await callbacks.onReplyStart(); + await vi.advanceTimersByTimeAsync(3_000); + await callbacks.onReplyStart(); + expect(start).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(start).toHaveBeenCalledTimes(3); + }); + }); + + it("keeps coalesced typing alive beyond 60 seconds while the same task refreshes it", async () => { + await withFakeTimers(async () => { + vi.setSystemTime(0); + const acceptedStarts: number[] = []; + const { callbacks } = createTypingHarness({ + keepaliveIntervalMs: 4_000, + maxDurationMs: 0, + start: async () => { + const now = Date.now(); + const previous = acceptedStarts.at(-1); + if (previous !== undefined && now - previous < 4_000) { + return; + } + acceptedStarts.push(now); + }, + }); + + await callbacks.onReplyStart(); + for (let elapsedMs = 6_000; elapsedMs <= 132_000; elapsedMs += 6_000) { + await vi.advanceTimersByTimeAsync(6_000); + await callbacks.onReplyStart(); + } + + expect(acceptedStarts.at(-1)).toBeGreaterThan(120_000); + for (let index = 1; index < acceptedStarts.length; index += 1) { + expect(acceptedStarts[index]! - acceptedStarts[index - 1]!).toBeLessThanOrEqual(4_000); + } + + callbacks.onIdle?.(); + const countAtTaskCompletion = acceptedStarts.length; + await vi.advanceTimersByTimeAsync(12_000); + expect(acceptedStarts).toHaveLength(countAtTaskCompletion); + }); + }); + it("stops keepalive after consecutive start failures", async () => { await withFakeTimers(async () => { const { start, onStartError, callbacks } = createTypingHarness({ diff --git a/src/channels/typing.ts b/src/channels/typing.ts index 784a98d9e533..91e0951baa62 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -97,13 +97,15 @@ export function createTypingCallbacks(params: CreateTypingCallbacksParams): Typi } stopSent = false; startGuard.reset(); - keepaliveLoop.stop(); clearTtlTimer(); const startPromise = fireStart(); void startPromise.then(() => { if (closed || startGuard.isTripped()) { return; } + // Core can refresh an active reply independently of this channel loop. + // Restarting the interval here shifts its deadline and can outlive a + // provider's visible typing window between consecutive renewals. keepaliveLoop.start(); startTtlTimer(); }); From f1cd165075b60b730c1037732f963ee8eb9a84ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:17:36 -0700 Subject: [PATCH 045/239] fix(agents): unblock channel turns after restart recovery (#116728) Co-authored-by: Peter Steinberger --- src/agents/main-session-recovery-lifecycle.ts | 9 ++- ...ain-session-recovery-run-ownership.test.ts | 75 +++++++++++++++++++ .../main-session-recovery-state.test.ts | 4 +- src/agents/main-session-recovery-state.ts | 28 ++++--- .../main-session-recovery-store.test.ts | 29 +++++++ .../main-session-restart-recovery-marking.ts | 43 ++++------- src/gateway/session-lifecycle-state.test.ts | 37 +++++++++ 7 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 src/agents/main-session-recovery-run-ownership.test.ts diff --git a/src/agents/main-session-recovery-lifecycle.ts b/src/agents/main-session-recovery-lifecycle.ts index 8066c98f5a66..5fda42d1e9d3 100644 --- a/src/agents/main-session-recovery-lifecycle.ts +++ b/src/agents/main-session-recovery-lifecycle.ts @@ -102,8 +102,15 @@ export function projectMainSessionRecoveryLifecycle(params: { lifecycleGeneration && runs?.some((run) => run.runId === runId && run.lifecycleGeneration === lifecycleGeneration), ); + // The current owner retires stale generations of its own run id. An older + // delayed event consumes only its matching fence and cannot settle its replacement. const remaining = matchesFence - ? runs?.filter((run) => run.runId !== runId || run.lifecycleGeneration !== lifecycleGeneration) + ? runs?.filter( + (run) => + run.runId !== runId || + (lifecycleGeneration !== params.currentLifecycleGeneration && + run.lifecycleGeneration !== lifecycleGeneration), + ) : runs; if (settlesRecovery) { const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims; diff --git a/src/agents/main-session-recovery-run-ownership.test.ts b/src/agents/main-session-recovery-run-ownership.test.ts new file mode 100644 index 000000000000..e41f6d77adf7 --- /dev/null +++ b/src/agents/main-session-recovery-run-ownership.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; +import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js"; + +function recoveryEntry(params?: { hasCurrentOwner?: boolean }): SessionEntry { + return { + sessionId: "session-1", + updatedAt: 100, + status: "running", + abortedLastRun: false, + restartRecoveryRuns: [ + { runId: "recovery", lifecycleGeneration: "generation-old" }, + { runId: "recovery", lifecycleGeneration: "generation-current" }, + ], + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 5, + chargedAttempts: 2, + ...(params?.hasCurrentOwner + ? { + foregroundClaims: { + lifecycleGeneration: "generation-current", + tokens: ["current-owner"], + }, + } + : {}), + }, + }; +} + +describe("main-session recovery run ownership", () => { + it("settles a resumed run once when older generations retain the same run id", () => { + expect( + projectMainSessionRecoveryLifecycle({ + currentLifecycleGeneration: "generation-current", + entry: recoveryEntry(), + event: { + runId: "recovery", + lifecycleGeneration: "generation-current", + data: { phase: "end" }, + }, + snapshotPatch: { status: "done", abortedLastRun: false }, + }), + ).toEqual({ + action: "apply", + patch: { + status: "done", + abortedLastRun: false, + restartRecoveryRuns: undefined, + mainRestartRecovery: undefined, + }, + }); + }); + + it("does not let an older same-id terminal settle its replacement generation", () => { + expect( + projectMainSessionRecoveryLifecycle({ + currentLifecycleGeneration: "generation-current", + entry: recoveryEntry({ hasCurrentOwner: true }), + event: { + runId: "recovery", + lifecycleGeneration: "generation-old", + data: { phase: "end" }, + }, + snapshotPatch: { status: "done", abortedLastRun: false }, + }), + ).toEqual({ + action: "apply", + patch: { + restartRecoveryRuns: [{ runId: "recovery", lifecycleGeneration: "generation-current" }], + restartRecoveryTerminalRunIds: ["recovery"], + }, + }); + }); +}); diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index 861b93f7d8ac..58a529f057f7 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -92,7 +92,7 @@ describe("main session recovery state", () => { expect(entry).toEqual(before); }); - it("marks without charging and preserves generation-scoped lifecycle fences", () => { + it("marks without charging and replaces an older lifecycle owner for the same run", () => { const entry = interruptedEntry({ restartRecoveryRuns: [ { runId: "older-run", lifecycleGeneration: "generation-old" }, @@ -123,7 +123,6 @@ describe("main session recovery state", () => { expect(entry.restartRecoveryRuns).toEqual([ { runId: "new-run", lifecycleGeneration: "generation-2" }, { runId: "older-run", lifecycleGeneration: "generation-old" }, - { runId: "shared-run", lifecycleGeneration: "generation-1" }, { runId: "shared-run", lifecycleGeneration: "generation-2" }, ]); }); @@ -422,6 +421,7 @@ describe("main session recovery state", () => { pendingFinalDelivery: { kind: "replayable", text: " captured reply ", createdAt: 1 }, restartRecoveryDeliveryRunId: "recovery-1", restartRecoveryDeliverySourceRunId: "source-1", + restartRecoveryRuns: [{ runId: "recovery-1", lifecycleGeneration: "generation-old" }], mainRestartRecovery: recoveryState({ revision: 2, chargedAttempts: 1, diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index 35d80dda3d6c..de54fc8b5a5b 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -102,20 +102,28 @@ function validateRecoveryAdmission( return hasCurrentForegroundClaim(state, command.lifecycleGeneration) ? "foreground_active" : null; } -function recordLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): void { - // Lifecycle fences can overlap and are consumed independently by their matching events. - const runs = new Map(); - for (const existing of entry.restartRecoveryRuns ?? []) { - runs.set(`${existing.runId}\u0000${existing.lifecycleGeneration}`, existing); +/** Keeps distinct concurrent runs while transferring each run id to its newest lifecycle owner. */ +export function normalizeMainSessionRecoveryRunFences( + runs: Iterable, +): RestartRecoveryRun[] { + const ownersByRunId = new Map(); + for (const run of runs) { + ownersByRunId.set(run.runId, run); } - runs.set(`${run.runId}\u0000${run.lifecycleGeneration}`, run); - entry.restartRecoveryRuns = [...runs.values()].toSorted((a, b) => - a.runId === b.runId - ? a.lifecycleGeneration.localeCompare(b.lifecycleGeneration) - : a.runId.localeCompare(b.runId), + return [...ownersByRunId.values()].toSorted((left, right) => + left.runId.localeCompare(right.runId), ); } +function recordLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): void { + // A resumed run keeps its id across Gateway generations. Leaving its old fence + // behind makes terminal settlement preserve a dead owner and blocks every later turn. + entry.restartRecoveryRuns = normalizeMainSessionRecoveryRunFences([ + ...(entry.restartRecoveryRuns ?? []), + run, + ]); +} + function hasLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): boolean { return Boolean( entry.restartRecoveryRuns?.some( diff --git a/src/agents/main-session-recovery-store.test.ts b/src/agents/main-session-recovery-store.test.ts index aed1262c28b3..e43c1f058d1a 100644 --- a/src/agents/main-session-recovery-store.test.ts +++ b/src/agents/main-session-recovery-store.test.ts @@ -241,6 +241,35 @@ describe("main session recovery store", () => { expect(readStore()[legacyKey]).toMatchObject({ abortedLastRun: true }); }); + it("transfers a resumed recovery run to one durable lifecycle owner", async () => { + await write( + interruptedEntry({ + restartRecoveryRuns: [{ runId: "recovery-1", lifecycleGeneration: "generation-old" }], + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 2, + chargedAttempts: 1, + reservation: { runId: "recovery-1", attempt: 1, lifecycleGeneration }, + }, + }), + ); + + const admitted = await commitMainSessionRecovery({ + command: { + kind: "admit_recovery", + lifecycleGeneration, + now: 300, + runId: "recovery-1", + sessionId: "session-1", + }, + target: { sessionKey, storePath }, + }); + + expect(admitted.transition).toEqual({ kind: "admitted_recovery" }); + expect(read().restartRecoveryRuns).toEqual([{ runId: "recovery-1", lifecycleGeneration }]); + expect(read().abortedLastRun).toBe(false); + }); + it("rejects an observation after the session is replaced", async () => { await write({ sessionId: "session-2", diff --git a/src/agents/main-session-restart-recovery-marking.ts b/src/agents/main-session-restart-recovery-marking.ts index 4c97e07fca06..9dba9de455a0 100644 --- a/src/agents/main-session-restart-recovery-marking.ts +++ b/src/agents/main-session-restart-recovery-marking.ts @@ -20,7 +20,10 @@ import { listActiveEmbeddedRunSessionIds, listActiveEmbeddedRunSessionKeys, } from "./embedded-agent-runner/run-state.js"; -import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; +import { + normalizeMainSessionRecoveryRunFences, + transitionMainSessionRecovery, +} from "./main-session-recovery-state.js"; import { hasCurrentProcessOwner, log, @@ -189,34 +192,16 @@ export async function markRestartAbortedMainSessions(params: { continue; } const wasRunning = entry.status === "running"; - const recoveryRuns = new Map(); - for (const run of entry.restartRecoveryRuns ?? []) { - if (run.lifecycleGeneration === currentLifecycleGeneration) { - recoveryRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, run); - } - } - const replaceActiveRunMarker = (run: RestartRecoveryRun) => { - for (const [key, existingRun] of recoveryRuns) { - if (existingRun.runId === run.runId) { - recoveryRuns.delete(key); - } - } - recoveryRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, run); - }; - for (const run of registeredActiveRuns) { - replaceActiveRunMarker(run); - } - for (const run of matchingActiveRuns) { - replaceActiveRunMarker({ - runId: run.runId, - lifecycleGeneration: run.lifecycleGeneration, - }); - } - entry.restartRecoveryRuns = [...recoveryRuns.values()].toSorted((a, b) => - a.runId === b.runId - ? a.lifecycleGeneration.localeCompare(b.lifecycleGeneration) - : a.runId.localeCompare(b.runId), - ); + entry.restartRecoveryRuns = normalizeMainSessionRecoveryRunFences([ + ...(entry.restartRecoveryRuns ?? []).filter( + (run) => run.lifecycleGeneration === currentLifecycleGeneration, + ), + ...registeredActiveRuns, + ...matchingActiveRuns.map(({ runId, lifecycleGeneration }) => ({ + runId, + lifecycleGeneration, + })), + ]); transitionMainSessionRecovery(entry, { kind: "mark_interrupted", cycleId: randomUUID(), diff --git a/src/gateway/session-lifecycle-state.test.ts b/src/gateway/session-lifecycle-state.test.ts index ee20ed09c265..8c40dfbb691f 100644 --- a/src/gateway/session-lifecycle-state.test.ts +++ b/src/gateway/session-lifecycle-state.test.ts @@ -397,6 +397,43 @@ describe("session lifecycle state", () => { expect(persisted.mainRestartRecovery).toBeUndefined(); }); + it("clears every generation of a resumed run when its current owner completes", async () => { + const lifecycleGeneration = getAgentEventLifecycleGeneration(); + const persisted = await persistLifecycle( + { + sessionId: "session-id", + updatedAt: 1_000, + startedAt: 1_050, + status: "running", + abortedLastRun: false, + restartRecoveryRuns: [ + { runId: "recovery-run", lifecycleGeneration: "pre-restart" }, + { runId: "recovery-run", lifecycleGeneration }, + ], + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 5, + chargedAttempts: 2, + }, + }, + { + ts: 2_000, + sessionId: "session-id", + runId: "recovery-run", + lifecycleGeneration, + data: { phase: "end", endedAt: 1_800 }, + }, + ); + + expect(persisted).toMatchObject({ + status: "done", + endedAt: 1_800, + abortedLastRun: false, + }); + expect(persisted.restartRecoveryRuns).toBeUndefined(); + expect(persisted.mainRestartRecovery).toBeUndefined(); + }); + it("does not settle a foreground owner from a stale lifecycle generation", async () => { const persisted = await persistLifecycle( { From a550827dad3006cb461a89402080638545454818 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:19:43 +0800 Subject: [PATCH 046/239] feat(plugins): externalize Synthetic provider (#116720) --- docs/plugins/plugin-inventory.md | 8 +-- docs/plugins/reference/synthetic.md | 2 +- docs/providers/synthetic.md | 10 +++- extensions/synthetic/README.md | 16 ++++++ extensions/synthetic/index.ts | 2 +- extensions/synthetic/package.json | 26 ++++++++-- package.json | 1 + .../official-external-provider-catalog.json | 49 +++++++++++++++++++ src/cli/plugins-location-bridges.test.ts | 1 + .../official-external-plugin-catalog.test.ts | 12 +++++ .../bundled-plugin-build-entries.test.ts | 8 +++ 11 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 extensions/synthetic/README.md diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index a66bc377a0ad..d79d99948bae 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -67 plugins +66 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -159,8 +159,6 @@ Each entry lists the package, distribution route, and description. - **[sglang](/plugins/reference/sglang)** (`@openclaw/sglang-provider`) - included in OpenClaw. Adds SGLang model provider support to OpenClaw. -- **[synthetic](/plugins/reference/synthetic)** (`@openclaw/synthetic-provider`) - included in OpenClaw. Adds Synthetic model provider support to OpenClaw. - - **[telegram](/plugins/reference/telegram)** (`@openclaw/telegram`) - included in OpenClaw. Adds the Telegram channel surface for sending and receiving OpenClaw messages. - **[together](/plugins/reference/together)** (`@openclaw/together-provider`) - included in OpenClaw. Adds Together model provider support to OpenClaw. @@ -189,7 +187,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -78 plugins +79 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -319,6 +317,8 @@ Each entry lists the package, distribution route, and description. - **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages. +- **[synthetic](/plugins/reference/synthetic)** (`@openclaw/synthetic-provider`) - npm; ClawHub: `clawhub:@openclaw/synthetic-provider`. Adds Synthetic model provider support to OpenClaw. + - **[tavily](/plugins/reference/tavily)** (`@openclaw/tavily-plugin`) - npm; ClawHub: `clawhub:@openclaw/tavily-plugin`. Adds agent-callable tools. Adds web search provider support. - **[teams-meetings](/plugins/reference/teams-meetings)** (`@openclaw/teams-meetings`) - npm; ClawHub: `clawhub:@openclaw/teams-meetings`. Join Microsoft Teams meetings as a Chrome browser guest. diff --git a/docs/plugins/reference/synthetic.md b/docs/plugins/reference/synthetic.md index 8eacd887529b..ca38a2076a2f 100644 --- a/docs/plugins/reference/synthetic.md +++ b/docs/plugins/reference/synthetic.md @@ -12,7 +12,7 @@ Adds Synthetic model provider support to OpenClaw. ## Distribution - Package: `@openclaw/synthetic-provider` -- Install route: included in OpenClaw +- Install route: npm; ClawHub: `clawhub:@openclaw/synthetic-provider` ## Surface diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md index 29b1d7649e6f..073cdc93a2f9 100644 --- a/docs/providers/synthetic.md +++ b/docs/providers/synthetic.md @@ -7,8 +7,8 @@ title: "Synthetic" --- [Synthetic](https://synthetic.new) exposes Anthropic-compatible endpoints. -OpenClaw bundles it as the `synthetic` provider and uses the Anthropic -Messages API. +OpenClaw provides it through the official `@openclaw/synthetic-provider` +plugin and uses the Anthropic Messages API. | Property | Value | | -------- | ------------------------------------- | @@ -20,6 +20,12 @@ Messages API. ## Getting started + + ```bash + openclaw plugins install @openclaw/synthetic-provider + openclaw gateway restart + ``` + Get a `SYNTHETIC_API_KEY` from your Synthetic account, or let onboarding prompt you for one. diff --git a/extensions/synthetic/README.md b/extensions/synthetic/README.md new file mode 100644 index 000000000000..782a114cfe2c --- /dev/null +++ b/extensions/synthetic/README.md @@ -0,0 +1,16 @@ +# OpenClaw Synthetic Provider + +Official OpenClaw provider plugin for Synthetic's hosted Anthropic-compatible +API. + +Install from OpenClaw: + +```bash +openclaw plugins install @openclaw/synthetic-provider +openclaw gateway restart +``` + +Configure `SYNTHETIC_API_KEY`, then select a `synthetic/` model. + +See https://docs.openclaw.ai/providers/synthetic for model and configuration +details. diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 32e57a5b7426..a5e24eba3291 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -9,7 +9,7 @@ const PROVIDER_ID = "synthetic"; export default defineSingleProviderPluginEntry({ id: PROVIDER_ID, name: "Synthetic Provider", - description: "Bundled Synthetic provider plugin", + description: "Synthetic provider plugin", manifest, provider: { label: "Synthetic", diff --git a/extensions/synthetic/package.json b/extensions/synthetic/package.json index 0c61cceb2f3d..64d899c8ba6c 100644 --- a/extensions/synthetic/package.json +++ b/extensions/synthetic/package.json @@ -1,8 +1,11 @@ { "name": "@openclaw/synthetic-provider", "version": "2026.7.2", - "private": true, - "description": "OpenClaw Synthetic provider plugin", + "description": "OpenClaw Synthetic provider plugin.", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" @@ -10,6 +13,23 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/synthetic-provider", + "npmSpec": "@openclaw/synthetic-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/package.json b/package.json index f6aa93bd248f..1fd62961b564 100644 --- a/package.json +++ b/package.json @@ -304,6 +304,7 @@ "!dist/extensions/slack/**", "!dist/extensions/sms/**", "!dist/extensions/stepfun/**", + "!dist/extensions/synthetic/**", "!dist/extensions/synology-chat/**", "!dist/extensions/tavily/**", "!dist/extensions/teams-meetings/**", diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 6590b6abd679..882145a98922 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -1659,6 +1659,55 @@ } } }, + { + "name": "@openclaw/synthetic-provider", + "description": "OpenClaw Synthetic provider plugin.", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "synthetic", + "label": "Synthetic" + }, + "providers": [ + { + "id": "synthetic", + "name": "Synthetic", + "docs": "/providers/synthetic", + "categories": [ + "cloud", + "llm" + ], + "envVars": [ + "SYNTHETIC_API_KEY" + ], + "authChoices": [ + { + "method": "api-key", + "choiceId": "synthetic-api-key", + "choiceLabel": "Synthetic API key", + "groupId": "synthetic", + "groupLabel": "Synthetic", + "groupHint": "Anthropic-compatible (multi-model)", + "optionKey": "syntheticApiKey", + "cliFlag": "--synthetic-api-key", + "cliOption": "--synthetic-api-key ", + "cliDescription": "Synthetic API key", + "onboardingScopes": [ + "text-inference" + ] + } + ] + } + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/synthetic-provider", + "npmSpec": "@openclaw/synthetic-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + } + } + }, { "name": "@openclaw/stepfun-provider", "description": "OpenClaw StepFun provider plugin.", diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 1ab8b5a7c718..d76637285bfc 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -165,6 +165,7 @@ describe("listPersistedBundledPluginLocationBridges", () => { }); it.each([ + ["synthetic", "@openclaw/synthetic-provider"], ["teams-meetings", "@openclaw/teams-meetings"], ["zoom-meetings", "@openclaw/zoom-meetings"], ])( diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 637755ebe992..72c2c481ba13 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -1960,6 +1960,18 @@ describe("official external plugin catalog", () => { }); }); + it("lists Synthetic as an official external provider", () => { + const synthetic = expectCatalogEntry("synthetic"); + + expect(resolveOfficialExternalPluginId(synthetic)).toBe("synthetic"); + expect(resolveOfficialExternalPluginInstall(synthetic)).toEqual({ + clawhubSpec: "clawhub:@openclaw/synthetic-provider", + npmSpec: "@openclaw/synthetic-provider", + defaultChoice: "npm", + minHostVersion: ">=2026.7.2", + }); + }); + it.each([ ["teams-meetings", "@openclaw/teams-meetings", "teams_meetings", "teams"], ["zoom-meetings", "@openclaw/zoom-meetings", "zoom_meetings", "zoom"], diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index c144c7fb101a..bb18e4c1531c 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -359,6 +359,14 @@ describe("bundled plugin build entries", () => { } }); + it("excludes the externalized Synthetic provider from bundled artifacts", () => { + const entries = listBundledPluginBuildEntries(); + const artifacts = listBundledPluginPackArtifacts(); + + expectNoPrefixMatches(Object.keys(entries), "extensions/synthetic/"); + expectNoPrefixMatches(artifacts, "dist/extensions/synthetic/"); + }); + it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const excludedPackageDirs = collectRootPackageExcludedExtensionDirs(); From 2c4886ec40f8b0f70aa4ba3199c84aa7adc49c79 Mon Sep 17 00:00:00 2001 From: dwc1997 Date: Fri, 31 Jul 2026 17:32:36 +0800 Subject: [PATCH 047/239] fix(ollama): release failed setup response bodies before returning (#111802) * fix(ollama): release failed setup response bodies before returning * chore(changelog): remove release-owned entry --------- Co-authored-by: Vincent Koc --- .../ollama/src/setup-body-release.test.ts | 210 ++++++++++++++++++ extensions/ollama/src/setup-pull.ts | 3 + extensions/ollama/src/setup.ts | 1 + 3 files changed, 214 insertions(+) create mode 100644 extensions/ollama/src/setup-body-release.test.ts diff --git a/extensions/ollama/src/setup-body-release.test.ts b/extensions/ollama/src/setup-body-release.test.ts new file mode 100644 index 000000000000..73077c06e27f --- /dev/null +++ b/extensions/ollama/src/setup-body-release.test.ts @@ -0,0 +1,210 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { Socket } from "node:net"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pullOllamaModel } from "./setup-pull.js"; +import { checkOllamaCloudAuth } from "./setup.js"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchWithSsrFGuardMock, + }; +}); + +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(body, init), + wasCanceled: () => canceled, + }; +} + +function createPullPrompter(): WizardPrompter { + return { + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as unknown as WizardPrompter; +} + +async function waitForSocketClose(closed: Promise | undefined): Promise { + if (!closed) { + throw new Error("Ollama test server did not receive a request"); + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + closed, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error("Ollama response socket was not closed")); + }, 2_000); + }), + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +describe("Ollama setup response cleanup", () => { + afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); + }); + + it.each([200, 503])("cancels the /api/me body for HTTP %s", async (status) => { + const tracked = cancelTrackedResponse('{"status":"unused"}\n', { status }); + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: tracked.response, + finalUrl: "https://ollama.com/api/me", + release, + }); + + await checkOllamaCloudAuth("https://ollama.com"); + + expect(tracked.wasCanceled()).toBe(true); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "non-OK /api/pull response", + response: () => cancelTrackedResponse("ollama unavailable", { status: 503 }), + }, + { + name: "streamed /api/pull error", + response: () => cancelTrackedResponse('{"error":"disk full"}\n', { status: 200 }), + }, + ])("cancels a $name body before returning", async ({ response: createResponse }) => { + const tracked = createResponse(); + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: tracked.response, + finalUrl: "http://127.0.0.1:11434/api/pull", + release, + }); + + await expect( + pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", createPullPrompter()), + ).resolves.toBe(false); + + expect(tracked.wasCanceled()).toBe(true); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "successful auth probe", + path: "/api/me", + status: 200, + body: '{"status":"unused"}\n', + run: async (baseUrl: string) => { + await checkOllamaCloudAuth(baseUrl); + }, + }, + { + name: "failed auth probe", + path: "/api/me", + status: 503, + body: "ollama unavailable", + run: async (baseUrl: string) => { + await checkOllamaCloudAuth(baseUrl); + }, + }, + { + name: "failed pull response", + path: "/api/pull", + status: 503, + body: "ollama unavailable", + run: async (baseUrl: string) => { + await pullOllamaModel(baseUrl, "gemma4:e2b", createPullPrompter()); + }, + }, + { + name: "streamed pull error", + path: "/api/pull", + status: 200, + body: '{"error":"disk full"}\n', + run: async (baseUrl: string) => { + await pullOllamaModel(baseUrl, "gemma4:e2b", createPullPrompter()); + }, + }, + ])("closes the real socket after a $name", async ({ path, status, body, run }) => { + const sockets = new Set(); + let requestSocketClosed: Promise | undefined; + const server = createServer((request, response) => { + if (request.url !== path) { + response.writeHead(404); + response.end(); + return; + } + requestSocketClosed = new Promise((resolve) => { + request.socket.once("close", () => resolve()); + }); + response.writeHead(status, { "content-type": "application/json" }); + response.write(body); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + + fetchWithSsrFGuardMock.mockImplementation( + async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({ + response: await globalThis.fetch(params.url, { + ...params.init, + ...(params.signal ? { signal: params.signal } : {}), + }), + finalUrl: params.url, + release: async () => {}, + }), + ); + + const listening = once(server, "listening"); + try { + server.listen(0, "127.0.0.1"); + await listening; + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Ollama test server did not expose a TCP address"); + } + + await run(`http://127.0.0.1:${address.port}`); + await waitForSocketClose(requestSocketClosed); + } finally { + for (const socket of sockets) { + socket.destroy(); + } + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + } + }); +}); diff --git a/extensions/ollama/src/setup-pull.ts b/extensions/ollama/src/setup-pull.ts index 41b54aba4221..37c0c335ff38 100644 --- a/extensions/ollama/src/setup-pull.ts +++ b/extensions/ollama/src/setup-pull.ts @@ -78,6 +78,7 @@ async function pullOllamaModelCore(params: { clearTimeout(responseTimeout); try { if (!response.ok) { + await response.body?.cancel().catch(() => undefined); return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` }; } if (!response.body) { @@ -135,6 +136,8 @@ async function pullOllamaModelCore(params: { for (const line of lines) { const parsed = parseLine(line); if (!parsed.ok) { + // Ollama can report an error before closing the stream; discard the unread tail. + await reader.cancel().catch(() => undefined); return parsed; } } diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 9ca354eb7ee1..becfb575fde7 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -142,6 +142,7 @@ export async function checkOllamaCloudAuth( } return { signedIn: true }; } finally { + await response.body?.cancel().catch(() => undefined); await release(); } } catch { From 89bfd2150cd9c38617ffef6c5b19138d8a4abd36 Mon Sep 17 00:00:00 2001 From: NIO Date: Fri, 31 Jul 2026 17:37:13 +0800 Subject: [PATCH 048/239] fix(ollama): use CJK-aware char estimate for usage fallback (#110073) * fix(ollama): use CJK-aware char estimate for usage fallback * fix(ollama): keep CJK estimator plugin-private --------- Co-authored-by: Vincent Koc --- extensions/ollama/src/cjk-char-estimate.ts | 47 ++++++++++ extensions/ollama/src/stream.test.ts | 103 +++++++++++++++++++++ extensions/ollama/src/stream.ts | 18 ++-- 3 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 extensions/ollama/src/cjk-char-estimate.ts diff --git a/extensions/ollama/src/cjk-char-estimate.ts b/extensions/ollama/src/cjk-char-estimate.ts new file mode 100644 index 000000000000..db04e2267ad2 --- /dev/null +++ b/extensions/ollama/src/cjk-char-estimate.ts @@ -0,0 +1,47 @@ +/** + * CJK-aware character weighting for Ollama usage fallback estimates. + * + * This stays plugin-private because exposing it through the Plugin SDK would + * create a stable public contract for one provider-specific fallback. Keep + * the weighting aligned with normalization-core's CJK budget heuristic. + */ + +const CHARS_PER_TOKEN_ESTIMATE = 4; + +const NON_ASCII_RE = /[\u0080-\u{10FFFF}]/u; +const COMMON_CJK_RE = /[\u00B7\u3000-\u319F\u4E00-\u9FA5\uAC00-\uD7AF\uFF01-\uFF60]/gu; +const RARE_BMP_CJK_RE = + /[\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF]/gu; +const TWO_TOKEN_CJK_RE = + /[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6]|\u{0305}|\u{0323}/gu; +const THREE_TOKEN_SUPPLEMENTARY_CJK_RE = /[\u{1D360}-\u{1D371}]/gu; +const SUPPLEMENTARY_CJK_RE = + /[\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]/gu; +const SPECIAL_CJK_RE = + /[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1D360}-\u{1D371}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]|\u{0305}|\u{0323}/u; + +function countMatches(text: string, pattern: RegExp): number { + return (text.match(pattern) ?? []).length; +} + +export function estimateStringChars(text: string): number { + if (!NON_ASCII_RE.test(text)) { + return text.length; + } + const commonCjkCount = countMatches(text, COMMON_CJK_RE); + const commonEstimate = text.length + commonCjkCount * (CHARS_PER_TOKEN_ESTIMATE - 1); + if (!SPECIAL_CJK_RE.test(text)) { + return commonEstimate; + } + const rareBmpCjkCount = countMatches(text, RARE_BMP_CJK_RE); + const twoTokenCjkCount = countMatches(text, TWO_TOKEN_CJK_RE); + const threeTokenSupplementaryCjkCount = countMatches(text, THREE_TOKEN_SUPPLEMENTARY_CJK_RE); + const supplementaryCjkCount = countMatches(text, SUPPLEMENTARY_CJK_RE); + return ( + commonEstimate + + rareBmpCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 1) + + twoTokenCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 2 - 1) + + threeTokenSupplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 2) + + supplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 4 - 2) + ); +} diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index 965874831463..e8aa0ec4532b 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -694,4 +694,107 @@ describe("createOllamaStreamFn thinking events", () => { error: { stopReason: "aborted" }, }); }); + + it("uses CJK-aware fallback usage while preserving missing cache provenance", async () => { + const events = await streamOllamaEvents( + [ + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:00Z", + message: { role: "assistant", content: "你好世界测试" }, + done: false, + }, + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:01Z", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + }, + ], + {}, + { messages: [{ role: "user", content: "这是一个测试用的句子呢" }] } as never, + ); + + const done = events.find((event) => event.type === "done") as { + message?: { + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + cacheTelemetry?: { state: string }; + }; + }; + }; + expect(done?.message?.usage).toMatchObject({ + input: 12, + output: 6, + cacheRead: 0, + cacheWrite: 0, + cacheTelemetry: { state: "unavailable" }, + }); + }); + + it("keeps provider usage authoritative over the CJK fallback", async () => { + const events = await streamOllamaEvents( + [ + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:00Z", + message: { role: "assistant", content: "你好世界测试" }, + done: false, + }, + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:01Z", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + prompt_eval_count: 77, + eval_count: 19, + }, + ], + {}, + { messages: [{ role: "user", content: "这是一个测试用的句子呢" }] } as never, + ); + + const done = events.find((event) => event.type === "done") as { + message?: { usage?: { input?: number; output?: number; cacheTelemetry?: { state: string } } }; + }; + expect(done?.message?.usage).toMatchObject({ + input: 77, + output: 19, + cacheTelemetry: { state: "unavailable" }, + }); + }); + + it("keeps the existing fallback estimate for ASCII-only usage", async () => { + const events = await streamOllamaEvents( + [ + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:00Z", + message: { role: "assistant", content: "Hello world" }, + done: false, + }, + { + model: "qwen3.5", + created_at: "2026-01-01T00:00:01Z", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + }, + ], + {}, + { + messages: [{ role: "user", content: "The quick brown fox jumps over the lazy dog" }], + } as never, + ); + + const done = events.find((event) => event.type === "done") as { + message?: { usage?: { input?: number; output?: number } }; + }; + expect(done?.message?.usage).toMatchObject({ input: 11, output: 3 }); + }); }); diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 58236b6de570..2cf85b5815d8 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -33,6 +33,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime"; import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { estimateStringChars } from "./cjk-char-estimate.js"; import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; @@ -683,7 +684,7 @@ interface OllamaChatResponse { function safeJsonLength(value: unknown): number { try { const serialized = JSON.stringify(value); - return typeof serialized === "string" ? serialized.length : 0; + return typeof serialized === "string" ? estimateStringChars(serialized) : 0; } catch { return 0; } @@ -714,10 +715,10 @@ function estimateOllamaPromptTokens(params: { }): number { let chars = 0; for (const message of params.messages) { - chars += message.content.length; + chars += estimateStringChars(message.content); chars += safeJsonLength(message.images); chars += safeJsonLength(message.tool_calls); - chars += message.tool_name?.length ?? 0; + chars += message.tool_name ? estimateStringChars(message.tool_name) : 0; } chars += safeJsonLength(params.tools); return estimateTokensFromChars(chars); @@ -729,9 +730,9 @@ function estimateOllamaCompletionTokens( ): number { const chars = extraOutputChars + - response.message.content.length + - (response.message.thinking?.length ?? 0) + - (response.message.reasoning?.length ?? 0) + + estimateStringChars(response.message.content) + + (response.message.thinking ? estimateStringChars(response.message.thinking) : 0) + + (response.message.reasoning ? estimateStringChars(response.message.reasoning) : 0) + safeJsonLength(response.message.tool_calls); return estimateTokensFromChars(chars); } @@ -1473,7 +1474,10 @@ function createRawOllamaStreamFn( const usageFallback = { input: estimateOllamaPromptTokens({ messages: ollamaMessages, tools: ollamaTools }), - output: estimateOllamaCompletionTokens(finalResponse, suppressedThinking.length), + output: estimateOllamaCompletionTokens( + finalResponse, + estimateStringChars(suppressedThinking), + ), }; const assistantMessage = buildAssistantMessage(finalResponse, modelInfo, usageFallback, { ...toolCallNameOptions, From d2b869eda72236914cda6cafc2274848698017ab Mon Sep 17 00:00:00 2001 From: zw-xysk Date: Fri, 31 Jul 2026 17:37:48 +0800 Subject: [PATCH 049/239] fix(ollama): models advertise tools when /api/show fails (#109971) * fix(ollama): do not advertise tools when /api/show fails Failed show responses left capabilities undefined, which buildOllamaModelDefinition treats as optimistic supportsTools. Match setup inspect: return empty capabilities instead. * test(ollama): cover show-fail tools gate including L3 live HTTP Unit paths for HTTP error/throw plus real 127.0.0.1 server proving buildOllamaProvider keeps supportsTools false when /api/show 500s. * fix(ollama): keep reasoning heuristics when /api/show fails Distinguish failed inspection from authoritative empty capabilities so tools stay conservative without suppressing model-name reasoning. * test(ollama): cover three capability states for tools and reasoning Failed show keeps reasoning heuristics; authoritative [] disables both. * fix(ollama): propagate showInspectionFailed through setup configs Setup inspection failures now use the three-state marker instead of authoritative empty capabilities, keeping tools off and reasoning heuristics. * test(ollama): setup show-fail keeps tools off and reasoning heuristics Cover interactive setup when /api/show returns 500 for deepseek-r1. * fix(ollama): propagate showInspectionFailed through dynamic model resolve /models add dynamic path now builds failed-show definitions with tools off while preserving reasoning name heuristics. * test(ollama): dynamic resolve covers failed /api/show three-state behavior Mock builder matches production tools/reasoning contract for inspection failure. * fix(ollama): keep catalog-missing dynamic resolve fail-closed on show failure Failed /api/show is an existence probe for unresolved models; return undefined so typos/404s stay rejected. Tag-discovered and setup paths keep showInspectionFailed tools-off behavior. * chore(changelog): remove release-owned entry --------- Co-authored-by: Vincent Koc --- extensions/ollama/index.test.ts | 4 +- extensions/ollama/provider-discovery.test.ts | 6 +- extensions/ollama/src/provider-models.test.ts | 77 +++++++++++++++++-- extensions/ollama/src/provider-models.ts | 22 ++++-- .../ollama/src/setup-model-selection.test.ts | 5 +- .../ollama/src/setup-model-selection.ts | 7 +- 6 files changed, 101 insertions(+), 20 deletions(-) diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 46d01c68de80..5e43f2cb00a2 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -1901,7 +1901,7 @@ describe("ollama plugin", () => { expect(rows).toHaveLength(8); }); - it("keeps unknown requested Ollama models unresolved when show has no metadata", async () => { + it("keeps unknown requested Ollama models unresolved when show inspection fails", async () => { const provider = registerProvider(); const previous = process.env.OLLAMA_API_KEY; process.env.OLLAMA_API_KEY = "ollama-local"; @@ -1910,7 +1910,7 @@ describe("ollama plugin", () => { api: "ollama", models: [], }); - queryOllamaModelShowInfoMock.mockResolvedValueOnce({}); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ showInspectionFailed: true }); try { await provider.prepareDynamicModel?.({ diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index 83dd2c74fb9b..c8ba8eb0134c 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -323,7 +323,7 @@ describe("Ollama provider", () => { const fetchMock = vi.fn(async (input: unknown) => { const url = String(input); if (url.endsWith("/api/tags")) { - return tagsResponse(["qwen3:32b"]); + return tagsResponse(["deepseek-r1:14b"]); } if (url.endsWith("/api/show")) { return jsonResponse({}, 500); @@ -335,8 +335,10 @@ describe("Ollama provider", () => { const provider = await runOllamaCatalog({ env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" }, }); - const model = provider?.models?.find((entry) => entry.id === "qwen3:32b"); + const model = provider?.models?.find((entry) => entry.id === "deepseek-r1:14b"); expect(model?.contextWindow).toBe(128000); + expect(model?.compat?.supportsTools).toBe(false); + expect(model?.reasoning).toBe(true); expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 1 }); }); diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index c2ae83a9b861..3be6e9e3d436 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -471,6 +471,27 @@ describe("ollama provider models", () => { expect(model.compat?.supportsUsageInStreaming).toBe(true); }); + it("keeps failed inspection distinct from omitted and empty capabilities", () => { + const uninspected = buildOllamaModelDefinition("deepseek-r1:14b", 65536); + const authoritativeEmpty = buildOllamaModelDefinition("deepseek-r1:14b", 65536, []); + const inspectionFailed = buildOllamaModelDefinition("deepseek-r1:14b", 65536, undefined, { + showInspectionFailed: true, + }); + + expect(uninspected).toMatchObject({ + reasoning: true, + compat: { supportsTools: true }, + }); + expect(authoritativeEmpty).toMatchObject({ + reasoning: false, + compat: { supportsTools: false }, + }); + expect(inspectionFailed).toMatchObject({ + reasoning: true, + compat: { supportsTools: false }, + }); + }); + it.each([ { parameters: "num_ctx 8192\nnum_ctx 32768", expected: 32768 }, { parameters: "temperature 0.8\nnum_ctx -1\nnum_ctx 0", expected: undefined }, @@ -506,9 +527,9 @@ describe("ollama provider models", () => { vi.fn(async () => showResponse.response), ); - await expect(queryOllamaModelShowInfo("http://127.0.0.1:11434", "llama3:8b")).resolves.toEqual( - {}, - ); + await expect(queryOllamaModelShowInfo("http://127.0.0.1:11434", "llama3:8b")).resolves.toEqual({ + showInspectionFailed: true, + }); expect(showResponse.wasCanceled()).toBe(true); }); @@ -609,7 +630,9 @@ describe("ollama provider models", () => { }); await waitForSocketClose("/api/tags"); - await expect(queryOllamaModelShowInfo(baseUrl, "llama3:8b")).resolves.toEqual({}); + await expect(queryOllamaModelShowInfo(baseUrl, "llama3:8b")).resolves.toEqual({ + showInspectionFailed: true, + }); await waitForSocketClose("/api/show"); mode = "success"; @@ -639,6 +662,50 @@ describe("ollama provider models", () => { } }); + it("keeps tools off after a live /api/show failure", async () => { + const server = createServer((request, response) => { + response.setHeader("Content-Type", "application/json"); + if (request.url === "/api/tags") { + response.end( + JSON.stringify({ + models: [{ name: "deepseek-r1:14b", digest: "sha256:show-failure" }], + }), + ); + return; + } + if (request.url === "/api/show") { + response.statusCode = 500; + response.end(JSON.stringify({ error: "show failed" })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + }); + + const listening = once(server, "listening"); + try { + server.listen(0, "127.0.0.1"); + await listening; + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Ollama test server did not expose a TCP address"); + } + + const provider = await buildOllamaProvider(`http://127.0.0.1:${address.port}`); + const model = expectDefined(provider.models?.[0], "show-failed Ollama model"); + + expect(model.id).toBe("deepseek-r1:14b"); + expect(model.compat?.supportsTools).toBe(false); + expect(model.reasoning).toBe(true); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } + }); + it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => { // Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels // the stream mid-flight; if the cap were removed the reader would buffer the whole payload. @@ -687,7 +754,7 @@ describe("ollama provider models", () => { vi.fn(async () => makeOversizedJsonResponse()), ); const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest"); - expect(showInfo).toEqual({}); + expect(showInfo).toEqual({ showInspectionFailed: true }); expect(canceled).toBe(true); expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB); }); diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index c9cc09871438..b282c56ac214 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -37,6 +37,7 @@ export type OllamaTagsResponse = { export type OllamaModelWithContext = OllamaTagModel & { contextWindow?: number; capabilities?: string[]; + showInspectionFailed?: boolean; }; const OLLAMA_SHOW_CONCURRENCY = 8; @@ -81,8 +82,14 @@ export function resolveOllamaApiBase(configuredBaseUrl?: string): string { export type OllamaModelShowInfo = { contextWindow?: number; capabilities?: string[]; + /** Distinguishes a failed request from a successful response that omitted capabilities. */ + showInspectionFailed?: boolean; }; +const OLLAMA_FAILED_SHOW_INFO: OllamaModelShowInfo = Object.freeze({ + showInspectionFailed: true, +}); + type OllamaModelRequestOptions = { apiKey?: string; timeoutMs?: number; @@ -227,7 +234,7 @@ export async function queryOllamaModelShowInfo( return await readOllamaModelShowInfo(apiBase, modelName, opts); } catch { throwIfOllamaRequestAborted(opts?.signal); - return {}; + return OLLAMA_FAILED_SHOW_INFO; } } @@ -279,10 +286,7 @@ export async function enrichOllamaModelsWithContext( const batchResults = await Promise.all( batch.map(async (model) => { const showInfo = await queryOllamaModelShowInfoCached(apiBase, model, opts); - return Object.assign({}, model, { - contextWindow: showInfo.contextWindow, - capabilities: showInfo.capabilities, - }); + return Object.assign({}, model, showInfo); }), ); enriched.push(...batchResults); @@ -343,6 +347,7 @@ export function buildOllamaModelDefinition( modelId: string, contextWindow?: number, capabilities?: string[], + opts?: { showInspectionFailed?: boolean }, ): ModelDefinitionConfig { const hasVision = capabilities?.includes("vision") ?? false; const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"]; @@ -352,7 +357,8 @@ export function buildOllamaModelDefinition( ? isReasoningModelHeuristic(modelId) : capabilities.includes("thinking")); const compat = { - supportsTools: capabilities?.includes("tools") ?? true, + supportsTools: + opts?.showInspectionFailed === true ? false : (capabilities?.includes("tools") ?? true), supportsUsageInStreaming: true, supportsJsonSchemaResponseFormat: !isOllamaCloudModel(modelId), }; @@ -467,7 +473,9 @@ export async function buildOllamaProvider( baseUrl: apiBase, api: "ollama", models: discovered.map((model) => - buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities), + buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities, { + showInspectionFailed: model.showInspectionFailed, + }), ), }; } diff --git a/extensions/ollama/src/setup-model-selection.test.ts b/extensions/ollama/src/setup-model-selection.test.ts index 491c393d5dab..67107cea5c4c 100644 --- a/extensions/ollama/src/setup-model-selection.test.ts +++ b/extensions/ollama/src/setup-model-selection.test.ts @@ -23,11 +23,12 @@ describe("Ollama onboarding model selection", () => { it("keeps failed model inspections distinct from uninspected models", () => { const models = buildOllamaModelsConfig( - ["broken", "uninspected"], - new Map([["broken", { name: "broken", capabilities: [] }]]), + ["deepseek-r1:14b", "uninspected"], + new Map([["deepseek-r1:14b", { name: "deepseek-r1:14b", showInspectionFailed: true }]]), ); expect(models[0]?.compat?.supportsTools).toBe(false); + expect(models[0]?.reasoning).toBe(true); expect(models[1]?.compat?.supportsTools).toBe(true); }); diff --git a/extensions/ollama/src/setup-model-selection.ts b/extensions/ollama/src/setup-model-selection.ts index 2b6a9e3d071e..39fa911980ff 100644 --- a/extensions/ollama/src/setup-model-selection.ts +++ b/extensions/ollama/src/setup-model-selection.ts @@ -78,6 +78,7 @@ export function buildOllamaModelsConfig( name, discovered?.contextWindow ?? defaultModel?.contextWindow, capabilities, + { showInspectionFailed: discovered?.showInspectionFailed }, ); }); } @@ -105,9 +106,11 @@ export async function inspectOllamaModelsForSetup( } catch (error) { signal?.throwIfAborted(); // A failed inspection must not inherit the optimistic tools default - // reserved for models that were never inspected. + // reserved for models that were never inspected. Keep the failure + // distinct from authoritative empty capabilities so name-based + // reasoning detection still applies. inspectionFailures.push(`${model.name}: ${formatErrorMessage(error)}`); - return Object.assign({}, model, { capabilities: [] as string[] }); + return Object.assign({}, model, { showInspectionFailed: true as const }); } }), ); From 57c18b3ab310cf3db29f7d6d7c56bc7e8281c8f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:43:39 -0700 Subject: [PATCH 050/239] docs(auto-qa): require clean root-cause refactors --- .agents/skills/auto-qa/SKILL.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.agents/skills/auto-qa/SKILL.md b/.agents/skills/auto-qa/SKILL.md index f8035b56959d..80e0fbfda1a3 100644 --- a/.agents/skills/auto-qa/SKILL.md +++ b/.agents/skills/auto-qa/SKILL.md @@ -5,7 +5,15 @@ description: "Continuously audit, live-test, and stress-test the current OpenCla # OpenClaw Auto QA -Run a continuous, current-`main` OpenClaw product campaign. Treat a reviewer finding as a hypothesis, a passing test as evidence only for its actual head, and a merge as complete only when the canonical repository confirms it. Repair the actual root cause in its canonical owner; a smaller patch is not better if it leaves sibling paths, lifecycle invariants, or the defective abstraction intact. +Run a continuous, current-`main` OpenClaw product campaign. Treat a reviewer finding as a hypothesis, a passing test as evidence only for its actual head, and a merge as complete only when the canonical repository confirms it. Always prefer a clean, appropriately scoped root-cause refactor over a quick fix or smaller diff. Repair the actual root cause in its canonical owner; a patch is not acceptable when it leaves sibling paths, lifecycle invariants, or the defective abstraction intact. + +## Prefer clean refactors over quick fixes + +- Identify the broken ownership boundary, abstraction, state transition, or dependency contract before choosing an implementation. Compare the canonical owner, callers, callees, and sibling paths; prefer the design that makes their shared invariant obvious and reliable. +- Consolidate decisions and authoritative state in their actual owner. Propagate prepared facts through existing lifecycles, repair all affected siblings, and delete obsolete branches, duplicate policy, dead helpers, and stale abstractions when they are no longer needed. +- Reject symptom-masking guards, one-off exceptions, observed-example literals, parallel code paths, extra caches, fallback stacks, compatibility shims, and tests that merely make a narrow reproduction pass. A smaller change is not safer when it preserves the cause or makes the architecture harder to understand. +- Preserve shipped public contracts and ownership boundaries. If the clean refactor would affect security, persistent state, public configuration, plugin SDK compatibility, a protocol, or a product decision, mark it for maintainer review instead of substituting a tactical patch. +- During independent review, explicitly ask whether the change is the cleanest appropriately bounded root-cause solution. Green tests, a minimal diff, and a plausible local fix are insufficient without that architectural judgment. ## Start with the moving source @@ -48,10 +56,10 @@ Read [references/live-proof-routing.md](references/live-proof-routing.md) before 1. Deduplicate against the current ledger, `origin/main`, current open and merged GitHub work, and sibling root causes. Count one broken invariant once, even when it produces multiple model, platform, route, lifecycle, or UI symptoms. 2. Independently reproduce the actual current-main user path. Map the entry point, canonical owner, callers, callees, sibling implementations, state lifecycle, existing regressions, shipped contracts, and relevant direct upstream source. Identify why the current design fails before proposing a repair. -3. Refactor the canonical owner in an isolated worktree. Repair all affected sibling paths in the same coherent change, simplify or remove the defective abstraction, and carry authoritative facts through the existing lifecycle. Prefer the appropriately sized root-cause solution over a minimal guard, special case, extra cache, fallback, compatibility shim, or narrowly passing test. +3. Refactor the canonical owner in an isolated worktree. Repair all affected sibling paths in the same coherent change, simplify or remove the defective abstraction, and carry authoritative facts through the existing lifecycle. Prefer the cleanest appropriately sized root-cause solution over a minimal diff; reject a guard, special case, extra cache, fallback, compatibility shim, or narrowly passing test that leaves the architectural defect behind. 4. Preserve public configuration, plugin ownership, gateway protocol, migrations, provider contracts, persistent state, and external dependencies. When a correct root-cause repair would change a sensitive contract or requires a product decision, prepare it for operator review; do not disguise that risk as a small autonomous fix. 5. Add authentic regression coverage for the original reproduction, affected siblings, lifecycle cleanup, and unchanged legitimate behavior. Run appropriately scoped proof on the exact candidate head. Route Docker, real providers, packaging, full checks, typechecking, broad suites, and browser work through the existing remote workflow; inspect actual exit status, nonzero scenario counts, and artifacts. -6. Run a fresh `$autoreview` on the complete final refactor. Resolve actionable findings; rerun review after any production, test, or head change. Personally read the latest ClawSweeper review, satisfy each applicable rank-up move with real evidence, and update the existing PR body before landing. +6. Run a fresh `$autoreview` on the complete final refactor. Require the reviewer to compare owner boundaries and sibling implementations, confirm this is the best clean root-cause solution, and reject quick-fix residue even when tests pass. Resolve actionable findings; rerun review after any production, test, or head change. Personally read the latest ClawSweeper review, satisfy each applicable rank-up move with real evidence, and update the existing PR body before landing. 7. Check existing open PRs, current author counts, and the actual repository automation before publishing. Read both the current labeler and response policy; verify the authenticated author association, repository permission, account type, automation branch prefix, and actual override label. Apply only exemptions proved by that current policy, including eligible owners, maintainers, collaborators, bots or apps, approved automation branches, and explicit overrides. Never infer capacity from a truncated list or assume that one privileged role represents every exemption. Reuse and repair an existing candidate PR for the same cause. When a real cap applies, hold reviewed worktrees and finish or land existing verified work first. 8. Create a focused PR with the repository's actual template, canonical cause, user impact, frozen head, completed proof, and risk. Use only the current repo-native `scripts/pr` review, artifact, prepare, and merge workflow for authorized main landing. 9. Autonomously merge only when the user authorized it **and** the canonical root-cause refactor is individually reproduced, low-risk, independently reviewed, current-main-compatible, and has green required exact-head proof. Evaluate risk by ownership and behavioral impact, not by whether the diff is the smallest possible. Verify the resulting canonical merge SHA before incrementing the ledger. From 6fbe39eed117cc14a0ab464f162b85c614f2dd59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:44:06 -0700 Subject: [PATCH 051/239] chore(skills): add autonomous issue sweep workflow --- .../openclaw-autonomous-issue-sweep/SKILL.md | 253 ++++++++++++++++++ .../agents/openai.yaml | 4 + 2 files changed, 257 insertions(+) create mode 100644 .agents/skills/openclaw-autonomous-issue-sweep/SKILL.md create mode 100644 .agents/skills/openclaw-autonomous-issue-sweep/agents/openai.yaml diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md new file mode 100644 index 000000000000..9ce683ef9957 --- /dev/null +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -0,0 +1,253 @@ +--- +name: openclaw-autonomous-issue-sweep +description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest; find existing PRs, deeply investigate bugs, simplify or refactor, live-test, independently review, land verified fixes, close already-fixed issues, and add only meaningful new evidence." +--- + +# OpenClaw Autonomous Issue Sweep + +Run an end-to-end maintainer campaign, not a candidate shortlist. The parent +conversation is the orchestrator: delegate discovery, investigation, coding, +testing, review, GitHub mutations, PR preparation, landing, and cleanup to +subagents. Keep parent-thread updates to concise progress and clickable URLs. + +## Authority and campaign shape + +- Spawn exactly **64 first-class subagents** unless the user requests another + count or available capacity makes that impossible; disclose the actual count. +- Use full-history forks so every subagent inherits the orchestrator's model + and **xhigh reasoning effort**. Never print, record, or disclose model + identifiers; redact subprocess banners and diagnostics before reporting. +- Treat a request to run this workflow as authority to review, fix, refactor, + commit, push, create/update PRs, land eligible changes, comment, and close + issues individually. Do not ask for routine confirmation again. +- Never treat sweep authority as permission to publish releases, bump protocol + or SQLite schema versions, weaken security, break shipped compatibility, + change another owner's protected product surface, or execute untrusted code + with local credentials. +- Have subagents read the complete root `AGENTS.md`, relevant scoped guides, + `VISION.md`, and companion skills before acting. Use `$gitcrawl`, Octopool, + `$openclaw-pr-maintainer`, `$openclaw-testing`, `$crabbox`, and `$autoreview` + where each owns the workflow. +- Keep the parent out of operational work. It may spawn, assign, receive + results, serialize shared resources, monitor host/pool health, prewarm and + allocate needed remote leases, issue follow-up tasks, and report; it must + not inspect issues, edit code, run tests, mutate GitHub, or land PRs. + +## Coordinate 64 workers safely + +1. Assign one subagent to maintain the live open-issue queue in descending + `createdAt` order, one to coordinate landing/proof capacity, and the rest to + issue investigations. Coordinator agents also investigate when idle. +2. Claim issues from the newest unclaimed end only; replenish workers as they + finish. Parallel completions may arrive out of order, but never knowingly + start an older unclaimed issue ahead of a newer available issue. +3. Deduplicate by canonical root cause, not merely by issue number. Let one + owner fix a shared defect and link related issues/PRs to that outcome. +4. Freeze the reviewed source SHA for each wave. Designate a single fetch owner; + pause shared-ref refreshes while repo-native PR prepare/merge runs. +5. Never switch a shared checkout branch or edit it while sibling agents use it. + Use an existing agent-owned checkout, a repo-native isolated PR worktree, or + an explicitly user-authorized new worktree. Otherwise serialize write + access; parallel read-only investigations may continue. +6. Sample checkout/temp-volume free disk, CPU/load, memory pressure, process + count, operator-gateway health, actual worker count, and Octopool capacity + before each wave and periodically thereafter. Throttle expensive work for + sustained pressure or low disk; never kill unrelated operator processes. +7. Serialize merge operations and each Testbox lease. A lease has one owner and + one active command; never reclaim, sync, or change its head during a run. +8. Respect GitHub rate limits, active assignees, repository ownership, and + existing contributor work. Do not auto-assign broad-discovery candidates. +9. Replace finished workers while the queue remains. Record actual active, + completed, failed, fixed, landed, closed, commented, and skipped counts; + never report launched or finished workers as still running. + +## Conserve GitHub capacity and host resources + +- Prefer local `$gitcrawl` archives and source history for queue discovery, + issue/PR search, duplicate clusters, comments, and previously merged work. + Check archive freshness; do not broadly sync, enrich, or re-embed merely to + start a sweep. +- Prefer `octopool gh ...` or narrowly bounded `octopool request` for + necessary live GitHub reads and mutations. Check `octopool health` and + `octopool stats` periodically; let repo-native PR wrappers retain their + required GitHub transport and authenticated identity. +- Use plain `gh` only when Octopool cannot support the operation or the + canonical maintainer wrapper requires it. Request minimal fields, reuse + results across workers, batch compatible reads, avoid unbounded pagination, + and never use `gh run watch` or frequent unchanged CI polls. +- Require a fresh live state check only before consequential mutations, final + merge decisions, or a stale/contradictory cached result. Rate-limit and + deduplicate worker requests instead of having 64 agents independently fetch + the same issue, PR, author profile, or CI rollup. +- Keep disk, load, memory pressure, active lease IDs, provider trust class, + checkout ownership, and pool capacity in the orchestration ledger. Slow new + assignments, serialize builds/tests, clean only campaign-owned artifacts, + and offload heavy proof before resource pressure threatens the host. +- The parent may prewarm a trusted Crabbox/Testbox lease when a concrete heavy + proof is imminent, then hand its verified lease ID and checkout ownership to + one subagent at a time. Avoid speculative fleets, respect path-scoped lease + ownership, and stop campaign-owned leases before handoff or closeout. +- Keep untrusted contributor proof on a separate sanitized direct-AWS lease; + never transfer a credential-hydrated trusted lease to untrusted work. + +## Search for existing work on every credible issue + +Always investigate existing PRs before implementing a fix: + +1. Read the live issue body, all material comments, labels, assignments, + timeline/cross-references, repro details, affected versions, and ClawSweeper + findings. +2. Search `$gitcrawl` for the issue number, title, error text, affected + subsystem, relevant symbols, duplicate symptoms, open PRs, merged PRs, and + recently closed work. +3. Verify candidates against Octopool-backed live GitHub search, directly + linked PRs, current PR heads, `origin/main`, and commit history. Search + exact issue references and symptom/root-cause terms; do not stop at the + first plausible PR. +4. Read competing implementations deeply enough to decide whether an existing + PR already fixes the real defect, merely masks one symptom, has gone stale, + or reveals a cleaner owner-boundary refactor. +5. Preserve contributor commits, attribution, issue reporter credit, and useful + ideas whenever repairing or replacing existing work. + +Choose outcomes in this order: + +1. **Fixed on main:** prove the original failure is resolved; close with the + exact merged PR, commit, current source/test, or release proof. +2. **Existing PR is the best fix:** improve it as needed, verify the exact + final head, and land it through the repo-native maintainer workflow. +3. **Existing PR is useful but incomplete:** finish it or create a cleaner + replacement that preserves human attribution and links the original. +4. **No suitable PR:** implement the best high-confidence root-cause repair or + a justified simplifying refactor; create, verify, and land a focused PR. +5. **Bug cannot be fixed, but simplification is real:** independently land a + proven behavior-neutral refactor when it meaningfully removes complexity + without pretending the original issue was fixed. +6. **Cannot fix or close:** comment only if investigation uncovered concrete, + material evidence missing from the issue and ClawSweeper's existing review. + +## Prove the bug and choose the best design + +- Trace the actual user path from entry point through caller, canonical owner, + callee, sibling implementations, transport/lifecycle boundaries, tests, + current `main`, shipped contracts, and direct dependency source or docs. +- Personally inspect sibling `../codex` source before any Codex integration + verdict or change, as required by the root guide; another agent's report is + not sufficient for the agent making that decision. +- Require a failing regression, reproducible command, real logs, live product + behavior, dependency contract, or exact source-level proof. Never repair an + issue on title, speculation, ClawSweeper output, or a plausible diff alone. +- Prefer the correct owner-boundary refactor over a narrow guard, workaround, + new fallback, duplicate policy, extra configuration, or compatibility shim. + A larger refactor is appropriate when it fixes the whole bug class more + clearly and its behavior/ownership risk remains understood and bounded. +- While reading, look for dead branches, unused helpers, duplicate paths, + stale abstractions, obsolete tests, and complexity that can be deleted as + part of the same coherent change. +- Measure `git diff --numstat`; aim to reduce **production LOC**, excluding + tests. Production growth is acceptable only when clearly justified by fewer + concepts, better ownership, essential product behavior, or stronger safety. +- Allow small missing product affordances, such as an obviously expected CLI + command, when adjacent behavior and docs establish the contract. Reject + substantial new features, speculative redesign, new paid services, + unsupported integrations, or unrelated drive-by changes. +- Do not edit `CHANGELOG.md`; capture user impact, issue/PR references, and + human credit in the PR body or commit message. + +## Verify behavior and obtain two independent reviews + +For every non-trivial production change: + +1. Add focused regression coverage for the original bug and affected sibling + paths. Delete tests protecting removed obsolete implementation details. +2. Choose proof with `$openclaw-testing`. Live-test the real user/provider/ + channel/CLI/package/UI path whenever feasible. Route heavy, packaging, + Docker, E2E, or broad checks through `$crabbox`; report an unavailable live + prerequisite accurately instead of calling a mock live proof. +3. Classify source trust before executing anything. Never run contributor/fork + scripts, hooks, config, tests, installs, or wrappers locally or on a + credential-hydrated host; follow the sanitized untrusted-source workflow. +4. Run `$autoreview` on the complete final change until no accepted actionable + findings remain. Re-run it after any production, test, or reviewed-head + change. Treat review findings as hypotheses and verify each against source. + Prose-only skill files and other non-production internal notes do not need + autoreview; validate their structure and formatting instead. +5. Separately self-invoke an independent Codex reviewer. First verify the + installed interface with `codex exec --help`, then run a bounded read-only, + ephemeral review from a trusted checkout, for example: + + ```bash + codex exec --json --sandbox read-only --ephemeral \ + -C "$trusted_checkout" --output-last-message "$review_result" \ + "Independently inspect the frozen candidate diff and its owner, callers, + siblings, tests, current main, user behavior, and dependency contracts. + Report only concrete correctness, architecture, simplification, or + verification gaps. Do not modify files or expose secrets." \ + >/dev/null 2>/dev/null + ``` + + Point the reviewer at the exact immutable diff/head. Do not substitute the + `$autoreview` Codex engine for this separate pass. Never run that reviewer + from an untrusted project-controlled checkout. Read only the final review + result; do not emit raw model banners. Verify actionable findings, make + justified fixes, rerun proof, and refresh both independent reviews. + +6. Read the latest ClawSweeper comment and address each applicable `Rank-up +moves:` item with real evidence or an explicit reason for skipping it. + +## Publish, land, and clean up + +- Prefer an existing writable contributor PR. If its head is unsuitable or + cannot be updated safely, open a focused replacement, explain the + relationship, and preserve attribution. +- Before opening replacement PRs, verify author association, active-PR counts, + repository permission, branch policy, current auto-response exemptions, and + override labels; never assume a privileged-role exemption. Reuse or land + existing reviewed work before creating a burst of competing PRs. +- Use the actual PR template and state the user impact, canonical root cause, + rejected alternatives, production LOC delta, exact head SHA, focused/live + proof, autoreview result, independent Codex result, CI state, and credit. +- Read `$agent-transcript` for agent-created PRs, but do not include logs + without the user's explicit transcript approval. During a fully autonomous + sweep, omit transcripts rather than interrupting the user for consent. +- Open new PRs as drafts, wait for a non-null mergeability result, mark them + ready, and verify CI attached to the exact pushed head before landing. +- Autonomously land only a reproduced, high-confidence, bounded-risk repair + or behavior-neutral simplification with clean independent reviews and green + exact-head required proof. Change size alone is not the risk criterion. +- For main-targeted PRs use only the repo-native `scripts/pr` flow: initialize + review, create/validate review artifacts, run + `OPENCLAW_TESTBOX=1 scripts/pr prepare-run `, then + `scripts/pr merge-run `. Verify the canonical merge SHA afterward. +- Keep owner/security/auth/config/public-SDK/protocol/persistent-state/product + decisions outside autonomous landing when the relevant guide requires owner + judgment. Continue with the next issue instead of blocking the whole sweep. +- Close a fixed issue only after live rechecking its open state and matching + the original symptoms to current-main proof. Cite the merged PR/commit and + ask the reporter to reopen if it still reproduces on the current version. +- Never close merely because a repro is difficult, the report is inconvenient, + the behavior might be intentional, or the PR is stale. Product-decision and + won't-implement closures require maintainer judgment. +- If no fix is possible, comment only when supplying new reproducible steps, + an exact failing owner/line, verified dependency behavior, previously + unidentified duplicate/fixing PR, a concrete workaround, or another + meaningful fact absent from prior discussion and ClawSweeper. +- Recheck live state immediately before every mutation; avoid redundant, + speculative, noisy, or duplicate comments. Handle closures individually and + follow repository limits on bulk operations. + +## Parent-thread reporting + +Send concise progress plus URLs only. Prefer updates such as: + +```text +64 agents active · 41 investigated · 3 landed · 5 already-fixed issues closed +Landed: https://github.com/openclaw/openclaw/pull/123 +Closed: https://github.com/openclaw/openclaw/issues/456 +``` + +Do not narrate routine reads, pending hypotheses, unchanged CI, or candidate +URLs that are not actually ready. Count only verified merged PRs, confirmed +closures, and comments that were really posted. Continue until the user stops +the sweep, the requested boundary is reached, or the live issue queue is +genuinely exhausted. diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/agents/openai.yaml b/.agents/skills/openclaw-autonomous-issue-sweep/agents/openai.yaml new file mode 100644 index 000000000000..ac7c3f2276c8 --- /dev/null +++ b/.agents/skills/openclaw-autonomous-issue-sweep/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OpenClaw Autonomous Issue Sweep" + short_description: "Autonomously fix, refactor, land, and close issues" + default_prompt: "Use $openclaw-autonomous-issue-sweep to orchestrate 64 subagents through OpenClaw issues newest to oldest; reuse existing PRs, prove and land high-confidence fixes or refactors, close resolved issues, and report concise progress plus URLs." From 1f578f0e65ff6977f14ebc6b56a9535ad5824f51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:45:15 -0700 Subject: [PATCH 052/239] fix(telegram): validate native queue arguments before fallback (#116726) Co-authored-by: Peter Steinberger --- .../live-transports/telegram/profiles.test.ts | 10 +++ .../bot-native-commands.session-meta.test.ts | 34 +++++++++ .../telegram/src/bot-native-commands.ts | 23 ++++++- .../channels/telegram-queue-invalid-mode.yaml | 69 +++++++++++++++++++ src/auto-reply/reply/get-reply-directives.ts | 21 ++++++ .../get-reply-native-slash-fast-path.test.ts | 53 ++++++++++++++ 6 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 qa/scenarios/channels/telegram-queue-invalid-mode.yaml diff --git a/extensions/qa-lab/src/live-transports/telegram/profiles.test.ts b/extensions/qa-lab/src/live-transports/telegram/profiles.test.ts index 190b3564f971..0f389a5076bd 100644 --- a/extensions/qa-lab/src/live-transports/telegram/profiles.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/profiles.test.ts @@ -56,6 +56,16 @@ describe("Telegram QA profiles", () => { ).toThrow("execution.kind=flow"); }); + it("selects the native queue-validation regression as an explicit live scenario", () => { + expect( + resolveTelegramQaScenarioIds({ + profile: "release", + providerMode: "live-frontier", + scenarioIds: ["telegram-queue-invalid-mode"], + }), + ).toEqual(["telegram-queue-invalid-mode"]); + }); + it("rejects unknown profiles and channel-ineligible explicit scenarios", () => { expect(() => resolveTelegramQaScenarioIds({ providerMode: "live-frontier", profile: "transport" }), diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index d240eefa9651..6c6986eb5d7e 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -15,6 +15,7 @@ import { type NativeCommandTestParams, } from "./bot-native-commands.fixture-test-support.js"; import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; // All mocks scoped to this file only — does not affect bot-native-commands.test.ts @@ -731,6 +732,39 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); }); + it("records a completed outcome after a native slash command", async () => { + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await handler(createTelegramPrivateCommandContext()); + }); + + expect(result).toEqual({ kind: "completed" }); + }); + + it("preserves every argument on native queue command turns", async () => { + const { handler } = registerAndResolveCommandHandler({ + commandName: "queue", + cfg: {}, + allowFrom: ["*"], + }); + + await handler(createTelegramPrivateCommandContext({ match: "Can you diagnose this?" })); + + expect(dispatchChannelInboundTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctxPayload: expect.objectContaining({ + Body: "/queue Can you diagnose this?", + CommandBody: "/queue Can you diagnose this?", + CommandTurn: expect.objectContaining({ + kind: "native", + body: "/queue Can you diagnose this?", + }), + }), + }), + ); + }); + it("keeps one live config snapshot through native command execution", async () => { const startupCfg: OpenClawConfig = { session: { store: "/tmp/startup-sessions.json" } }; const runtimeCfg: OpenClawConfig = { session: { store: "/tmp/runtime-sessions.json" } }; diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 52947497f153..08b5d5d5c71c 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -75,7 +75,10 @@ import { syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime, type TelegramMenuCommand, } from "./bot-native-command-menu.js"; -import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js"; +import { + recordTelegramMessageProcessingResult, + type TelegramMessageProcessingResult, +} from "./bot-processing-outcome.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { @@ -122,6 +125,20 @@ const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; const activeTelegramCodexLoginFlows = new Map(); type TelegramNativeCommandContext = Context & { match?: string }; + +function registerTelegramNativeCommandHandler( + bot: Bot, + command: string, + handler: (ctx: TelegramNativeCommandContext) => Promise, +): void { + bot.command(command, async (ctx: TelegramNativeCommandContext) => { + await handler(ctx); + // Native commands bypass processMessage, so their terminal outcome must be + // recorded here for every built-in, plugin, and direct-delivery branch. + recordTelegramMessageProcessingResult({ kind: "completed" }); + }); +} + type TelegramChunkMode = ReturnType< typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode >; @@ -1210,7 +1227,7 @@ export const registerTelegramNativeCommands = ({ if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) { for (const command of nativeCommands) { const normalizedCommandName = normalizeTelegramCommandName(command.name); - bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => { + registerTelegramNativeCommandHandler(bot, normalizedCommandName, async (ctx) => { const msg = ctx.message; if (!msg) { return; @@ -1800,7 +1817,7 @@ export const registerTelegramNativeCommands = ({ } for (const pluginCommand of pluginCatalog.commands) { - bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { + registerTelegramNativeCommandHandler(bot, pluginCommand.command, async (ctx) => { const msg = ctx.message; if (!msg) { return; diff --git a/qa/scenarios/channels/telegram-queue-invalid-mode.yaml b/qa/scenarios/channels/telegram-queue-invalid-mode.yaml new file mode 100644 index 000000000000..796f2c114d6a --- /dev/null +++ b/qa/scenarios/channels/telegram-queue-invalid-mode.yaml @@ -0,0 +1,69 @@ +title: Telegram native queue command rejects ordinary prompt text + +scenario: + id: telegram-queue-invalid-mode + surface: channels + category: channels.channel-actions-commands-and-approvals + coverage: + primary: + - telegram.built-in-commands + regressionRefs: + - openclaw/openclaw#116688 + objective: Verify a native Telegram queue command with ordinary trailing text returns its queue-mode validation error without invoking the model or synthesizing a model-failure fallback. + successCriteria: + - Telegram accepts the native queue command with its complete trailing argument text. + - The reply identifies the invalid queue mode and lists supported queue modes. + - The reply never blames the model, and a mock provider receives no request for the command. + codeRefs: + - extensions/telegram/src/bot-native-commands.ts + - src/auto-reply/reply/get-reply-directives.ts + - src/auto-reply/reply/directive-handling.queue-validation.ts + execution: + kind: flow + channel: telegram + summary: Send the reported native queue command and verify its explicit validation reply. + config: + commandText: /queue Can you diagnose this? + invalidModeNeedle: Unrecognized queue mode "Can" + validModesNeedle: "Valid modes: steer, followup, collect, interrupt." + falseFallbackNeedle: temporary model failure + +flow: + steps: + - name: invalid native queue arguments produce a visible command error + actions: + - resetTransport: true + - set: requestCursorBefore + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : 0" + - set: startIndex + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length" + - sendInbound: + conversation: { id: telegram-command-room, kind: channel } + senderId: qa-command-operator + senderName: QA Command Operator + text: { ref: config.commandText } + nativeCommand: { name: queue } + - waitForOutbound: + conversation: { id: telegram-command-room, kind: channel } + sinceIndex: { ref: startIndex } + textIncludes: { ref: config.invalidModeNeedle } + timeoutMs: 60000 + saveAs: reply + - assert: + expr: "reply.text.includes(config.validModesNeedle)" + message: + expr: "`queue validation reply omitted the valid modes: ${reply.text}`" + - assert: + expr: "!reply.text.includes(config.falseFallbackNeedle)" + message: + expr: "`queue validation emitted the false model fallback: ${reply.text}`" + - set: scenarioRequests + value: + expr: "env.mock ? await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`) : []" + - assert: + expr: "!env.mock || scenarioRequests.length === 0" + message: + expr: "`native queue validation unexpectedly invoked the model ${String(scenarioRequests.length)} time(s)`" + detailsExpr: reply.text diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 47edb7f7ab68..e78d10860048 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -18,6 +18,7 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import { ModelSelectionLockedError } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SkillCommandSpec } from "../../skills/types.js"; +import { isNativeCommandTurn, resolveCommandTurnContext } from "../command-turn-context.js"; import { shouldHandleTextCommands } from "../commands-text-routing.js"; import { markCommandReplyForDelivery } from "../reply-payload.js"; import type { @@ -36,6 +37,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { resolveBlockStreamingChunking } from "./block-streaming.js"; import { buildCommandContext } from "./commands-context.js"; import { type InlineDirectives, parseInlineDirectives } from "./directive-handling.parse.js"; +import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { reserveSkillCommandNames, resolveConfiguredDirectiveAliases, @@ -275,6 +277,25 @@ export async function resolveReplyDirectives(params: { modelAliases: configuredAliases, allowStatusDirective, }); + const commandTurn = resolveCommandTurnContext(ctx); + if ( + command.isAuthorizedSender && + isNativeCommandTurn(commandTurn) && + commandTurn.commandName === "queue" && + parsedDirectives.hasQueueDirective + ) { + // Native command arguments belong to the command, not to an inline prompt; + // validate them before mixed-text cleanup can erase an invalid queue mode. + const queueReply = maybeHandleQueueDirective({ + directives: parsedDirectives, + cfg, + channel: command.channel, + sessionEntry: targetSessionEntry, + }); + if (queueReply) { + return { kind: "reply", reply: markCommandReplyForDelivery(queueReply) }; + } + } const hasInlineStatus = parsedDirectives.hasStatusDirective && parsedDirectives.cleaned.trim().length > 0; if (hasInlineStatus) { diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts index 1be062bd7423..9ea0f8f1dfc4 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.test.ts @@ -43,6 +43,59 @@ describe("maybeResolveNativeSlashCommandFastReply", () => { handleCommandsMock.mockReset(); }); + it("returns native queue validation instead of discarding trailing command arguments", async () => { + handleCommandsMock.mockResolvedValue({ shouldContinue: true }); + + const body = "/queue Can you diagnose this?"; + const typing = createTypingController(); + const result = await maybeResolveNativeSlashCommandFastReply({ + ctx: buildTestCtx({ + Body: body, + BodyForAgent: body, + RawBody: body, + CommandBody: body, + CommandSource: "native", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: "agent:main:telegram:123", + CommandTurn: { + kind: "native", + source: "native", + authorized: true, + commandName: "queue", + body, + }, + }), + cfg: markCompleteReplyConfig({ + session: { + store: path.join(tempDirs.make("openclaw-native-queue-"), "sessions.json"), + }, + } as OpenClawConfig), + agentId: "main", + agentDir: "/tmp/agent", + agentCfg: undefined, + commandAuthorized: true, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: { byKey: new Map(), byAlias: new Map() }, + provider: "openai", + model: "gpt-5.5", + workspaceDir: "/tmp/workspace", + typing, + }); + + expect(result).toEqual({ + handled: true, + reply: expect.objectContaining({ + text: 'Unrecognized queue mode "Can". Valid modes: steer, followup, collect, interrupt.', + }), + }); + expect(handleCommandsMock).toHaveBeenCalledOnce(); + expect(typing.cleanup).toHaveBeenCalledOnce(); + }); + it("marks native /compact terminal replies for delivery under message_tool_only (#90185)", async () => { handleCommandsMock.mockResolvedValueOnce({ shouldContinue: false, From 0e1304d0de02ed6b29ad94e8daec5dfea8b765ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:45:32 -0700 Subject: [PATCH 053/239] fix(llama-cpp): recover plaintext tool calls (#116736) Co-authored-by: Peter Steinberger --- .../llama-cpp/src/inference-provider.test.ts | 94 +++++++++++++++++++ .../llama-cpp/src/inference-provider.ts | 5 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 8d41060a5829..c18d027fd74d 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -364,6 +364,100 @@ describe("llama.cpp inference provider", () => { expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled(); }); + it.each([ + { + format: "Harmony", + text: '<|channel|>commentary to=weather code<|message|>{"city":"Paris"}<|call|>', + }, + { + format: "bracketed", + text: '[weather]\n{"city":"Paris"}\n[END_TOOL_REQUEST]', + }, + ])("promotes $format plaintext tool calls into native tool events", async ({ text }) => { + mocks.generateResponse.mockImplementationOnce(async (_history, options) => { + options.onTextChunk(text.slice(0, 12)); + options.onTextChunk(text.slice(12)); + return { + response: text, + functionCalls: undefined, + metadata: { stopReason: "eogToken" }, + }; + }); + + const stream = await createLlamaCppStreamFn({})(model, { + messages: [{ role: "user", content: "Weather?", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + + const events = await collectEvents(stream); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events.at(-1)).toMatchObject({ + type: "done", + reason: "toolUse", + message: { + stopReason: "toolUse", + content: [ + { + type: "toolCall", + name: "weather", + arguments: { city: "Paris" }, + }, + ], + }, + }); + }); + + it("preserves plaintext calls for tools that are not registered", async () => { + const text = '[tool:calendar] {"city":"Paris"}'; + mocks.generateResponse.mockImplementationOnce(async (_history, options) => { + options.onTextChunk(text); + return { + response: text, + functionCalls: undefined, + metadata: { stopReason: "eogToken" }, + }; + }); + + const stream = await createLlamaCppStreamFn({})(model, { + messages: [{ role: "user", content: "Weather?", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + + const events = await collectEvents(stream); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events.at(-1)).toMatchObject({ + type: "done", + reason: "stop", + message: { content: [{ type: "text", text }] }, + }); + }); + it("lets tools win when responseFormat is also present", async () => { const stream = await createLlamaCppStreamFn({})( model, diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index e163f645c94a..0de0499bf706 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -17,6 +17,7 @@ import type { } from "openclaw/plugin-sdk/llm"; import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { DEFAULT_LLAMA_CPP_CONTEXT_SIZE, resolveLlamaCppModelCacheDir, @@ -293,7 +294,7 @@ async function clearLlamaCppInferenceCacheForTests(): Promise { } export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderConfig }): StreamFn { - return (model, context, options) => { + return createPlainTextToolCallCompatWrapper((model, context, options) => { const stream = createAssistantMessageEventStream(); let streamedText = ""; let generationAborted = false; @@ -453,7 +454,7 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC queueMicrotask(() => void serialize(run)); } return stream; - }; + }); } if (process.env.VITEST || process.env.NODE_ENV === "test") { From 1112c1cc827731077f16f2dc523339ac07247382 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:46:38 -0700 Subject: [PATCH 054/239] fix(agents): compact session status change text (#116742) --- .../openclaw-tools.session-status.test.ts | 66 +++++++++++++++++-- src/agents/tools/session-status-tool.ts | 6 +- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 8fafe6ed5d16..0a3a0ec1c0ad 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -578,6 +578,25 @@ describe("session_status tool", () => { getSessionStateVersionMock.mockReturnValue(12); listSessionStateEventsSinceMock.mockReturnValue({ events: [ + { + sequence: 11, + sessionKey: "main", + sessionId: "s1", + agentId: "main", + kind: "run_failed", + actorType: "agent", + actorId: "worker-1", + runId: "run-11", + occurredAt: 90, + summary: "child run timed out", + payload: { + outcome: "timeout", + channel: "codex", + turns: 2, + catalogId: "internal-catalog", + nested: { drop: true }, + }, + }, { sequence: 12, sessionKey: "main", @@ -591,7 +610,7 @@ describe("session_status tool", () => { }, ], truncated: false, - earliestAvailableSequence: 12, + earliestAvailableSequence: 11, historyGap: true, }); @@ -603,9 +622,18 @@ describe("session_status tool", () => { expect(getSessionStateVersionMock).toHaveBeenCalledWith("main", "main"); expect(listSessionStateEventsSinceMock).toHaveBeenCalledWith("main", "main", 3, 200); expect(details.stateVersion).toBe(12); - expect(details.stateChanges).toMatchObject({ - historyGap: true, + const expectedStateChanges = { events: [ + { + sequence: 11, + kind: "run_failed", + actorType: "agent", + occurredAt: 90, + summary: "child run timed out", + actorId: "worker-1", + runId: "run-11", + payload: { outcome: "timeout", channel: "codex", turns: 2 }, + }, { sequence: 12, kind: "upstream_missing", @@ -615,11 +643,35 @@ describe("session_status tool", () => { payload: { channel: "codex" }, }, ], - }); + truncated: false, + earliestAvailableSequence: 11, + historyGap: true, + }; + expect(details.stateChanges).toEqual(expectedStateChanges); expect(Value.Check(tool.outputSchema!, result.details)).toBe(true); - expect(JSON.stringify(details.stateChanges)).not.toContain("internal-catalog"); - expect(text).toContain("Session state changes:"); - expect(text).toContain('"kind": "upstream_missing"'); + expect(details.statusText).toBe(text); + const stateChangesMarker = "Session state changes:\n```json\n"; + const stateChangesStart = text.indexOf(stateChangesMarker); + expect(stateChangesStart).toBeGreaterThanOrEqual(0); + const stateChangesJsonStart = stateChangesStart + stateChangesMarker.length; + const stateChangesJsonEnd = text.indexOf("\n```", stateChangesJsonStart); + expect(stateChangesJsonEnd).toBeGreaterThan(stateChangesJsonStart); + const visibleStateChangesText = text.slice(stateChangesJsonStart, stateChangesJsonEnd); + expect(JSON.parse(visibleStateChangesText)).toEqual({ + stateVersion: 12, + stateChanges: expectedStateChanges, + }); + for (const omittedField of [ + '"sessionKey"', + '"sessionId"', + '"agentId"', + '"catalogId"', + '"nested"', + "internal-catalog", + ]) { + expect(visibleStateChangesText).not.toContain(omittedField); + expect(String(details.statusText)).not.toContain(omittedField); + } }); it("returns watched group changesSince under tree visibility", async () => { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index b6357324b4d3..331017840082 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -363,7 +363,7 @@ ${JSON.stringify(details, null, 2)} function formatSessionStateChanges(details: { stateVersion: number; - stateChanges: ReturnType; + stateChanges: ReturnType; }): string { return `Session state changes: \`\`\`json @@ -1085,9 +1085,7 @@ export function createSessionStatusTool(opts?: { : undefined; const extraBlocks = [ routeContextText, - rawStateChanges - ? formatSessionStateChanges({ stateVersion, stateChanges: rawStateChanges }) - : undefined, + stateChanges ? formatSessionStateChanges({ stateVersion, stateChanges }) : undefined, ].filter((block): block is string => Boolean(block)); const visibleStatusText = extraBlocks.length > 0 From b5a4f6e836b13710288214447d157c50cbbae379 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:50:27 +0800 Subject: [PATCH 055/239] chore(openai): drop release-owned changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4f39696aec..7fc8f587723b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- **OpenAI realtime preconnect close:** discard queued Talk audio when a bridge closes before its first connection, keep repeated closes idempotent, and require an explicit fresh connect before audio can flow again. - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. From f943a94a3303c3e37c86a42626a03c2f1849c474 Mon Sep 17 00:00:00 2001 From: civil <97610456+civiltox@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:53:05 +1000 Subject: [PATCH 056/239] fix(agents): preserve thinking for runtime-selected Ollama models (#116584) * fix(agents): hydrate catalog for thinking validation * test(agents): type runtime catalog fixture * test: align runtime catalog mocks with thinking hydration * fix(cron): preserve explicit-off catalog path * test(cron): type Ollama thinking defaults * docs(changelog): note Ollama fallback thinking fix * fix(cron): recompute fallback thinking * chore(changelog): remove release-owned entry * fix(cron): keep thinking catalog helper private * test(cron): align executor fixtures with thinking catalog --------- Co-authored-by: Vincent Koc --- .../agent-command.compaction-rotation.test.ts | 7 + ...-command.live-model-switch.test-helpers.ts | 2 +- .../agent-command.live-model-switch.test.ts | 143 ++++++- src/agents/command/model-selection.ts | 61 ++- src/agents/command/run-embedded-attempt.ts | 52 ++- src/agents/model-thinking-default.ts | 61 +-- src/agents/thinking-runtime.test.ts | 30 +- src/agents/thinking-runtime.ts | 28 ++ .../reply/get-reply-run-admission.ts | 2 +- src/auto-reply/reply/get-reply-run-helpers.ts | 20 +- src/commands/agent-command.test-mocks.ts | 4 + src/cron/isolated-agent.mocks.ts | 4 + src/cron/isolated-agent/model-selection.ts | 73 ++++ src/cron/isolated-agent/run-executor.ts | 58 ++- .../run-model-selection.runtime.ts | 5 +- src/cron/isolated-agent/run-prepare.ts | 57 ++- .../run.cron-runtime-model-thinking.test.ts | 360 ++++++++++++++++++ .../run.message-tool-policy.test.ts | 3 +- src/cron/isolated-agent/run.runtime.ts | 1 - .../run.source-delivery-guard.test.ts | 5 +- src/cron/isolated-agent/run.test-harness.ts | 4 + src/cron/isolated-agent/run.ts | 5 +- 22 files changed, 881 insertions(+), 104 deletions(-) create mode 100644 src/cron/isolated-agent/run.cron-runtime-model-thinking.test.ts diff --git a/src/agents/agent-command.compaction-rotation.test.ts b/src/agents/agent-command.compaction-rotation.test.ts index 04e51d20d455..f5a068cae3d4 100644 --- a/src/agents/agent-command.compaction-rotation.test.ts +++ b/src/agents/agent-command.compaction-rotation.test.ts @@ -86,6 +86,13 @@ vi.mock("./model-catalog.js", () => ({ state.loadManifestModelCatalogMock(params), })); +vi.mock("./model-catalog.runtime.js", () => ({ + loadPreparedModelCatalogSnapshot: vi.fn(async () => ({ + entries: [], + routeVariants: [], + })), +})); + vi.mock("./provider-model-normalization.runtime.js", () => ({ normalizeProviderModelIdWithRuntime: (params: { provider: string; diff --git a/src/agents/agent-command.live-model-switch.test-helpers.ts b/src/agents/agent-command.live-model-switch.test-helpers.ts index 117491749e13..1cf3a3c436a3 100644 --- a/src/agents/agent-command.live-model-switch.test-helpers.ts +++ b/src/agents/agent-command.live-model-switch.test-helpers.ts @@ -159,7 +159,7 @@ export function buildTestAllowedModelSet({ return { allowedKeys, allowedCatalog: allowedCatalog.filter((entry) => - allowedKeys.has(`${entry.provider}/${entry.id}`), + isTestModelKeyAllowed(allowedKeys, `${entry.provider}/${entry.id}`), ), allowAny: false, }; diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index bf4c6852e37e..f626ede7205d 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -30,6 +30,7 @@ import { INTERNAL_RUNTIME_CONTEXT_END, } from "./internal-runtime-context.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; import { createAgentRunDirectAbortError, createAgentRunRestartAbortError, @@ -95,6 +96,12 @@ const state = vi.hoisted(() => ({ resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level), resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"), loadManifestModelCatalogMock: vi.fn(() => []), + loadPreparedModelCatalogSnapshotMock: vi.fn( + async (): Promise => ({ + entries: [], + routeVariants: [], + }), + ), buildWorkspaceSkillSnapshotMock: vi.fn((..._args: unknown[]): unknown => ({ prompt: "", skills: [], @@ -529,6 +536,10 @@ vi.mock("./model-catalog.js", () => ({ loadManifestModelCatalog: state.loadManifestModelCatalogMock, })); +vi.mock("./model-catalog.runtime.js", () => ({ + loadPreparedModelCatalogSnapshot: state.loadPreparedModelCatalogSnapshotMock, +})); + vi.mock("./model-selection.js", () => ({ buildAllowedModelSet: buildTestAllowedModelSet, createModelVisibilityPolicy: createTestModelVisibilityPolicy, @@ -840,6 +851,10 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.resolveThinkingDefaultMock.mockReturnValue("low"); state.resolveAgentSkillsFilterMock.mockReturnValue(undefined); state.loadManifestModelCatalogMock.mockReturnValue([]); + state.loadPreparedModelCatalogSnapshotMock.mockResolvedValue({ + entries: [], + routeVariants: [], + }); state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(false); state.isModelSelectionLockedMock.mockImplementation( (entry: unknown) => @@ -2230,15 +2245,42 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { defaults: { model: { primary: "openai/gpt-5.6-sol" }, models: { - "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + "openai/gpt-5.6-sol": { + agentRuntime: { id: "codex" }, + params: { thinking: "off" }, + }, "openai/gpt-5.6-terra": { agentRuntime: { id: "codex" } }, }, }, }, }; state.resolveThinkingDefaultMock.mockImplementation((args: unknown) => { - const { model } = args as { model?: string }; - return model === "gpt-5.6-terra" ? "medium" : "low"; + const { model, catalog } = args as { + model?: string; + catalog?: Array<{ provider: string; id: string; reasoning?: boolean }>; + }; + if (model === "gpt-5.6-terra") { + expect(catalog).toEqual([ + expect.objectContaining({ + provider: "openai", + id: "gpt-5.6-terra", + reasoning: true, + }), + ]); + return "medium"; + } + return "low"; + }); + state.loadPreparedModelCatalogSnapshotMock.mockResolvedValue({ + entries: [ + { + provider: "OpenAI", + id: "gpt-5.6-terra", + name: "GPT 5.6 Terra", + reasoning: true, + }, + ], + routeVariants: [], }); state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => { await params.run(params.provider, params.model); @@ -2259,12 +2301,13 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), { modelOverride: "gpt-5.6-sol", - resolvedThinkLevel: "low", + resolvedThinkLevel: "off", }); expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), { modelOverride: "gpt-5.6-terra", resolvedThinkLevel: "medium", }); + expect(state.loadPreparedModelCatalogSnapshotMock).toHaveBeenCalledTimes(1); }); it("persists and clears current run delivery context for restart recovery", async () => { @@ -3321,6 +3364,98 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); }); + it("hydrates live catalog metadata before validating an explicit thinking level", async () => { + state.runtimeConfigMock = { + agents: { + defaults: { + thinkingDefault: "low", + model: { primary: "openai/gpt-5.4" }, + models: { + "openai/*": {}, + "ollama/*": {}, + }, + }, + }, + }; + state.loadManifestModelCatalogMock.mockReturnValue([]); + state.loadPreparedModelCatalogSnapshotMock.mockResolvedValue({ + entries: [ + { + provider: "OLLAMA", + id: "minimax-m3:cloud", + name: "minimax-m3:cloud", + reasoning: true, + }, + ], + routeVariants: [], + }); + state.isThinkingLevelSupportedMock.mockImplementation((args: unknown) => { + const { catalog, level } = args as { + catalog?: Array<{ reasoning?: boolean }>; + level?: string; + }; + return level === "off" || catalog?.some((entry) => entry.reasoning === true) === true; + }); + setupSuccessfulAttempt("ollama", "minimax-m3:cloud"); + + await agentCommand({ + message: "hello", + to: "+1234567890", + model: "ollama/minimax-m3:cloud", + thinking: "medium", + allowModelOverride: true, + }); + + expect(state.loadPreparedModelCatalogSnapshotMock).toHaveBeenCalledWith({ + config: state.runtimeConfigMock, + agentId: "default", + workspaceDir: "/tmp/workspace", + }); + const thinkingArgs = requireRecord( + mockCallArg(state.isThinkingLevelSupportedMock), + "thinking args", + ); + expect(thinkingArgs.level).toBe("medium"); + expect(thinkingArgs.catalog).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + reasoning: true, + }), + ]); + }); + + it("does not hydrate live catalog metadata when the selected model config disables thinking", async () => { + state.runtimeConfigMock = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { + "openai/*": {}, + "ollama/minimax-m3:cloud": { + params: { thinking: "off" }, + }, + }, + }, + }, + }; + state.loadManifestModelCatalogMock.mockReturnValue([]); + setupSuccessfulAttempt("ollama", "minimax-m3:cloud"); + + await agentCommand({ + message: "hello", + to: "+1234567890", + model: "ollama/minimax-m3:cloud", + allowModelOverride: true, + }); + + expect(state.loadPreparedModelCatalogSnapshotMock).not.toHaveBeenCalled(); + expectRecordFields(mockCallArg(state.runAgentAttemptMock), { + modelOverride: "minimax-m3:cloud", + resolvedThinkLevel: "off", + }); + }); + it("resolves explicit model aliases before thinking validation", async () => { state.runtimeConfigMock = { agents: { diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index ec22a7997a83..eb5361fc1c9e 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -45,6 +45,7 @@ import { resolveModelAliasFromPair, resolveThinkingDefault, } from "../model-selection.js"; +import { resolveConfiguredThinkingDefault } from "../model-thinking-default.js"; import { createModelVisibilityPolicy, type ModelVisibilityPolicy, @@ -52,7 +53,11 @@ import { import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../openai-routing.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import { resolveSessionRuntimeOverrideForProvider } from "../session-runtime-compat.js"; -import { resolveEffectiveAgentRuntime } from "../thinking-runtime.js"; +import { + hasResolvedThinkingCatalogEntry, + normalizeThinkingCatalogProviders, + resolveEffectiveAgentRuntime, +} from "../thinking-runtime.js"; import { normalizeAgentCommandDefaultModelRef, normalizeAgentCommandModelRef, @@ -483,12 +488,58 @@ export async function resolveEmbeddedModelSelection(params: { } } - const catalogForThinking = + const configuredThinkLevel = normalizeThinkLevel( + resolveAgentConfig(params.cfg, params.sessionAgentId)?.thinkingDefault, + ); + const immutableThinkLevel = params.requestedThinkLevel ?? configuredThinkLevel; + const primaryConfiguredThinkLevel = + immutableThinkLevel ?? + resolveConfiguredThinkingDefault({ + cfg: params.cfg, + provider, + model, + }); + let catalogForThinking = allowedModelCatalog.length > 0 ? allowedModelCatalog : modelCatalog && modelCatalog.length > 0 ? modelCatalog : params.configuredThinkingCatalog; + if ( + params.pluginsEnabled && + primaryConfiguredThinkLevel !== "off" && + !hasResolvedThinkingCatalogEntry({ catalog: catalogForThinking, provider, model }) + ) { + const { loadPreparedModelCatalogSnapshot } = await import("../model-catalog.runtime.js"); + const runtimeCatalog = normalizeThinkingCatalogProviders( + ( + await loadPreparedModelCatalogSnapshot({ + config: params.cfg, + agentId: params.sessionAgentId, + workspaceDir: params.workspaceDir, + }) + ).entries, + ); + const allowedRuntimeCatalog = createModelVisibilityPolicy({ + cfg: params.cfg, + catalog: runtimeCatalog, + defaultProvider, + defaultModel, + agentId: params.sessionAgentId, + allowManifestNormalization: true, + allowPluginNormalization: params.pluginsEnabled, + ...params.modelManifestContext, + }).allowedCatalog; + if ( + hasResolvedThinkingCatalogEntry({ + catalog: allowedRuntimeCatalog, + provider, + model, + }) + ) { + catalogForThinking = allowedRuntimeCatalog; + } + } const thinkingCatalog = catalogForThinking.length > 0 ? catalogForThinking : undefined; const thinkingRuntime = resolveEffectiveAgentRuntime({ cfg: params.cfg, @@ -498,12 +549,8 @@ export async function resolveEmbeddedModelSelection(params: { sessionKey: params.sessionKey, sessionEntry: sessionEntryForAttempt, }); - const configuredThinkLevel = normalizeThinkLevel( - resolveAgentConfig(params.cfg, params.sessionAgentId)?.thinkingDefault, - ); - const immutableThinkLevel = params.requestedThinkLevel ?? configuredThinkLevel; const primaryThinkLevel = - immutableThinkLevel ?? + primaryConfiguredThinkLevel ?? resolveThinkingDefault({ cfg: params.cfg, provider, diff --git a/src/agents/command/run-embedded-attempt.ts b/src/agents/command/run-embedded-attempt.ts index d689e812e5d2..53c1edc9d959 100644 --- a/src/agents/command/run-embedded-attempt.ts +++ b/src/agents/command/run-embedded-attempt.ts @@ -29,6 +29,8 @@ import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.j import { prepareInternalSessionEffectsSession } from "../internal-session-effects.js"; import { LiveSessionModelSwitchError } from "../live-model-switch.js"; import { modelKey, resolveThinkingDefault } from "../model-selection.js"; +import { resolveConfiguredThinkingDefault } from "../model-thinking-default.js"; +import { createModelVisibilityPolicy } from "../model-visibility-policy.js"; import type { AgentRunSessionTarget } from "../run-session-target.js"; import { isAgentRunDirectAbortReason, @@ -37,6 +39,8 @@ import { } from "../run-termination.js"; import { resolveSessionRuntimeOverrideForProvider } from "../session-runtime-compat.js"; import { + hasResolvedThinkingCatalogEntry, + normalizeThinkingCatalogProviders, resolveCandidateThinkingLevel, resolveEffectiveAgentRuntime, } from "../thinking-runtime.js"; @@ -99,7 +103,6 @@ export async function runEmbeddedAgentAttempt(params: { storedProviderOverride, hasStoredAutoFallbackProvenance, autoFallbackPrimaryProbe, - thinkingCatalog, immutableThinkLevel, sessionFile, } = params.modelSelection; @@ -112,6 +115,8 @@ export async function runEmbeddedAgentAttempt(params: { storedModelOverrideSource, effectiveTurnThinkLevel, } = params.modelSelection; + let thinkingCatalog = params.modelSelection.thinkingCatalog; + let attemptedThinkingCatalogHydration = false; let sessionEntry = params.sessionEntry; let lifecycleGeneration = params.lifecycleGeneration; @@ -361,8 +366,51 @@ export async function runEmbeddedAgentAttempt(params: { sessionKey, sessionEntry: attemptSessionEntry, }); - const candidateRequestedThinkLevel = + const candidateConfiguredThinkLevel = immutableThinkLevel ?? + resolveConfiguredThinkingDefault({ + cfg, + provider: providerOverride, + model: modelOverride, + }); + if ( + pluginsEnabled && + candidateConfiguredThinkLevel !== "off" && + !attemptedThinkingCatalogHydration && + !hasResolvedThinkingCatalogEntry({ + catalog: thinkingCatalog, + provider: providerOverride, + model: modelOverride, + }) + ) { + attemptedThinkingCatalogHydration = true; + const { loadPreparedModelCatalogSnapshot } = + await import("../model-catalog.runtime.js"); + const runtimeCatalog = normalizeThinkingCatalogProviders( + ( + await loadPreparedModelCatalogSnapshot({ + config: cfg, + agentId: sessionAgentId, + workspaceDir, + }) + ).entries, + ); + const allowedRuntimeCatalog = createModelVisibilityPolicy({ + cfg, + catalog: runtimeCatalog, + defaultProvider, + defaultModel, + agentId: sessionAgentId, + allowManifestNormalization: true, + allowPluginNormalization: true, + ...modelManifestContext, + }).allowedCatalog; + if (allowedRuntimeCatalog.length > 0) { + thinkingCatalog = allowedRuntimeCatalog; + } + } + const candidateRequestedThinkLevel = + candidateConfiguredThinkLevel ?? resolveThinkingDefault({ cfg, provider: providerOverride, diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index 07347abab959..a9210e87d367 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -16,6 +16,41 @@ import { legacyModelKey, modelKey, normalizeProviderId } from "./model-ref-share import { normalizeModelSelection } from "./model-selection-resolve.js"; import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; +/** Resolves configured thinking without consulting model capability metadata. */ +export function resolveConfiguredThinkingDefault(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): ThinkLevel | undefined { + const configuredModels = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + const perModelThinking = + configuredModels?.[canonicalKey]?.params?.thinking ?? + (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); + if ( + perModelThinking === false || + perModelThinking === "disabled" || + perModelThinking === "none" + ) { + return "off"; + } + if ( + perModelThinking === "off" || + perModelThinking === "minimal" || + perModelThinking === "low" || + perModelThinking === "medium" || + perModelThinking === "high" || + perModelThinking === "xhigh" || + perModelThinking === "adaptive" || + perModelThinking === "max" || + perModelThinking === "ultra" + ) { + return perModelThinking; + } + return params.cfg.agents?.defaults?.thinkingDefault; +} + /** Resolves the default thinking level for a provider/model pair. */ export function resolveThinkingDefault(params: { cfg: OpenClawConfig; @@ -45,31 +80,7 @@ export function resolveThinkingDefault(params: { normalizedPrimarySelection === normalizedCanonicalKey || Boolean(normalizedLegacyKey && normalizedPrimarySelection === normalizedLegacyKey) || normalizedPrimarySelection === normalizeLowercaseStringOrEmpty(params.model); - const perModelThinking = - configuredModels?.[canonicalKey]?.params?.thinking ?? - (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); - // Accept boolean false and common disable aliases as "off". - if ( - perModelThinking === false || - perModelThinking === "disabled" || - perModelThinking === "none" - ) { - return "off"; - } - if ( - perModelThinking === "off" || - perModelThinking === "minimal" || - perModelThinking === "low" || - perModelThinking === "medium" || - perModelThinking === "high" || - perModelThinking === "xhigh" || - perModelThinking === "adaptive" || - perModelThinking === "max" || - perModelThinking === "ultra" - ) { - return perModelThinking; - } - const configured = params.cfg.agents?.defaults?.thinkingDefault; + const configured = resolveConfiguredThinkingDefault(params); if (configured) { return configured; } diff --git a/src/agents/thinking-runtime.test.ts b/src/agents/thinking-runtime.test.ts index dbd7bff12ea0..ee386fc4b235 100644 --- a/src/agents/thinking-runtime.test.ts +++ b/src/agents/thinking-runtime.test.ts @@ -7,7 +7,35 @@ import { restoreRegisteredAgentHarnesses, } from "./harness/registry.js"; import type { AgentHarness } from "./harness/types.js"; -import { resolveCandidateThinkingLevel, resolveEffectiveAgentRuntime } from "./thinking-runtime.js"; +import { + hasResolvedThinkingCatalogEntry, + resolveCandidateThinkingLevel, + resolveEffectiveAgentRuntime, +} from "./thinking-runtime.js"; + +describe("hasResolvedThinkingCatalogEntry", () => { + it("requires authoritative reasoning metadata for the selected model", () => { + const catalog = [ + { provider: "ollama", id: "unknown", reasoning: true }, + { provider: "OLLAMA", id: "minimax-m3:cloud" }, + ]; + + expect( + hasResolvedThinkingCatalogEntry({ + catalog, + provider: "ollama", + model: "minimax-m3:cloud", + }), + ).toBe(false); + expect( + hasResolvedThinkingCatalogEntry({ + catalog: [{ provider: "OLLAMA", id: "minimax-m3:cloud", reasoning: false }], + provider: "ollama", + model: "minimax-m3:cloud", + }), + ).toBe(true); + }); +}); function openAIConfig(runtime: string): OpenClawConfig { return { diff --git a/src/agents/thinking-runtime.ts b/src/agents/thinking-runtime.ts index 773aa73e9c92..96a82e86ee18 100644 --- a/src/agents/thinking-runtime.ts +++ b/src/agents/thinking-runtime.ts @@ -1,3 +1,5 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isThinkingLevelSupported, resolveSupportedThinkingLevel, @@ -11,6 +13,32 @@ import { resolveAgentHarnessPolicy } from "./harness/policy.js"; import { resolveAutoAgentHarnessId } from "./harness/support.js"; import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js"; +export function hasResolvedThinkingCatalogEntry(params: { + catalog?: readonly ThinkingCatalogEntry[]; + provider: string; + model: string; +}): boolean { + const modelId = normalizeOptionalString(params.model); + if (!modelId) { + return false; + } + const normalizedProvider = normalizeProviderId(params.provider); + const entry = params.catalog?.find( + (candidate) => + normalizeProviderId(candidate.provider) === normalizedProvider && candidate.id === modelId, + ); + return entry?.reasoning !== undefined; +} + +export function normalizeThinkingCatalogProviders( + catalog: readonly T[], +): T[] { + return catalog.map((entry) => { + const provider = normalizeProviderId(entry.provider); + return provider === entry.provider ? entry : Object.assign({}, entry, { provider }); + }); +} + /** Convert residual auto policy into the built-in fallback when no registry selection is needed. */ export function concretizeAgentRuntime(runtime: string): string { return runtime === "auto" ? "openclaw" : runtime; diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts index 517908aae9d5..876e6c4c365f 100644 --- a/src/auto-reply/reply/get-reply-run-admission.ts +++ b/src/auto-reply/reply/get-reply-run-admission.ts @@ -4,6 +4,7 @@ import { clearAutoFallbackPrimaryProbeSelection } from "../../agents/agent-scope import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; +import { hasResolvedThinkingCatalogEntry } from "../../agents/thinking-runtime.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePath, @@ -23,7 +24,6 @@ import { } from "../thinking.js"; import type { PreparedReplyRunContext } from "./get-reply-run-context.js"; import { - hasResolvedThinkingCatalogEntry, loadAgentRunnerRuntime, loadEmbeddedAgentRuntime, loadSessionUpdatesRuntime, diff --git a/src/auto-reply/reply/get-reply-run-helpers.ts b/src/auto-reply/reply/get-reply-run-helpers.ts index 927d8a42c16d..d161aca5ece0 100644 --- a/src/auto-reply/reply/get-reply-run-helpers.ts +++ b/src/auto-reply/reply/get-reply-run-helpers.ts @@ -1,4 +1,3 @@ -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { EmbeddedFullAccessBlockedReason } from "../../agents/embedded-agent-runner/types.js"; @@ -15,7 +14,7 @@ import { } from "../../utils/delivery-context.shared.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import type { MsgContext, TemplateContext } from "../templating.js"; -import type { ElevatedLevel, ThinkingCatalogEntry } from "../thinking.js"; +import type { ElevatedLevel } from "../thinking.js"; import { isSystemEventProvider } from "./effective-reply-route.js"; import type { ExecOverrides } from "./get-reply-run.types.js"; import { @@ -93,23 +92,6 @@ export function buildPersistedMediaImageLayout(params: { }; } -export function hasResolvedThinkingCatalogEntry(params: { - catalog?: readonly ThinkingCatalogEntry[]; - provider: string; - model: string; -}): boolean { - const modelId = normalizeOptionalString(params.model); - if (!modelId) { - return false; - } - const normalizedProvider = normalizeProviderId(params.provider); - const entry = params.catalog?.find( - (candidate) => - normalizeProviderId(candidate.provider) === normalizedProvider && candidate.id === modelId, - ); - return entry?.reasoning !== undefined; -} - export function routeThreadIdsMatch( activeThreadId: string | number | undefined, currentThreadId: string | number | undefined, diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index e242634fc8fc..6d7db7e85250 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -64,6 +64,10 @@ vi.mock("../agents/model-catalog.js", () => ({ vi.mock("../agents/prepared-model-catalog.js", () => ({ loadPreparedModelCatalog: vi.fn(), + loadPreparedModelCatalogSnapshot: vi.fn(async () => ({ + entries: [], + routeVariants: [], + })), })); vi.mock("../agents/model-selection.js", () => { diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index d2523025ed4c..92f5b7a9eb6a 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -17,6 +17,10 @@ vi.mock("../agents/prepared-model-catalog.js", async () => { await vi.importActual("../agents/agent-scope.js"); return { loadPreparedModelCatalog, + loadPreparedModelCatalogSnapshot: vi.fn(async (params) => ({ + entries: (await loadPreparedModelCatalog(params)) ?? [], + routeVariants: [], + })), loadPublishedPreparedModelCatalog: loadPreparedModelCatalog, publishedModelCatalogOwnerMatchesAgent: (owner: { agentId: string }, agentId: string) => owner.agentId === agentId.trim().toLowerCase(), diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index 054b06549deb..dddea265386f 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -1,4 +1,11 @@ +import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { resolveConfiguredModelPolicyAllow } from "../../agents/model-selection-shared.js"; +import { resolveConfiguredThinkingDefault } from "../../agents/model-thinking-default.js"; +import { + hasResolvedThinkingCatalogEntry, + normalizeThinkingCatalogProviders, +} from "../../agents/thinking-runtime.js"; +import { normalizeThinkLevel, type ThinkLevel } from "../../auto-reply/thinking.js"; /** Resolves provider/model precedence for isolated cron runs. */ import type { AgentConfig } from "../../config/types.agents.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -9,6 +16,7 @@ import { DEFAULT_PROVIDER, getModelRefStatus, loadResolvedPublishedModelCatalogOwner, + loadPreparedModelCatalogSnapshot, normalizeModelSelection, publishedModelCatalogOwnerMatchesAgent, resolveAgentConfig, @@ -102,6 +110,71 @@ export async function resolveCronModelSelectionOwner(params: { return owner; } +async function resolveCronThinkingCatalog(params: { + owner: ResolvedPublishedModelCatalogOwner; + provider: string; + model: string; +}): Promise { + const catalog = normalizeThinkingCatalogProviders(params.owner.modelCatalog.entries); + if ( + hasResolvedThinkingCatalogEntry({ + catalog, + provider: params.provider, + model: params.model, + }) + ) { + return catalog; + } + return normalizeThinkingCatalogProviders( + ( + await loadPreparedModelCatalogSnapshot({ + config: params.owner.config, + agentId: params.owner.agentId, + agentDir: params.owner.agentDir, + workspaceDir: params.owner.workspaceDir, + }) + ).entries, + ); +} + +export async function resolveCronThinkingSelection(params: { + cfg: OpenClawConfig; + owner: ResolvedPublishedModelCatalogOwner; + provider: string; + model: string; + jobThinking?: string; + hookThinking?: string; + sessionThinking?: string; +}): Promise<{ + catalog: ModelCatalogEntry[]; + immutableThinkLevel: ThinkLevel | undefined; + loadThinkingCatalog: (provider: string, model: string) => Promise; + requestedThinkLevel: ThinkLevel | undefined; +}> { + const immutableThinkLevel = + normalizeThinkLevel(params.jobThinking) ?? + normalizeThinkLevel(params.hookThinking) ?? + normalizeThinkLevel(params.sessionThinking); + const requestedThinkLevel = + immutableThinkLevel ?? + resolveConfiguredThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); + const catalog = + requestedThinkLevel === "off" + ? params.owner.modelCatalog.entries + : await resolveCronThinkingCatalog(params); + return { + catalog, + immutableThinkLevel, + loadThinkingCatalog: async (provider, model) => + await resolveCronThinkingCatalog({ owner: params.owner, provider, model }), + requestedThinkLevel, + }; +} + /** Resolves the effective model for an isolated cron run across defaults, agents, hooks, payload, and session state. */ export async function resolveCronModelSelection( params: ResolveCronModelSelectionParams, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index a40823bf8e0b..3bd62b4ee20c 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -7,9 +7,11 @@ import type { FastModeAutoProgressState } from "../../agents/fast-mode.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; +import { resolveConfiguredThinkingDefault } from "../../agents/model-thinking-default.js"; import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js"; import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js"; import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; +import { hasResolvedThinkingCatalogEntry } from "../../agents/thinking-runtime.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; import type { CliSessionBinding } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; @@ -55,6 +57,7 @@ import type { PersistCronSessionEntry, } from "./run-session-state.js"; import { syncCronSessionLiveSelection } from "./run-session-state.js"; +import { resolveEffectiveAgentRuntime, resolveThinkingDefault } from "./run.runtime.js"; import { isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; type AgentTurnPayload = Extract | null; @@ -204,8 +207,9 @@ function createCronPromptExecutor(params: { workspaceDir: string; lane?: string; resolvedVerboseLevel: VerboseLevel; - thinkLevel: ThinkLevel | undefined; + immutableThinkLevel: ThinkLevel | undefined; thinkingCatalog?: ModelCatalogEntry[]; + loadThinkingCatalog: (provider: string, model: string) => Promise; timeoutMs: number; /** Set when the cron payload's `timeoutSeconds` was explicitly configured. */ runTimeoutOverrideMs?: number; @@ -285,6 +289,8 @@ function createCronPromptExecutor(params: { } | undefined; let attemptMediaTaskIds: ReadonlySet = new Set(); + let thinkingCatalog = params.thinkingCatalog; + let attemptedThinkingCatalogHydration = false; const currentAttemptCommittedMedia = () => hasNewGeneratedMediaTaskForSessionKey(params.runSessionKey, attemptMediaTaskIds); @@ -365,15 +371,55 @@ function createCronPromptExecutor(params: { entry: params.cronSession.sessionEntry, cfg: params.cfgWithAgentDefaults, }); + const candidateRuntime = resolveEffectiveAgentRuntime({ + cfg: params.cfgWithAgentDefaults, + provider: providerOverride, + modelId: modelOverride, + agentId: params.agentId, + sessionKey: params.runSessionKey, + sessionEntry: params.cronSession.sessionEntry, + }); + const candidateConfiguredThinkLevel = + params.immutableThinkLevel ?? + resolveConfiguredThinkingDefault({ + cfg: params.cfgWithAgentDefaults, + provider: providerOverride, + model: modelOverride, + }); + if ( + candidateConfiguredThinkLevel !== "off" && + !attemptedThinkingCatalogHydration && + !hasResolvedThinkingCatalogEntry({ + catalog: thinkingCatalog, + provider: providerOverride, + model: modelOverride, + }) + ) { + attemptedThinkingCatalogHydration = true; + const runtimeCatalog = await params.loadThinkingCatalog(providerOverride, modelOverride); + if (runtimeCatalog.length > 0) { + thinkingCatalog = runtimeCatalog; + } + } + const candidateRequestedThinkLevel = + candidateConfiguredThinkLevel ?? + resolveThinkingDefault({ + cfg: params.cfgWithAgentDefaults, + provider: providerOverride, + model: modelOverride, + catalog: thinkingCatalog, + agentRuntime: candidateRuntime, + }); const candidateThinkLevel = resolveCandidateThinkingLevel({ cfg: params.cfgWithAgentDefaults, provider: providerOverride, modelId: modelOverride, - level: params.thinkLevel, - catalog: params.thinkingCatalog, + level: candidateRequestedThinkLevel, + catalog: thinkingCatalog, agentId: params.agentId, sessionKey: params.runSessionKey, sessionEntry: params.cronSession.sessionEntry, + agentRuntime: candidateRuntime, }); const executionProvider = (sessionRuntimeOverride && @@ -650,8 +696,9 @@ export async function executeCronRun(params: { Partial>, ) => void; onLaneWait?: (info?: { waiting?: boolean }) => void; - thinkLevel: ThinkLevel | undefined; + immutableThinkLevel: ThinkLevel | undefined; thinkingCatalog?: ModelCatalogEntry[]; + loadThinkingCatalog: (provider: string, model: string) => Promise; timeoutMs: number; /** Set when the cron payload's `timeoutSeconds` was explicitly configured. */ runTimeoutOverrideMs?: number; @@ -679,8 +726,9 @@ export async function executeCronRun(params: { workspaceDir: params.workspaceDir, lane: params.lane, resolvedVerboseLevel, - thinkLevel: params.thinkLevel, + immutableThinkLevel: params.immutableThinkLevel, thinkingCatalog: params.thinkingCatalog, + loadThinkingCatalog: params.loadThinkingCatalog, timeoutMs: params.timeoutMs, runTimeoutOverrideMs: params.runTimeoutOverrideMs, suppressExecNotifyOnExit: params.suppressExecNotifyOnExit, diff --git a/src/cron/isolated-agent/run-model-selection.runtime.ts b/src/cron/isolated-agent/run-model-selection.runtime.ts index 34cd046a1d0c..33457ef300e4 100644 --- a/src/cron/isolated-agent/run-model-selection.runtime.ts +++ b/src/cron/isolated-agent/run-model-selection.runtime.ts @@ -3,7 +3,10 @@ export { resolveAgentConfig } from "../../agents/agent-scope-config.js"; export { resolveSubagentModelConfigSelectionResult } from "../../agents/agent-scope.js"; export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; export { publishedModelCatalogOwnerMatchesAgent } from "../../agents/prepared-model-catalog-owner.js"; -export { loadResolvedPublishedModelCatalogOwner } from "../../agents/prepared-model-catalog.js"; +export { + loadPreparedModelCatalogSnapshot, + loadResolvedPublishedModelCatalogOwner, +} from "../../agents/prepared-model-catalog.js"; export type { ResolvedPublishedModelCatalogOwner } from "../../agents/prepared-model-catalog.types.js"; export { getModelRefStatus, diff --git a/src/cron/isolated-agent/run-prepare.ts b/src/cron/isolated-agent/run-prepare.ts index fa0717375387..191d67b73871 100644 --- a/src/cron/isolated-agent/run-prepare.ts +++ b/src/cron/isolated-agent/run-prepare.ts @@ -2,9 +2,7 @@ import { isDeepStrictEqual } from "node:util"; import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; import { findModelInCatalog } from "../../agents/model-catalog-lookup.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { SessionEntry } from "../../config/sessions.js"; import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js"; @@ -28,7 +26,11 @@ import { createCronRunDiagnosticsFromError } from "../run-diagnostics.js"; import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import { isDetachedCronSessionTarget } from "../session-target.js"; import type { CronJob, CronRunDiagnostics } from "../types.js"; -import { resolveCronModelSelection, resolveCronModelSelectionOwner } from "./model-selection.js"; +import { + resolveCronModelSelection, + resolveCronModelSelectionOwner, + resolveCronThinkingSelection, +} from "./model-selection.js"; import { buildCronAgentDefaultsConfig, resolveCronActiveRuntimeConfig } from "./run-config.js"; import { buildCurrentConversationContextBlock } from "./run-current-context.js"; import { @@ -72,7 +74,6 @@ import { logWarn, mapHookExternalContentSource, normalizeAgentId, - normalizeThinkLevel, resolveAgentConfig, resolveAgentDir, resolveAgentTimeoutMs, @@ -120,8 +121,7 @@ export type PreparedCronRunContext = { useSubagentFallbacks: boolean; inheritDefaultFallbacksForAgentStringModel: boolean; modelFallbacksOverride?: string[]; - thinkLevel: ThinkLevel | undefined; - thinkingCatalog: ModelCatalogEntry[]; + thinkingSelection: Awaited>; timeoutMs: number; preflightDiagnostics?: CronRunDiagnostics; /** @@ -144,7 +144,6 @@ export async function prepareCronRunContext(params: { }): Promise { const { input } = params; const requestedRuntimeCfg = resolveCronActiveRuntimeConfig(input.cfg); - const requestedDefaultAgentId = resolveDefaultAgentId(requestedRuntimeCfg); const requestedAgentId = typeof input.agentId === "string" && input.agentId.trim() ? input.agentId @@ -152,7 +151,7 @@ export async function prepareCronRunContext(params: { ? input.job.agentId : undefined; const normalizedRequested = requestedAgentId ? normalizeAgentId(requestedAgentId) : undefined; - const initialAgentId = normalizedRequested ?? requestedDefaultAgentId; + const initialAgentId = normalizedRequested ?? resolveDefaultAgentId(requestedRuntimeCfg); const initialAgentDir = resolveAgentDir(requestedRuntimeCfg, initialAgentId); const initialWorkspaceDir = resolveAgentWorkspaceDir(requestedRuntimeCfg, initialAgentId); const modelOwner = await resolveCronModelSelectionOwner({ @@ -253,8 +252,7 @@ export async function prepareCronRunContext(params: { ? `${agentSessionKey}:run:${runSessionId}` : agentSessionKey; const initialSessionEntry = cronSession.initialSessionEntry; - // Admission must precede async model preparation so concurrent maintenance - // preserves this exact session generation instead of deleting it before claim. + // Claim before async model prep so maintenance cannot delete this session generation. const sessionWorkAdmission = await beginSessionWorkAdmission({ scope: cronSession.storePath, identities: [ @@ -317,8 +315,7 @@ export async function prepareCronRunContext(params: { }); return; } - // Guarded replace: the updater sees the freshest persisted row (or - // undefined pre-creation) so cron lifecycle claims reject stale owners. + // Guarded replace reads the freshest row so lifecycle claims reject stale owners. await patchSessionEntry( { storePath, sessionKey, agentId }, (_entry, context) => update(context.existingEntry), @@ -369,7 +366,6 @@ export async function prepareCronRunContext(params: { }; } const cfgWithAgentDefaults = resolvedModelSelection.cfgWithAgentDefaults; - const thinkingCatalog = modelOwner.modelCatalog.entries; const ownerAgentConfig = resolveAgentConfig(modelOwner.config, modelOwner.agentId); const matchesDefaultFallbackAgentStringModel = typeof ownerAgentConfig?.model === "string" && @@ -439,8 +435,7 @@ export async function prepareCronRunContext(params: { .slice(selectedPreflightCandidateIndex + 1) .map((candidate) => `${candidate.provider}/${candidate.model}`) : undefined; - // When preflight skips the first local candidate, trim the fallback chain so - // execution starts at the reachable provider and only falls forward from it. + // When preflight skips the first local candidate, start at the reachable provider. if (selectedPreflightCandidate && modelFallbacksOverride) { if (firstUnavailablePreflight?.status === "unavailable") { logWarn( @@ -450,15 +445,15 @@ export async function prepareCronRunContext(params: { provider = selectedPreflightCandidate.provider; model = selectedPreflightCandidate.model; } - - const hooksGmailThinking = isGmailHook - ? normalizeThinkLevel(runtimeCfg.hooks?.gmail?.thinking) - : undefined; - const jobThink = normalizeThinkLevel( - (input.job.payload.kind === "agentTurn" ? input.job.payload.thinking : undefined) ?? - undefined, - ); - const sessionThink = normalizeThinkLevel(cronSession.sessionEntry.thinkingLevel); + const thinkingSelection = await resolveCronThinkingSelection({ + cfg: cfgWithAgentDefaults, + owner: modelOwner, + provider, + model, + jobThinking: input.job.payload.kind === "agentTurn" ? input.job.payload.thinking : undefined, + hookThinking: isGmailHook ? runtimeCfg.hooks?.gmail?.thinking : undefined, + sessionThinking: cronSession.sessionEntry.thinkingLevel, + }); const effectiveAgentRuntime = resolveEffectiveAgentRuntime({ cfg: cfgWithAgentDefaults, provider, @@ -467,14 +462,13 @@ export async function prepareCronRunContext(params: { sessionKey: agentSessionKey, sessionEntry: cronSession.sessionEntry, }); - let requestedThinkLevel: ThinkLevel | undefined = - jobThink ?? hooksGmailThinking ?? sessionThink; + let requestedThinkLevel = thinkingSelection.requestedThinkLevel; if (!requestedThinkLevel) { requestedThinkLevel = resolveThinkingDefault({ cfg: cfgWithAgentDefaults, provider, model, - catalog: thinkingCatalog, + catalog: thinkingSelection.catalog, agentRuntime: effectiveAgentRuntime, }); } @@ -483,7 +477,7 @@ export async function prepareCronRunContext(params: { provider, model, level: requestedThinkLevel, - catalog: thinkingCatalog, + catalog: thinkingSelection.catalog, agentRuntime: effectiveAgentRuntime, }) ) { @@ -491,7 +485,7 @@ export async function prepareCronRunContext(params: { provider, model, level: requestedThinkLevel, - catalog: thinkingCatalog, + catalog: thinkingSelection.catalog, agentRuntime: effectiveAgentRuntime, }); if (fallbackThinkLevel !== requestedThinkLevel) { @@ -517,7 +511,7 @@ export async function prepareCronRunContext(params: { const agentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null; const configuredProvider = cfgWithAgentDefaults.models?.providers?.[provider]; const modelApi = - findModelInCatalog(thinkingCatalog, provider, model)?.api ?? + findModelInCatalog(thinkingSelection.catalog, provider, model)?.api ?? configuredProvider?.models?.find((candidate) => candidate.id === model)?.api ?? configuredProvider?.api; const preflightDiagnostics = await createCronToolsAllowPreflightDiagnostics({ @@ -726,8 +720,7 @@ export async function prepareCronRunContext(params: { useSubagentFallbacks, inheritDefaultFallbacksForAgentStringModel, modelFallbacksOverride, - thinkLevel: requestedThinkLevel, - thinkingCatalog, + thinkingSelection, timeoutMs, preflightDiagnostics, runTimeoutOverrideMs, diff --git a/src/cron/isolated-agent/run.cron-runtime-model-thinking.test.ts b/src/cron/isolated-agent/run.cron-runtime-model-thinking.test.ts new file mode 100644 index 000000000000..dc0bc9f82edd --- /dev/null +++ b/src/cron/isolated-agent/run.cron-runtime-model-thinking.test.ts @@ -0,0 +1,360 @@ +// Cron runtime model thinking tests cover live metadata hydration for payload overrides. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; +import { + clearFastTestEnv, + isThinkingLevelSupportedMock, + loadModelCatalogMock, + loadRunCronIsolatedAgentTurn, + makeCronSession, + makeCronSessionEntry, + resolveAgentConfigMock, + resolveAllowedModelRefMock, + resolveConfiguredModelRefMock, + resolveCronSessionMock, + resolveThinkingDefaultMock, + resolveSupportedThinkingLevelMock, + resetRunCronIsolatedAgentTurnHarness, + restoreFastTestEnv, + runEmbeddedAgentMock, + runWithModelFallbackMock, +} from "./run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + +const noHydrationDefaults: Array<{ name: string; defaults: AgentDefaultsConfig }> = [ + { + name: "agent default", + defaults: { + thinkingDefault: "off", + models: { "ollama/*": {} }, + }, + }, + { + name: "model default", + defaults: { + models: { + "ollama/minimax-m3:cloud": { + params: { thinking: "off" }, + }, + }, + }, + }, +]; + +function firstMockArg(mock: { mock: { calls: unknown[][] } }): Record { + const value = mock.mock.calls[0]?.[0]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected a non-array record"); + } + return value as Record; +} + +describe("runCronIsolatedAgentTurn runtime model thinking", () => { + let previousFastTestEnv: string | undefined; + + beforeEach(() => { + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + resolveConfiguredModelRefMock.mockReturnValue({ + provider: "anthropic", + model: "claude-opus-4-6", + }); + resolveAgentConfigMock.mockReturnValue(undefined); + resolveCronSessionMock.mockReturnValue( + makeCronSession({ + sessionEntry: makeCronSessionEntry({ + model: undefined, + modelProvider: undefined, + }), + isNewSession: true, + }), + ); + }); + + afterEach(() => { + restoreFastTestEnv(previousFastTestEnv); + }); + + it("hydrates live catalog metadata for a runtime-only cron model override", async () => { + resolveAllowedModelRefMock.mockReturnValue({ + ref: { provider: "ollama", model: "minimax-m3:cloud" }, + }); + loadModelCatalogMock.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + provider: "OLLAMA", + id: "minimax-m3:cloud", + name: "minimax-m3:cloud", + reasoning: true, + }, + ]); + isThinkingLevelSupportedMock.mockImplementation( + ({ catalog, level }: { catalog?: Array<{ reasoning?: boolean }>; level?: string }) => + level === "off" || catalog?.some((entry) => entry.reasoning === true) === true, + ); + resolveSupportedThinkingLevelMock.mockReturnValue("off"); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({ + result: await run(provider, model), + provider, + model, + attempts: [], + })); + + await runCronIsolatedAgentTurn({ + cfg: { + agents: { + defaults: { + models: { + "ollama/*": {}, + }, + }, + }, + }, + deps: {} as never, + job: { + id: "runtime-thinking-job", + name: "Runtime Thinking Test", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "summarize", + model: "ollama/minimax-m3:cloud", + thinking: "medium", + }, + } as never, + message: "summarize", + sessionKey: "cron:runtime-thinking", + }); + + expect(loadModelCatalogMock).toHaveBeenCalledTimes(2); + const embeddedCall = firstMockArg(runEmbeddedAgentMock); + expect(embeddedCall.provider).toBe("ollama"); + expect(embeddedCall.model).toBe("minimax-m3:cloud"); + expect(embeddedCall.thinkLevel).toBe("medium"); + const thinkingCall = firstMockArg(isThinkingLevelSupportedMock); + expect(thinkingCall.catalog).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + reasoning: true, + }), + ]); + }); + + it("does not hydrate live catalog metadata when thinking is explicitly off", async () => { + resolveAllowedModelRefMock.mockReturnValue({ + ref: { provider: "ollama", model: "minimax-m3:cloud" }, + }); + loadModelCatalogMock.mockResolvedValue([]); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({ + result: await run(provider, model), + provider, + model, + attempts: [], + })); + + await runCronIsolatedAgentTurn({ + cfg: { + agents: { + defaults: { + models: { + "ollama/*": {}, + }, + }, + }, + }, + deps: {} as never, + job: { + id: "runtime-thinking-off-job", + name: "Runtime Thinking Off Test", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "summarize", + model: "ollama/minimax-m3:cloud", + thinking: "off", + }, + } as never, + message: "summarize", + sessionKey: "cron:runtime-thinking-off", + }); + + expect(loadModelCatalogMock).toHaveBeenCalledTimes(1); + const embeddedCall = firstMockArg(runEmbeddedAgentMock); + expect(embeddedCall.provider).toBe("ollama"); + expect(embeddedCall.model).toBe("minimax-m3:cloud"); + expect(embeddedCall.thinkLevel).toBe("off"); + }); + + it.each(noHydrationDefaults)( + "does not hydrate live catalog metadata when the $name is off", + async ({ defaults }) => { + resolveAllowedModelRefMock.mockReturnValue({ + ref: { provider: "ollama", model: "minimax-m3:cloud" }, + }); + loadModelCatalogMock.mockResolvedValue([]); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({ + result: await run(provider, model), + provider, + model, + attempts: [], + })); + + await runCronIsolatedAgentTurn({ + cfg: { + agents: { + defaults, + }, + }, + deps: {} as never, + job: { + id: "configured-thinking-off-job", + name: "Configured Thinking Off Test", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "summarize", + model: "ollama/minimax-m3:cloud", + }, + } as never, + message: "summarize", + sessionKey: "cron:configured-thinking-off", + }); + + expect(loadModelCatalogMock).toHaveBeenCalledTimes(1); + expect(resolveThinkingDefaultMock).not.toHaveBeenCalled(); + const embeddedCall = firstMockArg(runEmbeddedAgentMock); + expect(embeddedCall.provider).toBe("ollama"); + expect(embeddedCall.model).toBe("minimax-m3:cloud"); + expect(embeddedCall.thinkLevel).toBe("off"); + }, + ); + + it("recomputes configured thinking defaults for each fallback candidate", async () => { + resolveAllowedModelRefMock.mockImplementation(({ raw }: { raw: string }) => { + const [provider, model] = raw.split("/"); + return { ref: { provider, model } }; + }); + loadModelCatalogMock.mockResolvedValue([ + { provider: "openai", id: "gpt-5.6-sol", reasoning: true }, + ]); + resolveThinkingDefaultMock.mockReturnValue("medium"); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + const result = await run("ollama", "minimax-m3:cloud"); + return { + result, + provider: "ollama", + model: "minimax-m3:cloud", + attempts: [], + }; + }); + + await runCronIsolatedAgentTurn({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": {}, + "ollama/minimax-m3:cloud": { + params: { thinking: "off" }, + }, + }, + }, + }, + }, + deps: {} as never, + job: { + id: "fallback-thinking-default-job", + name: "Fallback Thinking Default Test", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "summarize", + model: "openai/gpt-5.6-sol", + }, + } as never, + message: "summarize", + sessionKey: "cron:fallback-thinking-default", + }); + + expect(runEmbeddedAgentMock.mock.calls.map((call) => call[0].thinkLevel)).toEqual([ + "medium", + "off", + ]); + expect(loadModelCatalogMock).toHaveBeenCalledTimes(1); + }); + + it("hydrates runtime metadata for a reasoning-capable fallback candidate", async () => { + resolveAllowedModelRefMock.mockImplementation(({ raw }: { raw: string }) => { + const [provider, model] = raw.split("/"); + return { ref: { provider, model } }; + }); + loadModelCatalogMock + .mockResolvedValueOnce([{ provider: "openai", id: "gpt-5.6-sol", reasoning: true }]) + .mockResolvedValueOnce([ + { provider: "openai", id: "gpt-5.6-sol", reasoning: true }, + { provider: "OLLAMA", id: "minimax-m3:cloud", reasoning: true }, + ]); + resolveThinkingDefaultMock.mockImplementation( + ({ + catalog, + model, + }: { + catalog?: Array<{ id?: string; reasoning?: boolean }>; + model?: string; + }) => + model === "minimax-m3:cloud" && + catalog?.some((entry) => entry.id === "minimax-m3:cloud" && entry.reasoning === true) + ? "medium" + : "off", + ); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + const result = await run("ollama", "minimax-m3:cloud"); + return { + result, + provider: "ollama", + model: "minimax-m3:cloud", + attempts: [], + }; + }); + + await runCronIsolatedAgentTurn({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": {}, + "ollama/*": {}, + }, + }, + }, + }, + deps: {} as never, + job: { + id: "fallback-runtime-thinking-job", + name: "Fallback Runtime Thinking Test", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: "summarize", + model: "openai/gpt-5.6-sol", + }, + } as never, + message: "summarize", + sessionKey: "cron:fallback-runtime-thinking", + }); + + expect(loadModelCatalogMock).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgentMock.mock.calls.map((call) => call[0].thinkLevel)).toEqual([ + "off", + "medium", + ]); + }); +}); 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 7856f12e5991..aadb2e3872a8 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -366,7 +366,8 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { runSessionKey: "cron:message-tool-policy:run:test-session-id", workspaceDir: "/tmp/workspace", agentVerboseDefault: undefined, - thinkLevel: undefined, + immutableThinkLevel: undefined, + loadThinkingCatalog: async () => [], timeoutMs: 60_000, suppressExecNotifyOnExit: true, resolvedDeliveryOk: true, diff --git a/src/cron/isolated-agent/run.runtime.ts b/src/cron/isolated-agent/run.runtime.ts index 9aa389f8f815..dfd7d60abdd9 100644 --- a/src/cron/isolated-agent/run.runtime.ts +++ b/src/cron/isolated-agent/run.runtime.ts @@ -16,7 +16,6 @@ export { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js export { ensureAgentWorkspace } from "../../agents/workspace.js"; export { isThinkingLevelSupported, - normalizeThinkLevel, resolveSupportedThinkingLevel, } from "../../auto-reply/thinking.js"; export { setSessionRuntimeModel } from "../../config/sessions/types.js"; diff --git a/src/cron/isolated-agent/run.source-delivery-guard.test.ts b/src/cron/isolated-agent/run.source-delivery-guard.test.ts index ff4aff574d79..79fbe062b336 100644 --- a/src/cron/isolated-agent/run.source-delivery-guard.test.ts +++ b/src/cron/isolated-agent/run.source-delivery-guard.test.ts @@ -346,7 +346,7 @@ describe("executeCronRun sourceDelivery mapping", () => { }, liveSelection: { provider: "openai", model: "gpt-5.6-luna" }, cronSession, - thinkLevel: "ultra", + immutableThinkLevel: "ultra", }); await executor.runPrompt("run an Ultra task"); @@ -395,7 +395,8 @@ function makeExecuteCronRunParams(overrides: Record = {}) { persistSessionEntry: vi.fn().mockResolvedValue(undefined), abortReason: () => "aborted", isAborted: () => false, - thinkLevel: undefined, + immutableThinkLevel: undefined, + loadThinkingCatalog: async () => [], timeoutMs: 60_000, suppressExecNotifyOnExit: true, resolvedDelivery, diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index f09521f0fede..7fbfb0fa0392 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -233,6 +233,10 @@ vi.mock("../../skills/runtime/cron-snapshot.runtime.js", () => ({ vi.mock("./run-model-selection.runtime.js", () => ({ DEFAULT_MODEL: "gpt-5.4", DEFAULT_PROVIDER: "openai", + loadPreparedModelCatalogSnapshot: async (params: unknown) => ({ + entries: await loadModelCatalogMock(params), + routeVariants: [], + }), loadResolvedPublishedModelCatalogOwner: loadModelCatalogOwnerMock, publishedModelCatalogOwnerMatchesAgent: (owner: { agentId: string }, agentId: string) => owner.agentId === agentId.trim().toLowerCase(), diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ee83f82db14c..60175b910b86 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -242,8 +242,9 @@ export async function runCronIsolatedAgentTurn(params: { onLaneWait: params.onLaneWait, abortReason, isAborted, - thinkLevel: prepared.context.thinkLevel, - thinkingCatalog: prepared.context.thinkingCatalog, + immutableThinkLevel: prepared.context.thinkingSelection.immutableThinkLevel, + thinkingCatalog: prepared.context.thinkingSelection.catalog, + loadThinkingCatalog: prepared.context.thinkingSelection.loadThinkingCatalog, timeoutMs: prepared.context.timeoutMs, runTimeoutOverrideMs: prepared.context.runTimeoutOverrideMs, suppressExecNotifyOnExit: prepared.context.suppressExecNotifyOnExit, From a28219bcee4a19a22dfb619062eb8491d4c9ece3 Mon Sep 17 00:00:00 2001 From: wahaha1223 <0668001153@xydigit.com> Date: Fri, 31 Jul 2026 17:57:01 +0800 Subject: [PATCH 057/239] fix(ollama): stop oversized stream records from growing memory (#107473) * fix(ollama): bound pending NDJSON stream records * chore(changelog): remove release-owned entry --------- Co-authored-by: wangmiao0668000666 Co-authored-by: Vincent Koc --- extensions/ollama/src/setup-pull.test.ts | 37 ++++++++++++++++++++ extensions/ollama/src/setup-pull.ts | 36 +++++++++++-------- extensions/ollama/src/stream-ndjson-cap.ts | 22 ++++++++++++ extensions/ollama/src/stream-runtime.test.ts | 34 ++++++++++++++++++ extensions/ollama/src/stream.ts | 5 +-- 5 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 extensions/ollama/src/stream-ndjson-cap.ts diff --git a/extensions/ollama/src/setup-pull.test.ts b/extensions/ollama/src/setup-pull.test.ts index a590b6b15e4b..62547b12492b 100644 --- a/extensions/ollama/src/setup-pull.test.ts +++ b/extensions/ollama/src/setup-pull.test.ts @@ -41,4 +41,41 @@ describe("Ollama onboarding model pulls", () => { ); expect(release).toHaveBeenCalledOnce(); }); + + it("cancels an oversized unterminated pull record", async () => { + const chunk = new Uint8Array(1024 * 1024).fill(0x20); + let readCount = 0; + let canceled = false; + const body = new ReadableStream({ + pull(controller) { + readCount += 1; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(body, { status: 200 }), + release, + }); + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { + progress: vi.fn(() => progress), + } as unknown as WizardPrompter; + + await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe( + false, + ); + + expect(readCount).toBeGreaterThan(16); + expect(readCount).toBeLessThan(32); + expect(canceled).toBe(true); + expect(body.locked).toBe(false); + expect(progress.stop).toHaveBeenCalledWith( + "Failed to download gemma4:e2b: Ollama NDJSON record exceeds 16777216 bytes", + ); + expect(release).toHaveBeenCalledOnce(); + }); }); diff --git a/extensions/ollama/src/setup-pull.ts b/extensions/ollama/src/setup-pull.ts index 37c0c335ff38..81e8389b3768 100644 --- a/extensions/ollama/src/setup-pull.ts +++ b/extensions/ollama/src/setup-pull.ts @@ -4,6 +4,7 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { buildOllamaBaseUrlSsrFPolicy, resolveOllamaApiBase } from "./provider-models.js"; import { normalizeOllamaModelName } from "./setup-model-selection.js"; +import { checkNdjsonRecordCap } from "./stream-ndjson-cap.js"; const OLLAMA_PULL_RESPONSE_TIMEOUT_MS = 30_000; const OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS = 300_000; @@ -88,6 +89,7 @@ async function pullOllamaModelCore(params: { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let pendingRecordBytes = 0; const layers = new Map(); const parseLine = (line: string): OllamaPullResult => { @@ -125,22 +127,28 @@ async function pullOllamaModelCore(params: { return { ok: true }; }; - for (;;) { - const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader); - if (done) { - return parseLine(buffer); - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const parsed = parseLine(line); - if (!parsed.ok) { - // Ollama can report an error before closing the stream; discard the unread tail. - await reader.cancel().catch(() => undefined); - return parsed; + try { + for (;;) { + const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader); + if (done) { + return parseLine(buffer); + } + pendingRecordBytes = checkNdjsonRecordCap(value, pendingRecordBytes); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const parsed = parseLine(line); + if (!parsed.ok) { + return parsed; + } } } + } finally { + // Overflow and parsed-error returns can leave unread response bytes. + // Cancel before unlocking so setup never abandons a live pull body. + await reader.cancel().catch(() => undefined); + reader.releaseLock(); } } finally { await release(); diff --git a/extensions/ollama/src/stream-ndjson-cap.ts b/extensions/ollama/src/stream-ndjson-cap.ts new file mode 100644 index 000000000000..0f47681d3d9b --- /dev/null +++ b/extensions/ollama/src/stream-ndjson-cap.ts @@ -0,0 +1,22 @@ +// Ollama's Go client caps one streamed line at 8 MB. Keep more than a 2x +// compatibility margin while preventing newline-free peers from growing memory. +const OLLAMA_NDJSON_RECORD_MAX_BYTES = 16 * 1024 * 1024; + +export function checkNdjsonRecordCap(value: Uint8Array, pendingRecordBytes: number): number { + let offset = 0; + let pending = pendingRecordBytes; + while (offset < value.byteLength) { + const newlineIndex = value.indexOf(0x0a, offset); + const segmentEnd = newlineIndex === -1 ? value.byteLength : newlineIndex; + pending += segmentEnd - offset; + if (pending > OLLAMA_NDJSON_RECORD_MAX_BYTES) { + throw new Error(`Ollama NDJSON record exceeds ${OLLAMA_NDJSON_RECORD_MAX_BYTES} bytes`); + } + if (newlineIndex === -1) { + break; + } + pending = 0; + offset = newlineIndex + 1; + } + return pending; +} diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index c33b4ad491b3..4308425e4d74 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1300,6 +1300,40 @@ async function expectNoParsedChunks(reader: ReadableStreamDefaultReader { + it("cancels an oversized unterminated record", async () => { + const oversizedRecord = new Uint8Array(16 * 1024 * 1024 + 1).fill(0x20); + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(oversizedRecord); + }, + cancel() { + canceled = true; + }, + }); + const reader = stream.getReader(); + + await expect(expectNoParsedChunks(reader)).rejects.toThrow( + "Ollama NDJSON record exceeds 16777216 bytes", + ); + expect(canceled).toBe(true); + expect(stream.locked).toBe(false); + }); + + it("resets the record limit after each newline", async () => { + const legalRecord = new Uint8Array(9 * 1024 * 1024 + 1).fill(0x20); + legalRecord[legalRecord.length - 1] = 0x0a; + const reader = new ReadableStream({ + start(controller) { + controller.enqueue(legalRecord); + controller.enqueue(legalRecord); + controller.close(); + }, + }).getReader(); + + await expectNoParsedChunks(reader); + }); + it("does not log a dangling surrogate for a malformed complete line", async () => { const prefix = "x".repeat(119); const reader = mockNdjsonReader([`${prefix}😀tail`]); diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 2cf85b5815d8..c885b6a33b1f 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -46,7 +46,7 @@ import { createOllamaVisibleContentSanitizer, sanitizeOllamaFinalVisibleContent, } from "./sanitizers/visible-content.js"; - +import { checkNdjsonRecordCap } from "./stream-ndjson-cap.js"; const log = createSubsystemLogger("ollama-stream"); export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; @@ -1090,13 +1090,14 @@ export async function* parseNdjsonStream( ): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; - + let pendingRecordBytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } + pendingRecordBytes = checkNdjsonRecordCap(value, pendingRecordBytes); buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; From 3d0f02c223e899540e50616a030da6491bd1c444 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 02:57:31 -0700 Subject: [PATCH 058/239] fix(ollama): honor stop sequences and async payload hooks (#116737) Co-authored-by: Peter Steinberger --- extensions/ollama/src/stream-runtime.test.ts | 116 +++++++++++++++++-- extensions/ollama/src/stream.ts | 8 +- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 4308425e4d74..01926cd5444c 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1629,15 +1629,7 @@ async function createOllamaTestStream(params: { defaultHeaders?: Record; model?: Record; context?: Record; - options?: { - apiKey?: string; - maxTokens?: number; - temperature?: number; - signal?: AbortSignal; - timeoutMs?: number; - headers?: Record; - responseFormat?: Record; - }; + options?: Parameters>[2]; }) { const streamFn = createOllamaStreamFn(params.baseUrl, params.defaultHeaders); return streamFn( @@ -2388,6 +2380,112 @@ describe("createOllamaStreamFn", () => { ); }); + it.each([ + { + name: "forwards request stop sequences as native Ollama options", + model: {}, + stop: ["END", "DONE"], + expectedStop: ["END", "DONE"], + }, + { + name: "lets request stop sequences override configured model stop sequences", + model: { params: { stop: ["MODEL"] } }, + stop: ["REQUEST"], + expectedStop: ["REQUEST"], + }, + { + name: "keeps configured model stop sequences when request stops are empty", + model: { params: { stop: ["MODEL"] } }, + stop: [], + expectedStop: ["MODEL"], + }, + ])("$name", async ({ model, stop, expectedStop }) => { + await expectSuccessfulOllamaRequest( + { baseUrl: "http://ollama-host:11434", model, options: { stop } }, + ({ body }) => + expect(requireRecord(body.options, "Ollama request options").stop).toEqual(expectedStop), + ); + }); + + it("awaits asynchronous payload mutations before dispatching native Ollama requests", async () => { + await withSuccessfulOllamaFetch(async (fetchMock) => { + let releasePayload: (() => void) | undefined; + const payloadGate = new Promise((resolve) => { + releasePayload = resolve; + }); + const onPayload = vi.fn(async (payload: unknown) => { + await payloadGate; + requireRecord(payload, "Ollama request payload").model = "patched-model"; + }); + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { onPayload }, + }); + + await vi.waitFor(() => expect(onPayload).toHaveBeenCalledTimes(1)); + expect(fetchMock).not.toHaveBeenCalled(); + expectDefined(releasePayload, "pending Ollama payload hook")(); + const events = await collectStreamEvents(stream); + + expect(events.at(-1)?.type).toBe("done"); + expect(getGuardedFetchJsonBody(fetchMock).model).toBe("patched-model"); + }); + }); + + it("dispatches asynchronous payload replacements for native Ollama requests", async () => { + await expectSuccessfulOllamaRequest( + { + baseUrl: "http://ollama-host:11434", + options: { + onPayload: async (payload) => { + await Promise.resolve(); + const current = requireRecord(payload, "Ollama request payload"); + return { + ...current, + model: "replacement-model", + options: { + ...requireRecord(current.options, "Ollama request options"), + stop: ["REPLACEMENT"], + }, + }; + }, + }, + }, + ({ body }) => { + expect(body.model).toBe("replacement-model"); + expect(requireRecord(body.options, "Ollama request options").stop).toEqual(["REPLACEMENT"]); + }, + ); + }); + + it("surfaces asynchronous payload hook rejection without dispatching a request", async () => { + await withSuccessfulOllamaFetch(async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { + onPayload: async () => { + await Promise.resolve(); + throw new Error("payload admission rejected"); + }, + }, + }); + + const events = await collectStreamEvents(stream); + + expect(events).toMatchObject([ + { + type: "error", + reason: "error", + error: { + stopReason: "error", + errorMessage: "payload admission rejected", + }, + }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + it("maps responseFormat JSON Schema to native Ollama format", async () => { const schema = { type: "object", diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index c885b6a33b1f..01ef98b8f216 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1186,6 +1186,9 @@ function createRawOllamaStreamFn( if (typeof options?.maxTokens === "number") { ollamaOptions.num_predict = options.maxTokens; } + if (options?.stop && options.stop.length > 0) { + ollamaOptions.stop = options.stop; + } normalizeOllamaGreedySamplingOptions(ollamaOptions); // Structured-output grammars constrain the same token stream as tool @@ -1211,7 +1214,8 @@ function createRawOllamaStreamFn( options: ollamaOptions, requestParams, }); - options?.onPayload?.(body, model); + const replacement = await options?.onPayload?.(body, model); + const requestBody = replacement === undefined ? body : replacement; const headers: Record = { "Content-Type": "application/json", ...defaultHeaders, @@ -1229,7 +1233,7 @@ function createRawOllamaStreamFn( init: { method: "POST", headers, - body: JSON.stringify(body), + body: JSON.stringify(requestBody), }, policy: ssrfPolicy, ...(options?.signal ? { signal: options.signal } : {}), From 86b1e26993227c786a7631cbad1798e48d4aee1a Mon Sep 17 00:00:00 2001 From: Leon-SK668 <0668001470@xydigit.com> Date: Fri, 31 Jul 2026 17:57:47 +0800 Subject: [PATCH 059/239] fix(ollama): resolve web search secret refs (#104829) * fix(ollama): resolve web search secret refs * fix(ollama): preserve blocked search secret refs * fix(ollama): share web search credential policy * fix(ollama): resolve web search secret refs via shared resolver Route configured models.providers.ollama.apiKey resolution through the existing shared resolveWebSearchProviderCredential helper so env-backed SecretRefs resolve for web search, and resolve the ambient OLLAMA_API_KEY independently of the configured selected-host key so mixed setups still reach the Ollama Cloud fallback after both selected-host attempts fail (regression fixed at web-search-provider.ts:195). Drop the two optional plugin-SDK resolver hooks (provider normalization and unavailable-configured-ref callback) added earlier: the fix does not depend on them, so the shared resolver and its generated plugin-SDK baseline stay identical to main and no new plugin-SDK contract surface is introduced. Ollama applies its own non-secret-marker filter locally on the resolver output instead. Add a mixed-credential regression test (configured host key plus a distinct ambient OLLAMA_API_KEY reaching the cloud fallback) and drop the tests for the removed fail-closed-throw behavior. * fix(ollama): fail closed on unavailable web search refs * fix(web-search): resolve env shorthand secret refs * docs(changelog): note Ollama web-search SecretRef fix * chore(changelog): remove release-owned entry --------- Co-authored-by: Vincent Koc --- .../ollama/src/web-search-provider.test.ts | 154 +++++++++++++++++- extensions/ollama/src/web-search-provider.ts | 45 ++++- .../web-search-provider-credentials.test.ts | 33 ++++ .../tools/web-search-provider-credentials.ts | 12 +- 4 files changed, 230 insertions(+), 14 deletions(-) diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index e54ce77b4027..8f717566b2c0 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -1,5 +1,7 @@ // Ollama tests cover web search provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js"; @@ -15,7 +17,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ type OllamaProviderConfigOverride = Partial<{ api: "ollama"; - apiKey: string; + apiKey: SecretInput; baseUrl: string; baseURL: string; models: NonNullable< @@ -181,6 +183,16 @@ function expectSingleSearchResultUrl(results: unknown, url: string) { expect((result as { url?: unknown }).url).toBe(url); } +async function expectConfiguredRefFailure(input: SecretInput, message: string) { + await expect( + runOllamaWebSearch({ + config: createOllamaConfig({ apiKey: input }), + query: "openclaw", + }), + ).rejects.toThrow(message); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); +} + describe("ollama web search provider", () => { beforeEach(() => { fetchWithSsrFGuardMock.mockReset(); @@ -408,6 +420,146 @@ describe("ollama web search provider", () => { } }); + it.each([ + { + source: "env", + provider: "default", + id: "OLLAMA_WEB_SEARCH_REF", + }, + "$OLLAMA_WEB_SEARCH_REF", + "${OLLAMA_WEB_SEARCH_REF}", + ])("resolves provider apiKey env SecretRef %# for web search requests", async (apiKey) => { + const refEnvVar = "OLLAMA_WEB_SEARCH_REF"; + const resolvedKey = "resolved-ref-value"; + await withEnvAsync({ [refEnvVar]: resolvedKey }, async () => { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + results: [{ title: "Cloud", url: "https://example.com", content: "result" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }); + + const result = await runOllamaWebSearch({ + config: createOllamaConfig({ + baseUrl: "https://ollama.com", + apiKey, + }), + query: "openclaw", + }); + + expect(result.count).toBe(1); + expectOllamaWebSearchRequest(fetchCall(), { + url: "https://ollama.com/api/web_search", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${resolvedKey}`, + }, + policy: { + allowPrivateNetwork: true, + hostnameAllowlist: ["ollama.com"], + }, + }); + }); + }); + + it("keeps the ambient cloud fallback when a configured selected-host key is also set", async () => { + // Regression guard (mixed credentials): a configured selected-host key must not suppress the + // separate ambient OLLAMA_API_KEY used for the final Ollama Cloud attempt after the two + // selected-host attempts fail. + const ambientEnvVar = ["OLLAMA_API", "KEY"].join("_"); + await withEnvAsync({ [ambientEnvVar]: "ambient-cloud-key" }, async () => { + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response("not found", { status: 404 }), + release: vi.fn(async () => {}), + }) + .mockResolvedValueOnce({ + response: new Response("not found", { status: 404 }), + release: vi.fn(async () => {}), + }) + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + results: [{ title: "Cloud", url: "https://example.com", content: "result" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }); + + const result = await runOllamaWebSearch({ + config: createOllamaConfig({ apiKey: "configured-host-key" }), + query: "openclaw", + }); + + expect(result.count).toBe(1); + expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([ + "http://ollama.local:11434/api/experimental/web_search", + "http://ollama.local:11434/api/web_search", + "https://ollama.com/api/web_search", + ]); + // Selected-host attempts carry the configured key; the cloud fallback carries the ambient key. + expect(fetchRequest(0).init?.headers?.Authorization).toBe("Bearer configured-host-key"); + expect(fetchRequest(1).init?.headers?.Authorization).toBe("Bearer configured-host-key"); + expect(fetchRequest(2).init?.headers?.Authorization).toBe("Bearer ambient-cloud-key"); + }); + }); + + it("does not use ambient env fallback when a configured apiKey SecretRef is unavailable", async () => { + const refEnvVar = "OLLAMA_WEB_SEARCH_REF"; + const ambientEnvVar = ["OLLAMA_API", "KEY"].join("_"); + const ambientKey = ["ambient", "cloud", "value"].join("-"); + await withEnvAsync({ [refEnvVar]: undefined, [ambientEnvVar]: ambientKey }, async () => { + await expectConfiguredRefFailure( + { + source: "env", + provider: "default", + id: refEnvVar, + }, + "models.providers.ollama.apiKey env SecretRef OLLAMA_WEB_SEARCH_REF is not available", + ); + }); + }); + + it.each(["$OLLAMA_WEB_SEARCH_REF", "${OLLAMA_WEB_SEARCH_REF}"])( + "does not use ambient env fallback when configured apiKey SecretRef shorthand %s is unavailable", + async (apiKey) => { + const refEnvVar = "OLLAMA_WEB_SEARCH_REF"; + const ambientEnvVar = ["OLLAMA_API", "KEY"].join("_"); + const ambientKey = ["ambient", "cloud", "value"].join("-"); + await withEnvAsync({ [refEnvVar]: undefined, [ambientEnvVar]: ambientKey }, async () => { + await expectConfiguredRefFailure( + apiKey, + "models.providers.ollama.apiKey env SecretRef OLLAMA_WEB_SEARCH_REF is not available", + ); + }); + }, + ); + + it("does not use ambient env fallback for non-env apiKey SecretRefs", async () => { + const ambientEnvVar = ["OLLAMA_API", "KEY"].join("_"); + const ambientKey = ["ambient", "cloud", "value"].join("-"); + await withEnvAsync({ [ambientEnvVar]: ambientKey }, async () => { + await expectConfiguredRefFailure( + { + source: "file", + provider: "vault", + id: "/providers/ollama/web-search", + }, + "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search", + ); + }); + }); + it("surfaces Ollama signin guidance for 401 responses", async () => { fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response("", { status: 401 }), diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 85eacf70f7c1..69375c7544bf 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -14,10 +14,12 @@ import { resolveProviderWebSearchPluginConfig, resolveSearchCount, resolveSiteName, + resolveWebSearchProviderCredential, truncateText, wrapWebContent, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; +import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; @@ -80,16 +82,42 @@ function isOllamaCloudBaseUrl(baseUrl: string): boolean { } } -function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined { - const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey); - if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) { - return providerApiKey; - } - return undefined; +function normalizeOllamaWebSearchApiKey(value: unknown): string | undefined { + const apiKey = normalizeOptionalSecretInput(value); + return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined; } function resolveEnvOllamaWebSearchApiKey(): string | undefined { - return resolveEnvApiKey("ollama")?.apiKey; + return normalizeOllamaWebSearchApiKey(resolveEnvApiKey("ollama")?.apiKey); +} + +function createOllamaWebSearchCredentialError(ref: { source: string; id: string }): Error { + return new Error( + ref.source === "env" + ? `models.providers.ollama.apiKey env SecretRef ${ref.id} is not available for Ollama web search.` + : "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search. Use an env SecretRef for this path.", + ); +} + +// Delegate configured-key resolution (literal value or env-backed SecretRef) to the shared +// web-search resolver, then apply Ollama's marker filter so persisted non-secret placeholders +// (e.g. the OAuth/signin marker) fall through to the ambient OLLAMA_API_KEY instead of being sent. +function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined { + const credentialValue = config?.models?.providers?.ollama?.apiKey; + const credentialRef = coerceSecretRef(credentialValue); + const resolvedValue = normalizeOllamaWebSearchApiKey( + resolveWebSearchProviderCredential({ + credentialValue, + path: "models.providers.ollama.apiKey", + envVars: [], + }), + ); + // An explicit ref selects one credential. Do not reinterpret an unavailable ref as no config, + // which would permit an unrelated ambient key and potentially route the query to Ollama Cloud. + if (credentialRef && !resolvedValue) { + throw createOllamaWebSearchCredentialError(credentialRef); + } + return resolvedValue; } function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string { @@ -169,6 +197,9 @@ async function runOllamaWebSearch(params: { const baseUrl = resolveOllamaWebSearchBaseUrl(params.config); const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config); + // Resolve the ambient cloud key independently of the configured selected-host key so a mixed + // setup still reaches the Ollama Cloud fallback with OLLAMA_API_KEY after the selected-host + // attempts fail. Gating this on configuredApiKey would drop that final authenticated attempt. const envApiKey = resolveEnvOllamaWebSearchApiKey(); const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT); const startedAt = Date.now(); diff --git a/src/agents/tools/web-search-provider-credentials.test.ts b/src/agents/tools/web-search-provider-credentials.test.ts index e6e1f713df37..4655da04929c 100644 --- a/src/agents/tools/web-search-provider-credentials.test.ts +++ b/src/agents/tools/web-search-provider-credentials.test.ts @@ -33,6 +33,21 @@ describe("resolveWebSearchProviderCredential", () => { }); }); + it.each(["$TEST_WEB_SEARCH_REF_KEY", "${TEST_WEB_SEARCH_REF_KEY}"])( + "resolves configured env SecretRef shorthand %s", + (credentialValue) => { + withEnv({ TEST_WEB_SEARCH_REF_KEY: "ref-test-value" }, () => { + expect( + resolveWebSearchProviderCredential({ + credentialValue, + path: "tools.web.search.provider.apiKey", + envVars: ["TEST_WEB_SEARCH_KEY"], + }), + ).toBe("ref-test-value"); + }); + }, + ); + it("does not override missing env SecretRefs with ambient env fallback", () => { // An explicit SecretRef means "use this credential"; falling back to a // different env var can silently route requests through the wrong account. @@ -54,6 +69,24 @@ describe("resolveWebSearchProviderCredential", () => { ); }); + it.each(["$TEST_WEB_SEARCH_REF_KEY", "${TEST_WEB_SEARCH_REF_KEY}"])( + "does not override missing env SecretRef shorthand %s with ambient env fallback", + (credentialValue) => { + withEnv( + { TEST_WEB_SEARCH_REF_KEY: undefined, TEST_WEB_SEARCH_KEY: "ambient-test-value" }, + () => { + expect( + resolveWebSearchProviderCredential({ + credentialValue, + path: "tools.web.search.provider.apiKey", + envVars: ["TEST_WEB_SEARCH_KEY"], + }), + ).toBeUndefined(); + }, + ); + }, + ); + it("does not override non-env SecretRefs with ambient env fallback", () => { withEnv({ TEST_WEB_SEARCH_KEY: "ambient-test-value" }, () => { expect( diff --git a/src/agents/tools/web-search-provider-credentials.ts b/src/agents/tools/web-search-provider-credentials.ts index 2cd5f6eedc4e..7f9bd23123b7 100644 --- a/src/agents/tools/web-search-provider-credentials.ts +++ b/src/agents/tools/web-search-provider-credentials.ts @@ -16,12 +16,6 @@ export function resolveWebSearchProviderCredential(params: { path: string; envVars: string[]; }): string | undefined { - const fromConfigRaw = normalizeSecretInputString(params.credentialValue); - const fromConfig = normalizeSecretInput(fromConfigRaw); - if (fromConfig) { - return fromConfig; - } - const credentialRef = resolveSecretInputRef({ value: params.credentialValue }).ref; if (credentialRef) { if (credentialRef.source !== "env") { @@ -35,6 +29,12 @@ export function resolveWebSearchProviderCredential(params: { return undefined; } + const fromConfigRaw = normalizeSecretInputString(params.credentialValue); + const fromConfig = normalizeSecretInput(fromConfigRaw); + if (fromConfig) { + return fromConfig; + } + for (const envVar of params.envVars) { const fromEnv = normalizeSecretInput(process.env[envVar]); if (fromEnv) { From fe408e109f67d69a297151225c15a37e6e47d82d Mon Sep 17 00:00:00 2001 From: "openclaw-mantis[bot]" <281431406+openclaw-mantis[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:04:29 +0800 Subject: [PATCH 060/239] chore(i18n): refresh native locales (#116640) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/.i18n/native/ar.json | 18 +- apps/.i18n/native/de.json | 18 +- apps/.i18n/native/es.json | 18 +- apps/.i18n/native/fa.json | 18 +- apps/.i18n/native/fr.json | 18 +- apps/.i18n/native/hi.json | 18 +- apps/.i18n/native/id.json | 18 +- apps/.i18n/native/it.json | 18 +- apps/.i18n/native/ja-JP.json | 18 +- apps/.i18n/native/ko.json | 18 +- apps/.i18n/native/nl.json | 18 +- apps/.i18n/native/pl.json | 18 +- apps/.i18n/native/pt-BR.json | 18 +- apps/.i18n/native/ru.json | 18 +- apps/.i18n/native/sv.json | 18 +- apps/.i18n/native/th.json | 18 +- apps/.i18n/native/tr.json | 18 +- apps/.i18n/native/uk.json | 18 +- apps/.i18n/native/vi.json | 18 +- apps/.i18n/native/zh-CN.json | 18 +- apps/.i18n/native/zh-TW.json | 18 +- .../OpenClaw/Resources/Localizable.xcstrings | 816 +++++++++--------- 22 files changed, 597 insertions(+), 597 deletions(-) diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index d5fb2c3a7776..36d1a6125a88 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -21999,14 +21999,14 @@ "translated": "إعداد نموذج محلي" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "نزّل أو جهّز نموذجًا محليًا على هذا الـ Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "اتصل بخدمة نموذج محلي، أو جهّز نموذجًا على هذا الـ Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "إعداد / تنزيل النموذج" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "الاتصال / الإعداد" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "تسجيل الدخول" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "تم تنزيل النموذج وتجهيزه على هذا الـ Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "سيكتشف OpenClaw النموذج المُجهّز ويتحقق منه قبل استخدامه." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 930a3bbc1b6f..9d252e649eb3 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -21999,14 +21999,14 @@ "translated": "Lokales Modell einrichten" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Laden Sie ein lokales Modell auf diesem Gateway herunter oder bereiten Sie es vor." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Verbinden Sie einen lokalen Modelldienst oder bereiten Sie ein Modell auf diesem Gateway vor." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Modell einrichten / herunterladen" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Verbinden / Einrichten" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Anmelden" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Das Modell wurde auf diesem Gateway heruntergeladen und vorbereitet." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw erkennt und überprüft das vorbereitete Modell, bevor es verwendet wird." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 43fddc75c6a3..d5e9869fcce6 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -21999,14 +21999,14 @@ "translated": "Configurar un modelo local" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Descarga o prepara un modelo local en este Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Conecta un servicio de modelo local o prepara un modelo en este Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Configurar / Descargar modelo" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Conectar / Configurar" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Iniciar sesión" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "El modelo se ha descargado y preparado en este Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw detectará y verificará el modelo preparado antes de usarlo." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 6c793838f4ae..7744d28b67dc 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -21999,14 +21999,14 @@ "translated": "راه‌اندازی یک مدل محلی" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "دانلود یا آماده‌سازی یک مدل محلی روی این Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "یک سرویس مدل محلی متصل کنید یا مدلی را روی این Gateway آماده کنید." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "راه‌اندازی / دانلود مدل" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "اتصال / راه‌اندازی" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "ورود" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "مدل روی این Gateway دانلود و آماده شده است." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw قبل از استفاده، مدل آماده‌شده را شناسایی و تأیید می‌کند." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 270c3594de3c..1eb574c00256 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -21999,14 +21999,14 @@ "translated": "Configurer un modèle local" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Téléchargez ou préparez un modèle local sur ce Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Connectez un service de modèle local, ou préparez un modèle sur ce Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Configurer / Télécharger le modèle" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Connecter / Configurer" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Se connecter" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Le modèle est téléchargé et préparé sur ce Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw détectera et vérifiera le modèle préparé avant de l'utiliser." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index b858f956bbbf..9f0927ef257d 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -21999,14 +21999,14 @@ "translated": "लोकल मॉडल सेट करें" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "इस Gateway पर एक लोकल मॉडल डाउनलोड या तैयार करें।" + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "एक स्थानीय मॉडल सेवा कनेक्ट करें, या इस Gateway पर एक मॉडल तैयार करें।" }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "मॉडल सेट अप / डाउनलोड करें" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "कनेक्ट करें / सेट अप करें" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "साइन इन करें" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "मॉडल इस Gateway पर डाउनलोड और तैयार किया गया है।" + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw उपयोग करने से पहले तैयार किए गए मॉडल का पता लगाएगा और सत्यापित करेगा।" }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index df70aa6a22ea..6a5ed3d48b4d 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -21999,14 +21999,14 @@ "translated": "Siapkan model lokal" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Unduh atau siapkan model lokal di Gateway ini." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Sambungkan layanan model lokal, atau siapkan model di Gateway ini." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Siapkan / Unduh model" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Sambungkan / Siapkan" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Masuk" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Model diunduh dan disiapkan di Gateway ini." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw akan mendeteksi dan memverifikasi model yang disiapkan sebelum menggunakannya." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index fbfe820f4267..eb0240645554 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -21999,14 +21999,14 @@ "translated": "Configura un modello locale" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Scarica o prepara un modello locale su questo Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Connetti un servizio di modello locale o prepara un modello su questo Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Configura / Scarica modello" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Connetti / Configura" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Accedi" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Il modello è scaricato e preparato su questo Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw rileverà e verificherà il modello preparato prima di utilizzarlo." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 06177c93372a..f06b865582b2 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -21999,14 +21999,14 @@ "translated": "ローカルモデルをセットアップ" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "この Gateway でローカルモデルをダウンロードまたは準備します。" + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "ローカルモデルサービスを接続するか、この Gateway でモデルを準備します。" }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "モデルのセットアップ / ダウンロード" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "接続 / セットアップ" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "サインイン" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "モデルはこの Gateway でダウンロードされ、準備されています。" + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw は使用する前に、準備されたモデルを検出して検証します。" }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index ed5ade0a61de..ac0efa6b91b9 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -21999,14 +21999,14 @@ "translated": "로컬 모델 설정" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "이 Gateway에서 로컬 모델을 다운로드하거나 준비합니다." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "로컬 모델 서비스를 연결하거나 이 Gateway에서 모델을 준비하세요." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "모델 설정 / 다운로드" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "연결 / 설정" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "로그인" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "이 Gateway에서 모델이 다운로드되고 준비되었습니다." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw는 준비된 모델을 사용하기 전에 이를 감지하고 검증합니다." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index e89ae0605bb6..5b8e5c9f9fe4 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -21999,14 +21999,14 @@ "translated": "Een lokaal model instellen" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Download of bereid een lokaal model voor op deze Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Verbind een lokale modelservice of bereid een model voor op deze Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Model instellen / downloaden" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Verbinden / Instellen" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Aanmelden" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Het model is gedownload en voorbereid op deze Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw detecteert en verifieert het voorbereide model voordat het wordt gebruikt." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index da278b7fd836..bfdddef086a5 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -21999,14 +21999,14 @@ "translated": "Skonfiguruj model lokalny" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Pobierz lub przygotuj model lokalny na tym Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Połącz lokalną usługę modelu lub przygotuj model na tym Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Skonfiguruj / Pobierz model" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Połącz / Skonfiguruj" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Zaloguj się" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Model został pobrany i przygotowany na tym Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw wykryje i zweryfikuje przygotowany model przed jego użyciem." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index f5679b18f088..e916e9bf0912 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -21999,14 +21999,14 @@ "translated": "Configurar um modelo local" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Baixe ou prepare um modelo local neste Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Conecte um serviço de modelo local ou prepare um modelo neste Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Configurar / Baixar modelo" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Conectar / Configurar" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Entrar" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "O modelo foi baixado e preparado neste Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "O OpenClaw detectará e verificará o modelo preparado antes de usá-lo." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index 9ae5dc362154..7c867ff4a9e6 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -21999,14 +21999,14 @@ "translated": "Настроить локальную модель" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Загрузите или подготовьте локальную модель на этом Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Подключите локальный сервис модели или подготовьте модель на этом Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Настроить / Загрузить модель" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Подключить / Настроить" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Войти" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Модель загружена и подготовлена на этом Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw обнаружит и проверит подготовленную модель перед её использованием." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 8fc3b3f81ac9..b141c14f3dde 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -21999,14 +21999,14 @@ "translated": "Konfigurera en lokal modell" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Ladda ner eller förbered en lokal modell på denna Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Anslut en lokal modelltjänst eller förbered en modell på den här Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Konfigurera / Ladda ner modell" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Anslut / Konfigurera" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Logga in" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Modellen har laddats ner och förberetts på denna Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw upptäcker och verifierar den förberedda modellen innan den används." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index 7abce3aeb83d..b9afa4e697de 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -21999,14 +21999,14 @@ "translated": "ตั้งค่าโมเดลในเครื่อง" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "ดาวน์โหลดหรือเตรียมโมเดลในเครื่องบน Gateway นี้" + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "เชื่อมต่อบริการโมเดลในเครื่อง หรือเตรียมโมเดลบน Gateway นี้" }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "ตั้งค่า / ดาวน์โหลดโมเดล" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "เชื่อมต่อ / ตั้งค่า" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "ลงชื่อเข้าใช้" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "ดาวน์โหลดและเตรียมโมเดลบน Gateway นี้แล้ว" + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw จะตรวจจับและตรวจสอบโมเดลที่เตรียมไว้ก่อนใช้งาน" }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index d103c65dac77..6542d265c61d 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -21999,14 +21999,14 @@ "translated": "Yerel bir model kurun" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Bu Gateway'de yerel bir model indirin veya hazırlayın." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Yerel bir model hizmeti bağlayın veya bu Gateway üzerinde bir model hazırlayın." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Modeli kur / indir" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Bağlan / Kur" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Oturum aç" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Model bu Gateway'de indirildi ve hazırlandı." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw, hazırlanan modeli kullanmadan önce algılayıp doğrulayacaktır." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index a883ee6dea4b..12a95c66ba7a 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -21999,14 +21999,14 @@ "translated": "Налаштувати локальну модель" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Завантажте або підготуйте локальну модель на цьому Gateway." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Підключіть локальну службу моделей або підготуйте модель на цьому Gateway." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Налаштувати / Завантажити модель" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Підключити / Налаштувати" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Увійти" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Модель завантажено та підготовлено на цьому Gateway." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw виявить і перевірить підготовлену модель перед її використанням." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 92a99169293e..4ed897406960 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -21999,14 +21999,14 @@ "translated": "Thiết lập mô hình cục bộ" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "Tải xuống hoặc chuẩn bị một mô hình cục bộ trên Gateway này." + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "Kết nối dịch vụ mô hình cục bộ, hoặc chuẩn bị một mô hình trên Gateway này." }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "Thiết lập / Tải xuống mô hình" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "Kết nối / Thiết lập" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "Đăng nhập" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "Mô hình đã được tải xuống và chuẩn bị trên Gateway này." + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw sẽ phát hiện và xác minh mô hình đã chuẩn bị trước khi sử dụng." }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index cf458accdb8f..1607085781c3 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -21999,14 +21999,14 @@ "translated": "设置本地模型" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "在此 Gateway 上下载或准备本地模型。" + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "连接本地模型服务,或在此 Gateway 上准备模型。" }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "设置 / 下载模型" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "连接 / 设置" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "登录" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "模型已在此 Gateway 上下载并准备就绪。" + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw 会在使用前检测并验证已准备好的模型。" }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index aa8f97a80f98..30c4dc8ca813 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -21999,14 +21999,14 @@ "translated": "設定本機模型" }, { - "id": "native.apple.506a932536c642c2", - "source": "Download or prepare a local model on this Gateway.", - "translated": "在此 Gateway 上下載或準備本機模型。" + "id": "native.apple.780c1aa1c8868cc4", + "source": "Connect a local model service, or prepare a model on this Gateway.", + "translated": "連接本地模型服務,或在此 Gateway 上準備模型。" }, { - "id": "native.apple.d31411a9f3c68c33", - "source": "Set up / Download model", - "translated": "設定/下載模型" + "id": "native.apple.c218bd844ba8a0ac", + "source": "Connect / Set up", + "translated": "連接/設定" }, { "id": "native.apple.7fc409b6aaa6d3b9", @@ -22039,9 +22039,9 @@ "translated": "登入" }, { - "id": "native.apple.b62dfd708d5cfb14", - "source": "The model is downloaded and prepared on this Gateway.", - "translated": "模型已在此 Gateway 上下載並準備完成。" + "id": "native.apple.97b24c854f768998", + "source": "OpenClaw will detect and verify the prepared model before using it.", + "translated": "OpenClaw 會在使用前偵測並驗證已準備的模型。" }, { "id": "native.apple.869fe436119bb8a6", diff --git a/apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings b/apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings index 4e87b2db61f2..dc4103e6e724 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings +++ b/apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings @@ -28561,6 +28561,142 @@ } } }, + "Connect / Set up": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect / Set up" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "连接 / 设置" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "連接/設定" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conectar / Configurar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinden / Einrichten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectar / Configurar" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "接続 / セットアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결 / 설정" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecter / Configurer" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कनेक्ट करें / सेट अप करें" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال / الإعداد" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connetti / Configura" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlan / Kur" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підключити / Налаштувати" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Sambungkan / Siapkan" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połącz / Skonfiguruj" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เชื่อมต่อ / ตั้งค่า" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kết nối / Thiết lập" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Verbinden / Instellen" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "اتصال / راه‌اندازی" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключить / Настроить" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Anslut / Konfigurera" + } + } + } + }, "Connect Discord, Slack, Telegram, WhatsApp, …": { "localizations": { "en": { @@ -28697,6 +28833,142 @@ } } }, + "Connect a local model service, or prepare a model on this Gateway.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a local model service, or prepare a model on this Gateway." + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "连接本地模型服务,或在此 Gateway 上准备模型。" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "連接本地模型服務,或在此 Gateway 上準備模型。" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conecte um serviço de modelo local ou prepare um modelo neste Gateway." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinden Sie einen lokalen Modelldienst oder bereiten Sie ein Modell auf diesem Gateway vor." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conecta un servicio de modelo local o prepara un modelo en este Gateway." + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "ローカルモデルサービスを接続するか、この Gateway でモデルを準備します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 모델 서비스를 연결하거나 이 Gateway에서 모델을 준비하세요." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez un service de modèle local, ou préparez un modèle sur ce Gateway." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "एक स्थानीय मॉडल सेवा कनेक्ट करें, या इस Gateway पर एक मॉडल तैयार करें।" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اتصل بخدمة نموذج محلي، أو جهّز نموذجًا على هذا الـ Gateway." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connetti un servizio di modello locale o prepara un modello su questo Gateway." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerel bir model hizmeti bağlayın veya bu Gateway üzerinde bir model hazırlayın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підключіть локальну службу моделей або підготуйте модель на цьому Gateway." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Sambungkan layanan model lokal, atau siapkan model di Gateway ini." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połącz lokalną usługę modelu lub przygotuj model na tym Gateway." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เชื่อมต่อบริการโมเดลในเครื่อง หรือเตรียมโมเดลบน Gateway นี้" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kết nối dịch vụ mô hình cục bộ, hoặc chuẩn bị một mô hình trên Gateway này." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Verbind een lokale modelservice of bereid een model voor op deze Gateway." + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "یک سرویس مدل محلی متصل کنید یا مدلی را روی این Gateway آماده کنید." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключите локальный сервис модели или подготовьте модель на этом Gateway." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Anslut en lokal modelltjänst eller förbered en modell på den här Gateway." + } + } + } + }, "Connect a provider below with an API key or token, then check again.": { "localizations": { "en": { @@ -44337,142 +44609,6 @@ } } }, - "Download or prepare a local model on this Gateway.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download or prepare a local model on this Gateway." - } - }, - "zh-CN": { - "stringUnit": { - "state": "translated", - "value": "在此 Gateway 上下载或准备本地模型。" - } - }, - "zh-TW": { - "stringUnit": { - "state": "translated", - "value": "在此 Gateway 上下載或準備本機模型。" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Baixe ou prepare um modelo local neste Gateway." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Laden Sie ein lokales Modell auf diesem Gateway herunter oder bereiten Sie es vor." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descarga o prepara un modelo local en este Gateway." - } - }, - "ja-JP": { - "stringUnit": { - "state": "translated", - "value": "この Gateway でローカルモデルをダウンロードまたは準備します。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 Gateway에서 로컬 모델을 다운로드하거나 준비합니다." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Téléchargez ou préparez un modèle local sur ce Gateway." - } - }, - "hi": { - "stringUnit": { - "state": "translated", - "value": "इस Gateway पर एक लोकल मॉडल डाउनलोड या तैयार करें।" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "نزّل أو جهّز نموذجًا محليًا على هذا الـ Gateway." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scarica o prepara un modello locale su questo Gateway." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bu Gateway'de yerel bir model indirin veya hazırlayın." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Завантажте або підготуйте локальну модель на цьому Gateway." - } - }, - "id": { - "stringUnit": { - "state": "translated", - "value": "Unduh atau siapkan model lokal di Gateway ini." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Pobierz lub przygotuj model lokalny na tym Gateway." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ดาวน์โหลดหรือเตรียมโมเดลในเครื่องบน Gateway นี้" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải xuống hoặc chuẩn bị một mô hình cục bộ trên Gateway này." - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Download of bereid een lokaal model voor op deze Gateway." - } - }, - "fa": { - "stringUnit": { - "state": "translated", - "value": "دانلود یا آماده‌سازی یک مدل محلی روی این Gateway." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Загрузите или подготовьте локальную модель на этом Gateway." - } - }, - "sv": { - "stringUnit": { - "state": "translated", - "value": "Ladda ner eller förbered en lokal modell på denna Gateway." - } - } - } - }, "Each selected thread and its transcript will be removed from the gateway.": { "localizations": { "en": { @@ -91801,6 +91937,142 @@ } } }, + "OpenClaw will detect and verify the prepared model before using it.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw will detect and verify the prepared model before using it." + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw 会在使用前检测并验证已准备好的模型。" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw 會在使用前偵測並驗證已準備的模型。" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O OpenClaw detectará e verificará o modelo preparado antes de usá-lo." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw erkennt und überprüft das vorbereitete Modell, bevor es verwendet wird." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw detectará y verificará el modelo preparado antes de usarlo." + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw は使用する前に、準備されたモデルを検出して検証します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw는 준비된 모델을 사용하기 전에 이를 감지하고 검증합니다." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw détectera et vérifiera le modèle préparé avant de l'utiliser." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw उपयोग करने से पहले तैयार किए गए मॉडल का पता लगाएगा और सत्यापित करेगा।" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيكتشف OpenClaw النموذج المُجهّز ويتحقق منه قبل استخدامه." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw rileverà e verificherà il modello preparato prima di utilizzarlo." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw, hazırlanan modeli kullanmadan önce algılayıp doğrulayacaktır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw виявить і перевірить підготовлену модель перед її використанням." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw akan mendeteksi dan memverifikasi model yang disiapkan sebelum menggunakannya." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw wykryje i zweryfikuje przygotowany model przed jego użyciem." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw จะตรวจจับและตรวจสอบโมเดลที่เตรียมไว้ก่อนใช้งาน" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw sẽ phát hiện và xác minh mô hình đã chuẩn bị trước khi sử dụng." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw detecteert en verifieert het voorbereide model voordat het wordt gebruikt." + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw قبل از استفاده، مدل آماده‌شده را شناسایی و تأیید می‌کند." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw обнаружит и проверит подготовленную модель перед её использованием." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "OpenClaw upptäcker och verifierar den förberedda modellen innan den används." + } + } + } + }, "OpenClaw — setup helper": { "localizations": { "en": { @@ -122401,142 +122673,6 @@ } } }, - "Set up / Download model": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Set up / Download model" - } - }, - "zh-CN": { - "stringUnit": { - "state": "translated", - "value": "设置 / 下载模型" - } - }, - "zh-TW": { - "stringUnit": { - "state": "translated", - "value": "設定/下載模型" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Configurar / Baixar modelo" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Modell einrichten / herunterladen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Configurar / Descargar modelo" - } - }, - "ja-JP": { - "stringUnit": { - "state": "translated", - "value": "モデルのセットアップ / ダウンロード" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "모델 설정 / 다운로드" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Configurer / Télécharger le modèle" - } - }, - "hi": { - "stringUnit": { - "state": "translated", - "value": "मॉडल सेट अप / डाउनलोड करें" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعداد / تنزيل النموذج" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Configura / Scarica modello" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Modeli kur / indir" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Налаштувати / Завантажити модель" - } - }, - "id": { - "stringUnit": { - "state": "translated", - "value": "Siapkan / Unduh model" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Skonfiguruj / Pobierz model" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ตั้งค่า / ดาวน์โหลดโมเดล" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thiết lập / Tải xuống mô hình" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Model instellen / downloaden" - } - }, - "fa": { - "stringUnit": { - "state": "translated", - "value": "راه‌اندازی / دانلود مدل" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Настроить / Загрузить модель" - } - }, - "sv": { - "stringUnit": { - "state": "translated", - "value": "Konfigurera / Ladda ner modell" - } - } - } - }, "Set up a local model": { "localizations": { "en": { @@ -142121,142 +142257,6 @@ } } }, - "The model is downloaded and prepared on this Gateway.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The model is downloaded and prepared on this Gateway." - } - }, - "zh-CN": { - "stringUnit": { - "state": "translated", - "value": "模型已在此 Gateway 上下载并准备就绪。" - } - }, - "zh-TW": { - "stringUnit": { - "state": "translated", - "value": "模型已在此 Gateway 上下載並準備完成。" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O modelo foi baixado e preparado neste Gateway." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Modell wurde auf diesem Gateway heruntergeladen und vorbereitet." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El modelo se ha descargado y preparado en este Gateway." - } - }, - "ja-JP": { - "stringUnit": { - "state": "translated", - "value": "モデルはこの Gateway でダウンロードされ、準備されています。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 Gateway에서 모델이 다운로드되고 준비되었습니다." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Le modèle est téléchargé et préparé sur ce Gateway." - } - }, - "hi": { - "stringUnit": { - "state": "translated", - "value": "मॉडल इस Gateway पर डाउनलोड और तैयार किया गया है।" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تم تنزيل النموذج وتجهيزه على هذا الـ Gateway." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Il modello è scaricato e preparato su questo Gateway." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Model bu Gateway'de indirildi ve hazırlandı." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Модель завантажено та підготовлено на цьому Gateway." - } - }, - "id": { - "stringUnit": { - "state": "translated", - "value": "Model diunduh dan disiapkan di Gateway ini." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Model został pobrany i przygotowany na tym Gateway." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ดาวน์โหลดและเตรียมโมเดลบน Gateway นี้แล้ว" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mô hình đã được tải xuống và chuẩn bị trên Gateway này." - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Het model is gedownload en voorbereid op deze Gateway." - } - }, - "fa": { - "stringUnit": { - "state": "translated", - "value": "مدل روی این Gateway دانلود و آماده شده است." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Модель загружена и подготовлена на этом Gateway." - } - }, - "sv": { - "stringUnit": { - "state": "translated", - "value": "Modellen har laddats ner och förberetts på denna Gateway." - } - } - } - }, "The node service restarted but did not remain running.": { "localizations": { "en": { From 44ce19821fbced4382d2956fa379dc61d87b3ced Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:09:17 +0800 Subject: [PATCH 061/239] fix(ci): retire Kova runtime deps coverage (#116760) --- .github/workflows/openclaw-performance.yml | 4 ++-- test/scripts/openclaw-performance-workflow.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index f533c7dde338..85acf3351cad 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -154,8 +154,8 @@ jobs: deep_profile: "false" live: "false" managed_service: "true" - include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message" - expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" + include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message" + expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" - lane: mock-deep-profile title: Kova mock provider deep profile auth: mock diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 4134c584a91c..753fcd06801c 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -729,7 +729,7 @@ esac const expectedReleaseEntries = matrixEntries.map((entry) => entry.expected_release_entries); expect(includeFilters).toEqual([ - "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message", + "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message", "scenario:fresh-install,scenario:gateway-performance,scenario:agent-cold-warm-message", "scenario:agent-cold-warm-message", ]); @@ -742,7 +742,7 @@ esac expect(runKova.run).toContain('--include "$INCLUDE_FILTERS"'); expect(runKova.run).not.toContain("for filter in $INCLUDE_FILTERS"); expect(expectedReleaseEntries).toEqual([ - "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", + "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "fresh-install:fresh,fresh-install:onboarded-user,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "agent-cold-warm-message:mock-openai-provider", ]); From b252df88494e0bf1b5ea882fa7c74c1f786874e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:09:57 -0700 Subject: [PATCH 062/239] docs(skill): isolate autonomous issue sweep worktrees --- .../openclaw-autonomous-issue-sweep/SKILL.md | 83 +++++++++++++------ 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md index 9ce683ef9957..6f2e2c3e4344 100644 --- a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -1,6 +1,6 @@ --- name: openclaw-autonomous-issue-sweep -description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest; find existing PRs, deeply investigate bugs, simplify or refactor, live-test, independently review, land verified fixes, close already-fixed issues, and add only meaningful new evidence." +description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest with isolated issue worktrees and resource-bounded parallelism; investigate bugs, simplify or refactor, review, land verified fixes, close already-fixed issues, and add meaningful evidence." --- # OpenClaw Autonomous Issue Sweep @@ -17,9 +17,11 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. - Use full-history forks so every subagent inherits the orchestrator's model and **xhigh reasoning effort**. Never print, record, or disclose model identifiers; redact subprocess banners and diagnostics before reporting. -- Treat a request to run this workflow as authority to review, fix, refactor, - commit, push, create/update PRs, land eligible changes, comment, and close - issues individually. Do not ask for routine confirmation again. +- Treat a request to run this workflow as authority to create lightweight, + issue-scoped isolated Git worktrees and `codex/issue-` branches, review, + fix, refactor, commit, push, create/update PRs, land eligible changes, + comment, and close issues individually. Do not ask for separate worktree or + routine-operation confirmation again. - Never treat sweep authority as permission to publish releases, bump protocol or SQLite schema versions, weaken security, break shipped compatibility, change another owner's protected product surface, or execute untrusted code @@ -36,30 +38,52 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. ## Coordinate 64 workers safely 1. Assign one subagent to maintain the live open-issue queue in descending - `createdAt` order, one to coordinate landing/proof capacity, and the rest to - issue investigations. Coordinator agents also investigate when idle. + `createdAt` order, one to coordinate landing/proof capacity, and no more + than **3** to live issue closures or other GitHub mutations. Assign the + remaining slots to issue investigations; idle coordinators also investigate. 2. Claim issues from the newest unclaimed end only; replenish workers as they finish. Parallel completions may arrive out of order, but never knowingly start an older unclaimed issue ahead of a newer available issue. 3. Deduplicate by canonical root cause, not merely by issue number. Let one owner fix a shared defect and link related issues/PRs to that outcome. -4. Freeze the reviewed source SHA for each wave. Designate a single fetch owner; - pause shared-ref refreshes while repo-native PR prepare/merge runs. -5. Never switch a shared checkout branch or edit it while sibling agents use it. - Use an existing agent-owned checkout, a repo-native isolated PR worktree, or - an explicitly user-authorized new worktree. Otherwise serialize write - access; parallel read-only investigations may continue. -6. Sample checkout/temp-volume free disk, CPU/load, memory pressure, process - count, operator-gateway health, actual worker count, and Octopool capacity - before each wave and periodically thereafter. Throttle expensive work for - sustained pressure or low disk; never kill unrelated operator processes. -7. Serialize merge operations and each Testbox lease. A lease has one owner and - one active command; never reclaim, sync, or change its head during a run. +4. Freeze the reviewed source SHA for each wave. Serialize only shared Git/ref + mutations: fetches, branch/ref changes, `git worktree add`/remove, PR + preparation and merges, and main-targeted pushes. Give each mutation a brief + coordinator-owned exclusive slot; do not hold it across coding, proof, + reviews, remote waits, or other independent issue work. +5. Give every independent root-cause fix its own isolated, issue-scoped + lightweight worktree and `codex/issue-` branch. Create it from the + frozen SHA, for example: + + ```bash + git worktree add -b "codex/issue-$issue_id" \ + "$campaign_worktrees/issue-$issue_id" "$frozen_main_sha" + ``` + + Reuse a repo-native isolated PR worktree when repairing an existing PR; + duplicate issues sharing one root cause share its single owner/worktree. + Share Git objects; do not clone the repository or install dependencies per + worktree merely for isolation. Never edit, switch, reset, or otherwise + mutate the shared checkout while sibling workers are active. Once isolated + worktrees exist, independent issue owners edit, inspect, and verify in + parallel within their own checkout. +6. Keep all **64** inherited high-effort agents available, but distinguish idle + agents from active local tool users. Start with bounded waves of **4–8** + concurrently active code/test workers and continuously reduce or expand that + limit according to usable CPU/load, memory/swap pressure, checkout and temp + free disk, process count, operator-gateway health, and remote-pool capacity. + Reserve capacity for the operator; count heavyweight proof proportionally, + stop admitting new commands under sustained pressure, and resume in small + waves after recovery. Never kill unrelated operator processes. +7. Serialize merges and each Testbox lease, not independent worktree edits. A + lease has one owner and one active command; never reclaim, sync, or change + its head during a run. 8. Respect GitHub rate limits, active assignees, repository ownership, and existing contributor work. Do not auto-assign broad-discovery candidates. 9. Replace finished workers while the queue remains. Record actual active, - completed, failed, fixed, landed, closed, commented, and skipped counts; - never report launched or finished workers as still running. + parked, completed, failed, fixed, landed, verified-closed, queued-for-close, + commented, and skipped counts. Persist that campaign checkpoint for resumed + workers; never report launched, parked, or finished workers as still running. ## Conserve GitHub capacity and host resources @@ -79,10 +103,17 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. merge decisions, or a stale/contradictory cached result. Rate-limit and deduplicate worker requests instead of having 64 agents independently fetch the same issue, PR, author profile, or CI rollup. -- Keep disk, load, memory pressure, active lease IDs, provider trust class, - checkout ownership, and pool capacity in the orchestration ledger. Slow new - assignments, serialize builds/tests, clean only campaign-owned artifacts, - and offload heavy proof before resource pressure threatens the host. +- Keep disk, CPU/load, memory pressure, active lease IDs, provider trust class, + issue-worktree ownership, active local tool count, frozen heads, and pool + capacity in the orchestration ledger. Dynamically cap concurrent code/test + workers instead of serializing every independent fix. Pause or interrupt only + campaign-owned work under host pressure, preserve each issue's claim and + isolated checkout, then resume from that recorded state when capacity returns. + Offload heavy proof before resource pressure threatens the host. +- Worktree checkout and dependency use must respect free-disk headroom. Reuse + shared Git objects and existing trusted dependency installs where safe; route + dependency-missing or heavyweight proof to the selected remote box instead + of multiplying local installs across issue checkouts. - The parent may prewarm a trusted Crabbox/Testbox lease when a concrete heavy proof is imminent, then hand its verified lease ID and checkout ownership to one subagent at a time. Avoid speculative fleets, respect path-scoped lease @@ -235,6 +266,10 @@ moves:` item with real evidence or an explicit reason for skipping it. - Recheck live state immediately before every mutation; avoid redundant, speculative, noisy, or duplicate comments. Handle closures individually and follow repository limits on bulk operations. +- After verifying the canonical landed SHA and preserving contributor credit, + remove only that campaign-owned isolated worktree during a brief serialized + Git mutation slot. Delete its campaign-owned branch only when no unlanded + work depends on it; never prune unrelated worktrees, refs, or user files. ## Parent-thread reporting From f9207db3ca957d77efe293dddbeaeb8cb122ed40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:10:54 -0700 Subject: [PATCH 063/239] fix(bedrock): reject truncated streams and preserve audio results (#116743) Co-authored-by: Peter Steinberger --- .../amazon-bedrock/stream.runtime.test.ts | 123 ++++++++++++++++++ extensions/amazon-bedrock/stream.runtime.ts | 9 +- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index 114bbd539c39..cf5499377ac8 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -123,6 +123,74 @@ describe("Bedrock inbound image base64", () => { }); describe("Bedrock tool-result replay", () => { + it("replays unsupported audio attachments as their canonical text placeholder", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_audio", + toolName: "listen", + content: [{ type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_audio", + content: [{ text: "(see attached audio)" }], + }, + }, + ], + }); + }); + + it("preserves valid text and image attachments alongside unsupported audio", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_media", + toolName: "inspect", + content: [ + { type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }, + { type: "text", text: "actual tool output" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2U=" }, + ], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_media", + content: [ + { text: "actual tool output" }, + { image: { format: "png", source: { bytes: expect.any(Uint8Array) } } }, + ], + }, + }, + ], + }); + }); + it("drops payload-less image husks from consecutive tool results", () => { const messages = testing.convertMessages( { @@ -335,6 +403,61 @@ describe("Bedrock profile endpoint resolution", () => { }); describe("Bedrock stop reasons", () => { + it.each([ + { + name: "text", + events: [ + { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "truncated response" } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "text", + }, + { + name: "tool call", + events: [ + { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "call_lookup", name: "lookup" } }, + }, + }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"query":"partial"}' } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "toolCall", + }, + ])( + "reports truncated $name streams without a terminal messageStop", + async ({ events, contentType }) => { + vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: streamEvents([{ messageStart: { role: ConversationRole.ASSISTANT } }, ...events]), + } as never); + + const stream = streamBedrockForTest(bedrockModel({}), { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], + } as never); + const eventTypes: string[] = []; + for await (const event of stream) { + eventTypes.push(event.type); + } + const result = await stream.result(); + + expect(eventTypes.at(-1)).toBe("error"); + expect(eventTypes).not.toContain("done"); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Bedrock stream ended before messageStop"); + expect(result.content).toEqual([expect.objectContaining({ type: contentType })]); + expect(result.content[0]).not.toHaveProperty("index"); + expect(result.content[0]).not.toHaveProperty("partialJson"); + }, + ); + it.each([ BedrockStopReason.CONTENT_FILTERED, BedrockStopReason.GUARDRAIL_INTERVENED, diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 2137af2981c9..aa12bbc0ed7f 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -332,7 +332,7 @@ const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } } - if (refusalBuffer && !sawMessageStop) { + if (!sawMessageStop) { throw new Error("Bedrock stream ended before messageStop"); } if (options.signal?.aborted) { @@ -812,7 +812,7 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR content.push({ text: sanitizeSurrogates(block.text) }); continue; } - if (describeToolResultMediaPlaceholder([block])) { + if (block.type === "image" && describeToolResultMediaPlaceholder([block])) { content.push({ image: createImageBlock(block.mimeType, block.data) }); } } @@ -820,7 +820,10 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR return { toolResult: { toolUseId: message.toolCallId, - content: content.length > 0 ? content : [{ text: "(no output)" }], + content: + content.length > 0 + ? content + : [{ text: describeToolResultMediaPlaceholder(message.content) ?? "(no output)" }], status: message.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }; From 7ea4129227146118096fea4d0706504bef6ad6ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:12:48 -0700 Subject: [PATCH 064/239] fix(qa): authenticate summaries and bound native scenario commands (#116748) Co-authored-by: Peter Steinberger --- extensions/qa-lab/src/cli.runtime.test.ts | 122 +++++++++++++++--- extensions/qa-lab/src/cli.runtime.ts | 52 ++++---- .../telegram/cli.runtime.test.ts | 43 +++++- .../live-transports/telegram/cli.runtime.ts | 13 +- extensions/qa-lab/src/suite-summary.test.ts | 54 ++++++++ extensions/qa-lab/src/suite-summary.ts | 13 +- .../src/test-file-scenario-runner.test.ts | 76 ++++++++++- .../qa-lab/src/test-file-scenario-runner.ts | 4 +- 8 files changed, 320 insertions(+), 57 deletions(-) diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index bd0eac4708f4..f0a7392ef79d 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -314,12 +314,12 @@ describe("qa cli runtime", () => { watchUrl: "http://127.0.0.1:43124", }); runQaMultipass.mockResolvedValue({ - outputDir: "/tmp/multipass", - reportPath: "/tmp/multipass/qa-suite-report.md", - summaryPath: "/tmp/multipass/qa-suite-summary.json", - hostLogPath: "/tmp/multipass/multipass-host.log", - bootstrapLogPath: "/tmp/multipass/multipass-guest-bootstrap.log", - guestScriptPath: "/tmp/multipass/multipass-guest-run.sh", + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + hostLogPath: path.join(suiteArtifactsDir, "multipass-host.log"), + bootstrapLogPath: path.join(suiteArtifactsDir, "multipass-guest-bootstrap.log"), + guestScriptPath: path.join(suiteArtifactsDir, "multipass-guest-run.sh"), vmName: "openclaw-qa-test", scenarioIds: ["channel-chat-baseline"], }); @@ -464,9 +464,7 @@ describe("qa cli runtime", () => { } }); - it("keeps direct-suite zero-work validation disabled with --allow-failures", async () => { - const priorExitCode = process.exitCode; - process.exitCode = undefined; + it("rejects direct-suite zero-work summaries even with --allow-failures", async () => { const optionalScenario = { name: "Runtime tool fixture — image_generate", status: "skip" as const, @@ -490,14 +488,108 @@ describe("qa cli runtime", () => { }), ); - try { - await runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }); - expect(process.exitCode).toBeUndefined(); - } finally { - process.exitCode = priorExitCode; - } + await expect( + runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }), + ).rejects.toThrow("did not include any executed scenarios"); }); + it.each([ + { runner: "host" as const, summary: "missing" as const, expected: "Could not read QA summary" }, + { + runner: "host" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "missing" as const, + expected: "Could not read QA summary", + }, + { + runner: "multipass" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "zero-work" as const, + expected: "did not include any executed scenarios", + }, + ...(["host", "flow", "multipass"] as const).flatMap((runner) => [ + { + runner, + summary: "required-skip" as const, + expected: "did not include any executed scenarios", + }, + { + runner, + summary: "blocked" as const, + expected: "did not include any executed scenarios", + }, + ]), + ])( + "rejects $summary $runner summaries even with --allow-failures", + async ({ runner, summary, expected }) => { + if (summary === "missing") { + await fs.rm(suiteSummaryPath); + } else if (summary === "malformed") { + await fs.writeFile(suiteSummaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { + total: 1, + passed: 0, + failed: 0, + skipped: summary === "required-skip" ? 1 : 0, + }, + scenarios: [ + { + name: "Required channel scenario", + status: summary === "required-skip" ? "skip" : "blocked", + details: "Required transport unavailable", + }, + ], + }), + "utf8", + ); + } + if (runner === "host" || runner === "flow") { + runQaSuite.mockResolvedValueOnce( + runner === "flow" + ? flowSuiteRuntimeResult({ + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + }) + : unifiedSuiteRuntimeResult({ + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + evidencePath: suiteEvidencePath, + }), + ); + } + + await expect( + runQaSuiteCommand({ + repoRoot: "/tmp/openclaw-repo", + ...(runner === "multipass" ? { runner } : {}), + allowFailures: true, + }), + ).rejects.toThrow(expected); + }, + ); + it("rejects host-only resource options for Playwright scenarios", async () => { await expect( runQaSuiteCommand({ diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 8168ca123e77..5ab0fe1b0a45 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -368,6 +368,7 @@ async function runQaParityPreflight(params: { process.stdout.write(`QA parity preflight summary: ${result.summaryPath}\n`); const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( result.summaryPath, + { requireExecutedScenario: params.allowFailures === true }, ); if (blockingScenarioCount > 0) { if (params.allowFailures === true) { @@ -978,19 +979,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA Multipass summary: ${result.summaryPath}\n`); process.stdout.write(`QA Multipass host log: ${result.hostLogPath}\n`); process.stdout.write(`QA Multipass bootstrap log: ${result.bootstrapLogPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1051,19 +1051,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA suite report: ${result.reportPath}\n`); process.stdout.write(`QA suite evidence: ${result.evidencePath}\n`); process.stdout.write(`QA suite summary: ${result.summaryPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1080,6 +1079,7 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { scenarioIds, explicitScenarioSelection: opts.explicitScenarioSelection, }), + requireExecutedScenario: allowFailures, }, ); if (!allowFailures && blockingScenarioCount > 0) { diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts index 39bb142fb031..c5a71ff2257b 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts @@ -60,6 +60,8 @@ describe("Telegram live QA scenario gate", () => { summaryPath, JSON.stringify({ counts: { + total: 1, + passed: status === "pass" ? 1 : 0, failed: status === "fail" ? 1 : 0, skipped: status === "skip" || status === "skipped" ? 1 : 0, }, @@ -76,6 +78,7 @@ describe("Telegram live QA scenario gate", () => { delete process.env[SUT_COMMAND_ENV]; tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-qa-telegram-gate-")); summaryPath = path.join(tempRoot, "qa-suite-summary.json"); + writeSummary("pass"); mocks.resolveTelegramQaScenarioIds.mockReturnValue(["channel-canary"]); mocks.runQaFlowSuiteFromRuntime.mockResolvedValue({ reportPath: ".artifacts/qa-e2e/telegram/qa-suite-report.md", @@ -121,7 +124,8 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); - it("does not read the summary when failures are explicitly allowed", async () => { + it("permits genuinely executed failed scenarios when failures are explicitly allowed", async () => { + writeSummary("fail"); await runQaTelegramSuite({ repoRoot: "/repo", providerMode: "mock-openai", @@ -131,6 +135,43 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); + it.each([ + { summary: "missing", expected: "Could not read QA summary" }, + { summary: "malformed", expected: "Could not parse QA summary" }, + { summary: "zero-work", expected: "did not include any executed scenarios" }, + { summary: "required-skip", expected: "did not include any executed scenarios" }, + { summary: "blocked", expected: "did not include any executed scenarios" }, + ])( + "rejects $summary Telegram summaries even with --allow-failures", + async ({ summary, expected }) => { + if (summary === "missing") { + rmSync(summaryPath); + } else if (summary === "malformed") { + writeFileSync(summaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + writeFileSync( + summaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + writeSummary(summary === "required-skip" ? "skip" : "blocked"); + } + + await expect( + runQaTelegramSuite({ + repoRoot: "/repo", + providerMode: "mock-openai", + allowFailures: true, + }), + ).rejects.toThrow(expected); + expect(process.exitCode).toBeUndefined(); + }, + ); + it("lists only scenarios accepted by its flow runner", async () => { const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); mocks.listTelegramQaScenarios.mockReturnValue([ diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts index 99011ebab2bf..4754c1d7eed1 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts @@ -199,13 +199,12 @@ export async function runQaTelegramSuite(opts: TelegramQaSuiteOptions) { report: result.reportPath, summary: result.summaryPath, }); - if (!runOptions.allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { requireExecutedScenario: runOptions.allowFailures === true }, + ); + if (!runOptions.allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } diff --git a/extensions/qa-lab/src/suite-summary.test.ts b/extensions/qa-lab/src/suite-summary.test.ts index cb32550a119f..9abcbf5fc279 100644 --- a/extensions/qa-lab/src/suite-summary.test.ts +++ b/extensions/qa-lab/src/suite-summary.test.ts @@ -94,6 +94,60 @@ describe("qa suite summary helpers", () => { ).resolves.toBe(1); }); + it.each([ + { + name: "required skip", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skip" }], + }, + }, + { + name: "required skipped", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skipped" }], + }, + }, + { + name: "blocked scenario", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "blocked" }], + }, + }, + { + name: "blocked evidence", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + entries: [{ result: { status: "blocked" } }], + }, + }, + ])("requires a completed scenario before tolerating $name", async ({ summary }) => { + await expect( + readSummary(summary, (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).rejects.toThrow("did not include any executed scenarios"); + }); + + it("still permits a genuinely executed failed scenario in failure-tolerant gates", async () => { + await expect( + readSummary( + { + counts: { total: 1, passed: 0, failed: 1, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "fail" }], + }, + (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).resolves.toBe(1); + }); + it("rejects a suite containing only catalog-confirmed report-only skips", async () => { await expect( readSummary( diff --git a/extensions/qa-lab/src/suite-summary.ts b/extensions/qa-lab/src/suite-summary.ts index 22a873b93bcb..25c7e44d0a08 100644 --- a/extensions/qa-lab/src/suite-summary.ts +++ b/extensions/qa-lab/src/suite-summary.ts @@ -118,6 +118,7 @@ function assertQaSuiteSummaryHasExecutedScenarios( summaryPath: string, errorCode: "summary_failure_count_missing" | "summary_blocking_count_missing", optionalScenarioNames?: ReadonlySet, + requireExecutedScenario = false, ): void { if (!summary || typeof summary !== "object") { return; @@ -137,14 +138,15 @@ function assertQaSuiteSummaryHasExecutedScenarios( const entries = Array.isArray(payload.entries) ? (payload.entries as QaEvidenceEntryStatus[]) : undefined; - const hasExecutedScenario = + const hasCompletedScenario = scenarios?.some((scenario) => scenario.status === "pass" || scenario.status === "fail") === true || entries?.some((entry) => entry.result?.status === "pass" || entry.result?.status === "fail") === true || (passed ?? 0) > 0 || - (failed ?? 0) > 0 || - (total !== null && total > 0 && (skipped === null || total > skipped)); + (failed ?? 0) > 0; + const hasExecutedScenario = + hasCompletedScenario || (total !== null && total > 0 && (skipped === null || total > skipped)); const hasBlockingNonOptionalSkip = errorCode === "summary_blocking_count_missing" && scenarios?.some( @@ -169,6 +171,8 @@ function assertQaSuiteSummaryHasExecutedScenarios( if ( total === 0 || scenarios?.length === 0 || + // A tolerated blocking result cannot authenticate a campaign that never completed a scenario. + (requireExecutedScenario && !hasCompletedScenario) || (!hasExecutedScenario && !hasBlockingUnknownOrFailedScenario && !hasBlockingNonOptionalSkip && @@ -309,7 +313,7 @@ export async function readQaSuiteFailedScenarioCountFromFile(summaryPath: string export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath: string, - options?: { optionalScenarioNames?: ReadonlySet }, + options?: { optionalScenarioNames?: ReadonlySet; requireExecutedScenario?: boolean }, ): Promise { const payload = await readQaSuiteSummaryFile(summaryPath); assertQaSuiteSummaryHasExecutedScenarios( @@ -317,6 +321,7 @@ export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath, "summary_blocking_count_missing", options?.optionalScenarioNames, + options?.requireExecutedScenario, ); const blockingScenarioCount = readQaSuiteFailedOrSkippedScenarioCountFromSummary(payload); if (blockingScenarioCount !== null) { diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index c7bc85230128..2daa843720fc 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -258,7 +258,7 @@ describe("qa test file scenario runner", () => { "sends a chat turn through the GUI", ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined, undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000, 1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -366,7 +366,7 @@ describe("qa test file scenario runner", () => { )}`, ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -885,6 +885,78 @@ describe("qa test file scenario runner", () => { expect(commands.map((command) => command.timeoutMs)).toEqual([3 * 60 * 60_000]); }); + it.each([ + { executionKind: "vitest" as const, commandCount: 1 }, + { executionKind: "playwright" as const, commandCount: 2 }, + ])( + "applies the resolved command timeout to every $executionKind subprocess", + async ({ commandCount, executionKind }) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-command-timeout-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const commands: QaScenarioCommandExecution[] = []; + + await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 321, + runCommand: async (command) => { + commands.push(command); + await writeNativeVitestReport(command, { passed: 1 }); + return { exitCode: 0, stdout: "native pass\n", stderr: "" }; + }, + }); + + expect(commands).toHaveLength(commandCount); + expect(commands.map((command) => command.timeoutMs)).toEqual( + Array.from({ length: commandCount }, () => 321), + ); + }, + ); + + it.each(["vitest", "playwright"] as const)( + "terminates a hanging $executionKind subprocess with failure evidence", + async (executionKind) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-hung-command-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const result = await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 100, + runCommand: (execution) => + runQaScenarioCommandLifecycle({ + ...execution, + args: ["-e", "setInterval(() => {}, 1_000)"], + }), + }); + + expect(result.results[0]).toMatchObject({ + failureMessage: expect.stringContaining("timed out after 100ms"), + status: "fail", + }); + expect(result.evidence.entries[0]?.result.status).toBe("fail"); + }, + ); + describe.skipIf(process.platform === "win32")("script timeout process groups", () => { const commandTimeoutMs = 1_500; let descendantPid: number | undefined; diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index 152ba80291e4..5b6ae5264665 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -295,13 +295,13 @@ async function runScenarioCommandSteps(params: { const timeoutMs = params.scenario.execution.kind === "script" ? (params.scenario.execution.timeoutMs ?? params.commandTimeoutMs) - : undefined; + : params.commandTimeoutMs; const result = await params.runCommand({ command: step.command, args: step.args, cwd: params.repoRoot, env: params.env, - ...(timeoutMs === undefined ? {} : { timeoutMs }), + timeoutMs, }); if (result.stdout) { logChunks.push(result.stdout); From 454bf5ccd7eb7bae652bf043f2809359c27c9186 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:16:06 -0700 Subject: [PATCH 065/239] fix(cli): reject dangling config path escapes (#116738) Co-authored-by: Peter Steinberger --- src/cli/config-cli-path.ts | 5 +++-- src/cli/config-cli.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/cli/config-cli-path.ts b/src/cli/config-cli-path.ts index 86fb16ee15e4..9af8986610c9 100644 --- a/src/cli/config-cli-path.ts +++ b/src/cli/config-cli-path.ts @@ -69,9 +69,10 @@ function parsePath(raw: string): PathSegment[] { const ch = trimmed[i]; if (ch === "\\") { const next = trimmed[i + 1]; - if (next) { - current += next; + if (next === undefined) { + throw new Error(`Invalid path (trailing escape): ${raw}`); } + current += next; i += 2; continue; } diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8d00c3f9d0db..857f123b8812 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -3346,6 +3346,31 @@ describe("config cli", () => { args: ["config", "set", "gateway.[port]", "23456"], error: "Invalid path (empty segment): gateway.[port]", }, + { + name: "rejects a trailing escape for config get before reading another key", + args: ["config", "get", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config set before writing another key", + args: ["config", "set", "gateway.port\\", "23456"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config unset before deleting another key", + args: ["config", "unset", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for batch config set before writing another key", + args: [ + "config", + "set", + "--batch-json", + JSON.stringify([{ path: "gateway.port\\", value: 23456 }]), + ], + error: "Invalid path (trailing escape): gateway.port\\", + }, ])("$name", async ({ args, error, list }) => { if (list) { const resolved = { agents: { list } } as unknown as OpenClawConfig; @@ -3358,6 +3383,15 @@ describe("config cli", () => { expect(mockWriteConfigFile).not.toHaveBeenCalled(); }); + it.each(["gateway.port\\", "gateway.port\\ "])( + "rejects a trailing escape in shared config path %s", + (configPath) => { + expect(() => parseConfigSetPath(configPath)).toThrow( + `Invalid path (trailing escape): ${configPath}`, + ); + }, + ); + it.each([ "agents.list[0]id", "agents.list[0] id", @@ -3396,6 +3430,12 @@ describe("config cli", () => { ["agents.list[0].id", ["agents", "list", "0", "id"]], ["agents.list[0][1]", ["agents", "list", "0", "1"]], ["[0]", ["0"]], + [" gateway.port ", ["gateway", "port"]], + ["channels.discord.guilds.prod\\.guild", ["channels", "discord", "guilds", "prod.guild"]], + [ + "channels.discord.guilds.prod\\\\.channels", + ["channels", "discord", "guilds", "prod\\", "channels"], + ], ])("preserves valid bracket path %s", (configPath, expected) => { expect(parseConfigSetPath(configPath)).toEqual(expected); }); From 402bd4af01b7653d58e6b0364e143651df004e37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:17:24 -0700 Subject: [PATCH 066/239] fix(whatsapp): keep sends running when typing presence fails (#116739) Co-authored-by: Peter Steinberger --- extensions/whatsapp/src/send.test.ts | 34 ++++++++++++++++++++++++++++ extensions/whatsapp/src/send.ts | 10 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index a2746251a5f3..e625c381016b 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -142,6 +142,40 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined); }); + it.each([ + { name: "text", mediaUrl: undefined }, + { name: "media", mediaUrl: "/tmp/pic.jpg" }, + ])("still sends $name when composing presence fails", async ({ mediaUrl }) => { + const mediaBuffer = Buffer.from("img"); + if (mediaUrl) { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: mediaBuffer, + contentType: "image/jpeg", + kind: "image", + }); + } + sendComposingTo.mockRejectedValueOnce(new Error("presence update unavailable")); + + await expect( + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + ...(mediaUrl ? { mediaUrl } : {}), + }), + ).resolves.toEqual({ + messageId: "msg123", + toJid: "1555@s.whatsapp.net", + }); + + expect(sendComposingTo).toHaveBeenCalledWith("+1555"); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "hi", + mediaUrl ? mediaBuffer : undefined, + mediaUrl ? "image/jpeg" : undefined, + ); + }); + it("re-chunks after WhatsApp marker expansion", async () => { const onDeliveryResult = vi.fn(); await sendMessageWhatsApp("+1555", Array.from({ length: 8 }, () => "`x`").join(" "), { diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 660c448ea601..5ec2906e13bc 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -240,7 +240,15 @@ export async function sendMessageWhatsApp( logger.info({ jid: redactedJid, hasMedia }, "sending message"); if (!isWhatsAppNewsletterJid(jid)) { await active.assertSendReady?.(to); - await active.sendComposingTo(to); + try { + await active.sendComposingTo(to); + } catch (err) { + // Typing is optional; a failed chatstate update must not block the actual message. + logger.warn( + { err: String(err), jid: redactedJid }, + "failed to send composing presence; continuing message delivery", + ); + } } const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; From 58580fff2f9c5e23ac1a217286365530657ea599 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:18:51 +0800 Subject: [PATCH 067/239] fix(plugins): require explicit source external startup (#116759) --- src/plugins/channel-plugin-ids.test.ts | 77 ++++++++++++++++++++++ src/plugins/gateway-startup-plugin-plan.ts | 9 ++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 0381150bab4e..881c41805b54 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -211,6 +211,16 @@ function createManifestRegistryFixture(): PluginManifestRegistry { origin: "global", activation: { onStartup: true }, }, + { + id: "source-external-startup", + enabledByDefault: true, + activation: { onStartup: true }, + channels: ["source-external-channel"], + providers: ["source-external-provider"], + packageManifest: { + build: { bundledDist: false }, + }, + }, { id: "demo-config-startup", enabledByDefault: true, @@ -314,6 +324,7 @@ function createInstalledPluginRecordFixture( origin: record.origin, enabled: true, ...(record.enabledByDefault === true ? { enabledByDefault: true } : {}), + ...(record.packageManifest?.build ? { packageBuild: record.packageManifest.build } : {}), startup: { sidecar: record.activation?.onStartup === true, memory, @@ -1429,6 +1440,72 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("does not ambient-start source-discovered external plugins from onStartup alone", () => { + expectStartupPluginIds({ + config: createStartupConfig({ + noConfiguredChannels: true, + memorySlot: "none", + }), + expected: ["browser"], + }); + }); + + it.each([ + [ + "plugins.entries", + createStartupConfig({ + enabledPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["browser", "source-external-startup"], + ], + [ + "plugins.allow", + createStartupConfig({ + allowPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["source-external-startup"], + ], + ])( + "starts source-discovered external plugins explicitly selected through %s", + (_name, config, expected) => { + expectStartupPluginIds({ + config, + expected, + }); + }, + ); + + it.each([ + [ + "configured channel", + { + channels: { + "source-external-channel": { enabled: true }, + }, + plugins: { + slots: { memory: "none" }, + }, + } as OpenClawConfig, + ], + [ + "selected provider", + createStartupConfig({ + modelId: "source-external-provider/demo-model", + noConfiguredChannels: true, + memorySlot: "none", + }), + ], + ])("preserves %s activation for source-discovered external plugins", (_name, config) => { + expectStartupPluginIds({ + config, + expected: ["browser", "source-external-startup"], + }); + }); + it("loads explicit trusted policy plugins at startup", () => { expectStartupPluginIds({ config: createStartupConfig({ diff --git a/src/plugins/gateway-startup-plugin-plan.ts b/src/plugins/gateway-startup-plugin-plan.ts index 60fef360d28b..7bc0f462f7ff 100644 --- a/src/plugins/gateway-startup-plugin-plan.ts +++ b/src/plugins/gateway-startup-plugin-plan.ts @@ -418,9 +418,14 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginIds.push(plugin.pluginId); continue; } + const isSourceExternalPlugin = + plugin.origin === "bundled" && plugin.packageBuild?.bundledDist === false; + // Source checkout discovery still uses the bundled root, but source-only + // packages are externally owned and must keep the external explicit-startup policy. + const startupPolicyOrigin = isSourceExternalPlugin ? "workspace" : plugin.origin; const activationState = resolveEffectivePluginActivationState({ id: plugin.pluginId, - origin: plugin.origin, + origin: startupPolicyOrigin, config: pluginsConfig, rootConfig: params.config, enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), @@ -430,7 +435,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { continue; } if ( - plugin.origin !== "bundled" + startupPolicyOrigin !== "bundled" ? activationState.explicitlyEnabled : activationState.source === "explicit" || activationState.source === "default" ) { From 873bcc2985e786ed7903bb83a932f22a2a484320 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:21:31 -0700 Subject: [PATCH 068/239] fix(irc): recognize punctuation in nickname mentions (#116758) Co-authored-by: Peter Steinberger --- extensions/irc/src/inbound.behavior.test.ts | 64 +++++++++++++++++++++ extensions/irc/src/inbound.ts | 26 ++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/extensions/irc/src/inbound.behavior.test.ts b/extensions/irc/src/inbound.behavior.test.ts index 7a6d4f8ca3e2..1c2c23fe5e49 100644 --- a/extensions/irc/src/inbound.behavior.test.ts +++ b/extensions/irc/src/inbound.behavior.test.ts @@ -334,6 +334,70 @@ describe("irc inbound behavior", () => { expect(ctx?.OriginatingTo).toBe("channel:#ops"); }); + it.each([ + { label: "ordinary nick", nick: "OpenClaw", text: "OpenClaw: hello", mentioned: true }, + { label: "ASCII case folding", nick: "OpenClaw", text: "openclaw: hello", mentioned: true }, + { label: "leading bracket", nick: "[Claw]", text: "[Claw]: hello", mentioned: true }, + { label: "trailing bracket", nick: "Claw]", text: "hello Claw],", mentioned: true }, + { label: "leading caret", nick: "^Claw", text: "^Claw, hello", mentioned: true }, + { label: "trailing hyphen", nick: "Claw-", text: "Claw-: hello", mentioned: true }, + { label: "escaped backslash", nick: "\\Claw", text: "\\Claw: hello", mentioned: true }, + { label: "embedded brackets", nick: "Claw[Ops]", text: "Claw[Ops]: hi", mentioned: true }, + { label: "RFC1459 opening bracket", nick: "[Claw", text: "{claw: hello", mentioned: true }, + { label: "RFC1459 opening brace", nick: "{Claw", text: "[claw: hello", mentioned: true }, + { label: "RFC1459 closing bracket", nick: "Claw]", text: "claw}: hello", mentioned: true }, + { label: "RFC1459 closing brace", nick: "Claw}", text: "claw]: hello", mentioned: true }, + { label: "RFC1459 backslash", nick: "\\Claw", text: "|claw: hello", mentioned: true }, + { label: "RFC1459 vertical bar", nick: "|Claw", text: "\\claw: hello", mentioned: true }, + { label: "RFC1459 caret", nick: "^Claw", text: "~claw: hello", mentioned: true }, + { label: "RFC1459 tilde", nick: "~Claw", text: "^claw: hello", mentioned: true }, + { label: "ordinary nick suffix", nick: "Claw", text: "Clawbot: hello", mentioned: false }, + { label: "ordinary nick prefix", nick: "Claw", text: "overClaw: hello", mentioned: false }, + { label: "IRC nick punctuation suffix", nick: "Claw", text: "Claw-bot: hi", mentioned: false }, + { label: "RFC1459 tilde nick suffix", nick: "Claw", text: "Claw~bot: hi", mentioned: false }, + { + label: "punctuated nick inside a longer nick", + nick: "[Claw]", + text: "prefix[Claw]: hello", + mentioned: false, + }, + ])( + "recognizes only complete IRC nickname mentions: $label", + async ({ nick, text, mentioned }) => { + const coreRuntime = createPluginRuntimeMock(); + const runtime = createRuntimeEnv(); + setIrcRuntime(coreRuntime as never); + + await handleIrcInbound({ + message: createMessage({ + target: "#ops", + isGroup: true, + text, + }), + account: createAccount({ + nick, + config: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groupAllowFrom: [], + groups: { + "#ops": { enabled: true, requireMention: true }, + }, + }, + }), + config: { channels: { irc: {} } } as CoreConfig, + runtime, + sendReply: vi.fn(async () => {}), + }); + + expect(coreRuntime.channel.inbound.dispatch).toHaveBeenCalledTimes(mentioned ? 1 : 0); + if (!mentioned) { + expect(runtime.log).toHaveBeenCalledWith("irc: drop channel #ops (missing-mention)"); + } + }, + ); + it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => { const coreRuntime = createPluginRuntimeMock(); const runtime = createRuntimeEnv(); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 919630c59f6e..d15d01490293 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -81,6 +81,27 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({ }); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// IRC nicknames permit punctuation, so ASCII word boundaries lose valid leading/trailing chars. +const IRC_NICK_CHARACTER = String.raw`[A-Za-z0-9_\-\[\]\\\x60^{}|~]`; +const IRC_RFC1459_CASE_EQUIVALENTS = new Map([ + ["[", "{"], + ["{", "["], + ["]", "}"], + ["}", "]"], + ["\\", "|"], + ["|", "\\"], + ["^", "~"], + ["~", "^"], +]); + +function buildIrcNickMentionPattern(value: string): string { + return Array.from(value, (character) => { + const equivalent = IRC_RFC1459_CASE_EQUIVALENTS.get(character); + return equivalent + ? `[${escapeIrcRegexLiteral(character)}${escapeIrcRegexLiteral(equivalent)}]` + : escapeIrcRegexLiteral(character); + }).join(""); +} function isBareNick(value: string): boolean { return !value.includes("!") && !value.includes("@"); @@ -266,7 +287,10 @@ export async function handleIrcInbound(params: { const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); const mentionNick = connectedNick?.trim() || account.nick; const explicitMentionRegex = mentionNick - ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") + ? new RegExp( + `(? Date: Fri, 31 Jul 2026 18:27:14 +0800 Subject: [PATCH 069/239] fix(skills): allow autonomous sweep sync (#116755) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b6a2496a3a4e..c09f181ff90e 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,8 @@ USER.md !.agents/skills/graincrawl/** !.agents/skills/notcrawl/ !.agents/skills/notcrawl/** +!.agents/skills/openclaw-autonomous-issue-sweep/ +!.agents/skills/openclaw-autonomous-issue-sweep/** !.agents/skills/openclaw-changelog-update/ !.agents/skills/openclaw-changelog-update/** !.agents/skills/openclaw-ci-limits/ From 27fa5a8950f14dd4d1b8242fe062ddb3867c1155 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:36:55 -0700 Subject: [PATCH 070/239] docs(skill): require independent proof before issue closure --- .../openclaw-autonomous-issue-sweep/SKILL.md | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md index 6f2e2c3e4344..6f1b9a595781 100644 --- a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -17,6 +17,10 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. - Use full-history forks so every subagent inherits the orchestrator's model and **xhigh reasoning effort**. Never print, record, or disclose model identifiers; redact subprocess banners and diagnostics before reporting. +- Begin every full-history child assignment with its explicit role and agent + identity, require inherited **xhigh reasoning effort**, and forbid + `create_goal`, visualizations, `spawn_agent`, or nested agents. Children + return evidence to the orchestrator; never downgrade their model or effort. - Treat a request to run this workflow as authority to create lightweight, issue-scoped isolated Git worktrees and `codex/issue-` branches, review, fix, refactor, commit, push, create/update PRs, land eligible changes, @@ -67,6 +71,7 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. mutate the shared checkout while sibling workers are active. Once isolated worktrees exist, independent issue owners edit, inspect, and verify in parallel within their own checkout. + 6. Keep all **64** inherited high-effort agents available, but distinguish idle agents from active local tool users. Start with bounded waves of **4–8** concurrently active code/test workers and continuously reduce or expand that @@ -185,6 +190,74 @@ Choose outcomes in this order: - Do not edit `CHANGELOG.md`; capture user impact, issue/PR references, and human credit in the PR body or commit message. +## Hard issue-closure gate + +An issue stays open unless every step below passes. Similar wording, adjacent +tests, merged PR dates, contributor suggestions, and confident review summaries +are not closure proof. + +1. Write down the reporter's exact **primary symptom**, desired user-visible + outcome, every separately affected surface, reported version/build SHA, and + all proposed alternatives. An optional mitigation or diagnostic suggestion + does not replace the reported primary outcome. +2. Personally trace both shipped and current behavior end to end: entry point, + canonical owner, caller, callee, dependency contract, sibling surfaces, and + existing tests. Reproduce the exact reported failure on the affected build + and prove the same user action succeeds on current `main`. Use a runnable + product or boundary-level regression; a nearby unit test, revised error text, + or an unexecuted source inspection is insufficient. +3. Prove Git ancestry rather than inferring it from dates: + + ```bash + git merge-base --is-ancestor "$fix_sha" "$current_main_sha" + git merge-base --is-ancestor "$fix_sha" "$reported_build_or_tag_sha" + git tag --contains "$fix_sha" + ``` + + The fix must be an ancestor of current `main`. Compare it against **each** + affected exact build/tag, account for diverged release branches, and identify + the first containing release when known. A merge before a release date does + not prove inclusion in that release. If the fix was already in an affected + build, assume the report still reproduces until a later causal fix is proved. + +4. Classify the candidate honestly: root-cause repair, mitigation, diagnostic + improvement, unsupported contract, workaround, or product decision. Never + close because a suggested fallback landed if the primary action still fails, + any reported surface remains broken, an owner hold exists, or documented + behavior requires an unresolved maintainer/security/product decision. +5. Require a **different, independent subagent with inherited xhigh reasoning** + to challenge the investigator's closure packet. The challenger personally + verifies the primary outcome, every affected surface, runtime owner and + contract, release ancestry, and before/after proof. The investigator cannot + self-approve; only a separate authorized closure coordinator may grant the + mutation after both reviewers agree. Any disagreement means **leave open**. +6. Immediately recheck live GitHub state, labels/owner holds, current `main`, + and exact proof. Do not close on stale state, an incomplete source map, an + indirect main-only test, changed wording without changed behavior, or any + unresolved facet. In **one sentence**, the closure comment must state the + exact fixed behavior, fix SHA/PR, first containing version when known, and + before/after evidence. +7. If a closure is challenged or an incorrectly closed issue is reopened, + **pause all closure mutations**. Audit earlier closures, correct the public + record, reopen proven mistakes, and resume only after explicit root + authorization. Continue safe investigation and verified code-fix work. + +Required evidence map: + +```text +Primary symptom -> expected outcome -> every reported surface -> affected build/tag +Entry -> caller -> canonical owner -> callee -> dependency -> sibling -> boundary proof +Fix SHA -> current-main ancestry -> each affected-build ancestry -> containing release +Affected-build failure -> current-main success -> independent challenge -> coordinator grant +``` + +Reject example: a remote command fails because its explicit working directory +does not exist on the target host. A merged change that only replaces a vague +spawn error with an accurate invalid-directory diagnostic is useful, but the +command still fails. If the primary expected outcome is successful execution, +leave the issue open; changing that explicit-directory contract may need an +owner decision. + ## Verify behavior and obtain two independent reviews For every non-trivial production change: @@ -253,9 +326,9 @@ moves:` item with real evidence or an explicit reason for skipping it. - Keep owner/security/auth/config/public-SDK/protocol/persistent-state/product decisions outside autonomous landing when the relevant guide requires owner judgment. Continue with the next issue instead of blocking the whole sweep. -- Close a fixed issue only after live rechecking its open state and matching - the original symptoms to current-main proof. Cite the merged PR/commit and - ask the reporter to reopen if it still reproduces on the current version. +- Close a fixed issue only after the complete **Hard issue-closure gate**, + independent challenger sign-off, coordinator grant, and fresh live recheck. + Cite the exact causal PR/commit and first containing release when known. - Never close merely because a repro is difficult, the report is inconvenient, the behavior might be intentional, or the PR is stale. Product-decision and won't-implement closures require maintainer judgment. From 9b736a42c69b7d80f7cb3a1fe1950630b68011dd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:27:39 +0800 Subject: [PATCH 071/239] feat(models): expose tool support to clients --- .../Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ .../src/schema/agents-models-skills.ts | 1 + src/gateway/server-methods/models-list-result.ts | 5 ++++- src/gateway/server.models-voicewake-misc.test.ts | 10 ++++++++++ ui/src/api/types.ts | 1 + 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5b7fc1d99b87..025a6e632b97 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11956,6 +11956,7 @@ public struct ModelChoice: Codable, Sendable { public let available: Bool? public let contextwindow: Int? public let reasoning: Bool? + public let supportstools: Bool? public let agentruntime: [String: AnyCodable]? public let apikeysupported: Bool? public let input: [AnyCodable]? @@ -11968,6 +11969,7 @@ public struct ModelChoice: Codable, Sendable { available: Bool? = nil, contextwindow: Int? = nil, reasoning: Bool? = nil, + supportstools: Bool? = nil, agentruntime: [String: AnyCodable]? = nil, apikeysupported: Bool? = nil, input: [AnyCodable]? = nil) @@ -11979,6 +11981,7 @@ public struct ModelChoice: Codable, Sendable { self.available = available self.contextwindow = contextwindow self.reasoning = reasoning + self.supportstools = supportstools self.agentruntime = agentruntime self.apikeysupported = apikeysupported self.input = input @@ -11992,6 +11995,7 @@ public struct ModelChoice: Codable, Sendable { case available case contextwindow = "contextWindow" case reasoning + case supportstools = "supportsTools" case agentruntime = "agentRuntime" case apikeysupported = "apiKeySupported" case input diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index f5e2f5a9e45c..46a03dc47173 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -37,6 +37,7 @@ export const ModelChoiceSchema = closedObject({ available: Type.Optional(Type.Boolean()), contextWindow: Type.Optional(Type.Integer({ minimum: 1 })), reasoning: Type.Optional(Type.Boolean()), + supportsTools: Type.Optional(Type.Boolean()), agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), apiKeySupported: Type.Optional(Type.Boolean()), input: Type.Optional( diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 9b5941d76c9d..ecd75339299b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -60,7 +60,7 @@ type ModelsListView = ModelCatalogBrowseView; type ModelsListEntry = Pick< ModelCatalogEntry, "alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning" -> & { available?: boolean }; +> & { available?: boolean; supportsTools?: boolean }; type ModelsListEntryWithCapabilities = ModelsListEntry & { agentRuntime?: GatewayAgentRuntime; apiKeySupported?: boolean; @@ -96,6 +96,9 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { ...(entry.alias ? { alias: entry.alias } : {}), ...(contextWindow ? { contextWindow } : {}), ...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}), + ...(typeof entry.compat?.supportsTools === "boolean" + ? { supportsTools: entry.compat.supportsTools } + : {}), }; } diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index f6396a97f59b..90aed1058314 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -92,6 +92,7 @@ type ModelCatalogRpcEntry = { contextWindow?: number; input?: string[]; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: GatewayAgentRuntime; }; @@ -179,6 +180,7 @@ type ConfiguredProviderModelFixture = { name: string; alias: string; contextWindow: number; + supportsTools?: boolean; }; const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) => ({ @@ -200,6 +202,9 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) = id: params.modelId, name: params.name, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined + ? {} + : { compat: { supportsTools: params.supportsTools } }), }, ], }, @@ -213,6 +218,7 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture) alias: params.alias, provider: params.provider, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined ? {} : { supportsTools: params.supportsTools }), }); describe("gateway server models + voicewake", () => { @@ -362,6 +368,9 @@ describe("gateway server models + voicewake", () => { if (expected.contextWindow !== undefined) { expect(models[0]?.contextWindow).toBe(expected.contextWindow); } + if (expected.supportsTools !== undefined) { + expect(models[0]?.supportsTools).toBe(expected.supportsTools); + } }; test( @@ -757,6 +766,7 @@ describe("gateway server models + voicewake", () => { name: "Kimi K2.5 (Configured)", alias: "Kimi K2.5 (NVIDIA)", contextWindow: 32_000, + supportsTools: false, }, }, { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 6e64deaf133f..a958e9d65392 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -935,6 +935,7 @@ export type ModelCatalogEntry = { available?: boolean; contextWindow?: number; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime; input?: Array<"text" | "image" | "document">; apiKeySupported?: boolean; From 5b7f514c940c2e3bb84aded0350862ea3d0eac54 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:28:02 +0800 Subject: [PATCH 072/239] fix(chat): explain chat-only model limits --- .../run/attempt-system-prompt-prepare.ts | 14 +- .../attempt.spawn-workspace.test-support.ts | 3 +- .../embedded-agent-runner/run/attempt.ts | 1 + src/agents/model-tool-support.test.ts | 12 +- src/agents/model-tool-support.ts | 8 + ui/src/e2e/chat-only-model.e2e.test.ts | 168 ++++++++++++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/pages/chat/chat-view.test.ts | 39 ++++ .../chat/components/chat-model-controls.ts | 56 ++++-- ui/src/styles/chat/layout.css | 21 +++ ui/src/test-helpers/control-ui-e2e.ts | 2 + 11 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 ui/src/e2e/chat-only-model.e2e.test.ts diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index af866fe7ac50..b08aea516277 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -25,6 +25,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; +import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js"; import { buildProjectMemoryWriteInstruction, prepareProjectMemoryBootstrap, @@ -65,6 +66,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { getProviderRuntimeHandle: () => ProviderRuntimePluginHandle; isRawModelRun: boolean; markStage: (name: string) => void; + modelToolsEnabled: boolean; proactiveSubagentOrchestration: boolean; sandbox?: SandboxContext; sandboxSessionKey: string; @@ -273,6 +275,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { const projectMemoryWriteInstruction = buildProjectMemoryWriteInstruction( attempt.preparedModelRuntime?.projectKey, ); + const extraSystemPrompt = + [ + attempt.extraSystemPrompt, + projectMemoryWriteInstruction, + buildModelToolsUnavailablePrompt(params.modelToolsEnabled), + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n") || undefined; const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun: params.isRawModelRun, @@ -287,9 +297,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { workspaceDir: params.effectiveWorkspace, defaultThinkLevel: attempt.thinkLevel, reasoningLevel: attempt.reasoningLevel ?? "off", - extraSystemPrompt: projectMemoryWriteInstruction - ? [attempt.extraSystemPrompt, projectMemoryWriteInstruction].filter(Boolean).join("\n\n") - : attempt.extraSystemPrompt, + extraSystemPrompt, ownerNumbers: attempt.ownerNumbers, reasoningTagHint, heartbeatPrompt, diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index c30d400f03e9..06f4e1a7c4cc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -744,7 +744,8 @@ vi.mock("../../model-auth.js", () => ({ resolveModelAuthMode: () => undefined, })); -vi.mock("../../model-tool-support.js", () => ({ +vi.mock("../../model-tool-support.js", async (importOriginal) => ({ + ...(await importOriginal()), supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args), })); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1155d41528bd..96d512902376 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -292,6 +292,7 @@ export async function runEmbeddedAttempt( getProviderRuntimeHandle, isRawModelRun, markStage: (name) => prepStages.mark(name), + modelToolsEnabled: toolsEnabled, proactiveSubagentOrchestration, sandbox: sandbox ?? undefined, sandboxSessionKey, diff --git a/src/agents/model-tool-support.test.ts b/src/agents/model-tool-support.test.ts index d7f7b2db6e7b..1e3c39f4b5c0 100644 --- a/src/agents/model-tool-support.test.ts +++ b/src/agents/model-tool-support.test.ts @@ -1,6 +1,6 @@ // Documents model tool-support compatibility defaults. import { describe, expect, it } from "vitest"; -import { supportsModelTools } from "./model-tool-support.js"; +import { buildModelToolsUnavailablePrompt, supportsModelTools } from "./model-tool-support.js"; describe("supportsModelTools", () => { it("defaults to true when the model has no compat override", () => { @@ -15,3 +15,13 @@ describe("supportsModelTools", () => { expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false); }); }); + +describe("buildModelToolsUnavailablePrompt", () => { + it("tells chat-only models not to invent tool-backed work", () => { + expect(buildModelToolsUnavailablePrompt(true)).toBeUndefined(); + expect(buildModelToolsUnavailablePrompt(false)).toContain( + "Do not claim that you ran commands, read or wrote files, browsed the web, generated media", + ); + expect(buildModelToolsUnavailablePrompt(false)).toContain("switch to a tool-capable model"); + }); +}); diff --git a/src/agents/model-tool-support.ts b/src/agents/model-tool-support.ts index 742907d8cf38..2f0b57da3e2a 100644 --- a/src/agents/model-tool-support.ts +++ b/src/agents/model-tool-support.ts @@ -4,6 +4,9 @@ * Provider catalogs can opt a model out via `compat.supportsTools === false`; * absent metadata remains permissive for older catalog entries. */ +const MODEL_TOOLS_UNAVAILABLE_PROMPT = + "## Tool availability\n\nThis model cannot use tools in this run. Do not claim that you ran commands, read or wrote files, browsed the web, generated media, or performed any other tool-backed action. If a request requires tools, say they are unavailable in this chat and ask the user to switch to a tool-capable model."; + /** Returns whether a catalog model should be offered tool calls. */ export function supportsModelTools(model: { compat?: unknown }): boolean { const compat = @@ -12,3 +15,8 @@ export function supportsModelTools(model: { compat?: unknown }): boolean { : undefined; return compat?.supportsTools !== false; } + +/** Builds the bounded honesty guard for models that explicitly disable tools. */ +export function buildModelToolsUnavailablePrompt(modelToolsEnabled: boolean): string | undefined { + return modelToolsEnabled ? undefined : MODEL_TOOLS_UNAVAILABLE_PROMPT; +} diff --git a/ui/src/e2e/chat-only-model.e2e.test.ts b/ui/src/e2e/chat-only-model.e2e.test.ts new file mode 100644 index 000000000000..8e45decdaff6 --- /dev/null +++ b/ui/src/e2e/chat-only-model.e2e.test.ts @@ -0,0 +1,168 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const sessionKey = "agent:main:main"; +const proofDir = + process.env.OPENCLAW_CAPTURE_UI_PROOF === "1" + ? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model") + : null; + +const models = [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 200_000, + supportsTools: true, + }, +]; + +function sessionsList(model: string, modelProvider: string) { + return { + count: 1, + defaults: { + contextTokens: 32_768, + model: "qwen3-8b", + modelProvider: "lmstudio", + thinkingDefault: "off", + thinkingLevels: [{ id: "off", label: "off" }], + }, + path: "", + sessions: [ + { + contextTokens: 32_768, + displayName: "Local chat", + hasActiveRun: false, + key: sessionKey, + kind: "direct", + label: "Local chat", + model, + modelProvider, + status: "done", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +suite.define(() => { + it("explains chat-only models and keeps model switching as the recovery path", async () => { + if (proofDir) { + await mkdir(proofDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + agentModel: "lmstudio/qwen3-8b", + models, + sessionKey, + methodResponses: { + "sessions.list": sessionsList("qwen3-8b", "lmstudio"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + + const main = page.getByRole("main"); + const composer = main.locator(".agent-chat__composer-shell"); + const picker = composer.locator('[data-chat-model-select="true"]'); + const badge = picker.locator(".chat-controls__model-capability-badge"); + + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + await expect.poll(async () => (await badge.textContent())?.trim()).toBe("Chat only"); + await expect.poll(() => picker.getAttribute("aria-label")).toContain("Chat only"); + + if (proofDir) { + await composer.screenshot({ + animations: "disabled", + path: path.join(proofDir, "01-desktop-chat-only-composer.png"), + }); + } + + await picker.click(); + const localOption = composer.locator('[data-chat-model-option="lmstudio/qwen3-8b"]'); + const openAiOption = composer.locator('[data-chat-model-option="openai/gpt-5.5"]'); + await expect + .poll(async () => (await localOption.textContent())?.replace(/\s+/g, " ").trim()) + .toContain("32.8k context · Chat only"); + await expect + .poll(async () => (await openAiOption.textContent())?.includes("Chat only")) + .toBe(false); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "02-desktop-model-picker.png"), + }); + } + + await composer.locator('[data-chat-model-provider="openai"]').click(); + await openAiOption.click(); + const patch = await gateway.waitForRequest("sessions.patch"); + expect(patch.params).toMatchObject({ key: sessionKey, model: "openai/gpt-5.5" }); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("available"); + await expect.poll(() => badge.count()).toBe(0); + + const pickerDetails = composer.locator("details.chat-controls__model"); + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + await composer.locator('[data-chat-model-provider="lmstudio"]').click(); + await localOption.click(); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + if (await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open)) { + await picker.click(); + } + await page.setViewportSize({ height: 844, width: 390 }); + await expect.poll(() => picker.isVisible()).toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "03-mobile-chat-only-model.png"), + }); + } + + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + const menu = composer.locator(".chat-controls__inline-select-menu--combined"); + await expect + .poll(async () => { + const box = await menu.boundingBox(); + return box !== null && box.x >= 0 && box.x + box.width <= 390; + }) + .toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "04-mobile-model-picker.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 544ff72a38ca..117a25cfc816 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4902,6 +4902,9 @@ export const en: TranslationMap = { fastHelp: "Fast responses finish sooner and can use more of your usage limits.", speedUnsupported: "Speed control is not supported for this model.", contextWindow: "{count} context", + chatOnly: "Chat only", + chatOnlyHelp: + "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", providerModels: "{provider} models", resetReasoning: "Reset to default ({level})", useDefaultReasoning: "Use default reasoning ({level})", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 17fd51fc7b3b..4374dadd83ea 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -5352,6 +5352,45 @@ describe("chat model controls", () => { expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); }); + it("marks chat-only models in the active control and picker", () => { + const { state } = createChatHeaderState({ + model: "qwen3-8b", + modelProvider: "lmstudio", + models: [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + supportsTools: true, + }, + ], + }); + const container = renderModelControls(state); + const trigger = getChatModelSelect(container); + + expect(trigger.dataset.chatModelTools).toBe("unavailable"); + expect( + trigger.querySelector(".chat-controls__model-capability-badge")?.textContent?.trim(), + ).toBe("Chat only"); + expect(trigger.getAttribute("aria-label")).toContain("Chat only"); + expect( + container + .querySelector('[data-chat-model-option="lmstudio/qwen3-8b"]') + ?.querySelector(".chat-controls__model-option-meta") + ?.textContent?.trim(), + ).toBe("32.8k context · Chat only"); + expect( + container.querySelector('[data-chat-model-option="openai/gpt-5.5"]')?.textContent, + ).not.toContain("Chat only"); + }); + it("shows canonical OpenAI model names instead of command aliases", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 406eda8e8160..838ca4164a76 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & { contextWindow?: number; isDefault: boolean; provider: string; + supportsTools?: boolean; }; const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly> = { @@ -210,6 +211,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) { return { commitValue: isDefault ? "" : option.value, ...(catalogEntry?.contextWindow ? { contextWindow: catalogEntry.contextWindow } : {}), + ...(typeof catalogEntry?.supportsTools === "boolean" + ? { supportsTools: catalogEntry.supportsTools } + : {}), isDefault, value: option.value, label: resolveChatModelPickerLabel(option.value, option.label, props.modelCatalog), @@ -403,8 +407,21 @@ function renderChatModelReasoningSelect(params: { } = params; const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel); const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel); - const triggerTitle = `${triggerModel} · ${triggerThinking}`; - const triggerLabel = triggerTitle; + const defaultModelOption = modelOptions.find((option) => option.isDefault); + const activeModelOption = + selectedModelValue === "" + ? defaultModelOption + : modelOptions.find((option) => option.value === selectedModelValue); + const selectedModelOption = activeModelOption ?? modelOptions[0]; + const modelToolsUnavailable = activeModelOption?.supportsTools === false; + const triggerTitle = [ + triggerModel, + triggerThinking, + modelToolsUnavailable ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); + const triggerLabel = `${triggerModel} · ${triggerThinking}`; const sliderStops = thinkingOptions.filter((option) => option.value !== ""); const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); const hasThinkingOverride = selectedThinkingValue !== ""; @@ -530,7 +547,6 @@ function renderChatModelReasoningSelect(params: { providerGroups.set(option.provider, [option]); } } - const defaultModelOption = modelOptions.find((option) => option.isDefault); const orderedProviderGroups = [...providerGroups]; const defaultProviderIndex = orderedProviderGroups.findIndex( ([provider]) => provider === defaultModelOption?.provider, @@ -541,21 +557,22 @@ function renderChatModelReasoningSelect(params: { orderedProviderGroups.unshift(defaultProviderGroup); } } - const selectedModelOption = - (selectedModelValue === "" - ? defaultModelOption - : modelOptions.find((option) => option.value === selectedModelValue)) ?? modelOptions[0]; const selectedProvider = selectedModelOption?.provider ?? orderedProviderGroups[0]?.[0] ?? "other"; const renderModelOption = (entry: ChatModelProviderOption) => { const selected = entry.value === selectedModelValue || (entry.isDefault && selectedModelValue === ""); const modelLabel = formatCombinedPickerModelOptionLabel(entry); - const contextLabel = entry.contextWindow - ? t("chat.modelControls.contextWindow", { - count: formatCompactTokenCount(entry.contextWindow), - }) - : ""; + const modelMeta = [ + entry.contextWindow + ? t("chat.modelControls.contextWindow", { + count: formatCompactTokenCount(entry.contextWindow), + }) + : "", + entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); return html`
+ ` + : nothing} +
+ `; +} diff --git a/ui/src/e2e/model-alias-display.e2e.test.ts b/ui/src/e2e/model-alias-display.e2e.test.ts index 95a176352487..01f106a4a082 100644 --- a/ui/src/e2e/model-alias-display.e2e.test.ts +++ b/ui/src/e2e/model-alias-display.e2e.test.ts @@ -143,8 +143,9 @@ suite.define(() => { expect(response?.status()).toBe(200); await gateway.waitForRequest("agents.list"); await gateway.waitForRequest("config.get"); - const modelRequest = await gateway.waitForRequest("models.list"); - expect(modelRequest.params).toEqual({ view: "configured" }); + const modelRequest = await gateway.waitForRequest("chat.metadata"); + expect(modelRequest.params).toEqual({ agentId: "main" }); + expect(await gateway.getRequests("models.list")).toHaveLength(0); const select = page.locator("select.settings-select").first(); await select.waitFor({ state: "visible", timeout: 10_000 }); diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 9c9a4e167f02..fc2bb748024d 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -5,12 +5,13 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsFilesListResult, AgentsListResult, + CronJob, ModelCatalogEntry, ToolsEffectiveResult, } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import type { AgentsPanel } from "../../lib/agents/panels.ts"; -import * as chatModels from "../chat/models.ts"; +import { loadCronJobsPage, type CronState } from "../../lib/cron/index.ts"; import type { AgentsRouteData } from "./route.ts"; import "./agents-page.ts"; @@ -30,7 +31,9 @@ type TestAgentsPage = HTMLElement & { toolsEffectiveLoading: boolean; toolsEffectiveResult: ToolsEffectiveResult | null; chatModelCatalog: ModelCatalogEntry[]; + chatModelCatalogError: string | null; chatModelCatalogRequest: unknown; + cron: CronState; requestGeneration: number; routeDataInitialized: boolean; subscriptions: { @@ -42,6 +45,9 @@ type TestAgentsPage = HTMLElement & { applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot, sourceChanged: boolean) => void; ensureAgentIdentities: () => void; loadActivePanelData: () => void; + refreshCron: () => Promise; + requestUpdate: () => void; + runCronTask: (task: (cronState: CronState) => Promise) => Promise; loadEffectiveToolsForAgent: (agentId: string) => void; loadAgentFiles: (agentId: string, force?: boolean) => Promise; }; @@ -82,6 +88,21 @@ function files(agentId: string, workspace: string): AgentsFilesListResult { return { agentId, workspace, files: [] }; } +function cronJob(id: string, agentId?: string): CronJob { + return { + id, + ...(agentId ? { agentId } : {}), + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + } as CronJob; +} + const agentsList: AgentsListResult = { defaultId: "main", mainKey: "main", @@ -139,7 +160,7 @@ function pageContext( } describe("AgentsPage gateway lifecycle", () => { - it("loads the configured model catalog once for the overview model picker", async () => { + it("loads the selected agent's configured model catalog once for the overview model picker", async () => { const models = [ { id: "claude-opus-4-8", @@ -160,7 +181,71 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); expect(request).toHaveBeenCalledOnce(); - expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }); + expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }); + }); + + it("caches separate configured model catalogs for the default and worker agents", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const request = vi.fn(async (_method: string, params?: { agentId?: string }) => ({ + models: params?.agentId === "worker" ? workerModels : defaultModels, + })); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(defaultModels)); + + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + + page.agentsSelectedId = "main"; + page.loadActivePanelData(); + expect(page.chatModelCatalog).toEqual(defaultModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "chat.metadata", { agentId: "main" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); + }); + + it("rejects a stale default-agent catalog after switching to a worker agent", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const defaultResult = deferred<{ models: ModelCatalogEntry[] }>(); + const request = vi.fn((_method: string, params?: { agentId?: string }) => + params?.agentId === "worker" + ? Promise.resolve({ models: workerModels }) + : defaultResult.promise, + ); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + defaultResult.resolve({ models: defaultModels }); + await defaultResult.promise; + await Promise.resolve(); + + expect(page.chatModelCatalog).toEqual(workerModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); }); it("rejects an old-client model catalog after the Gateway client changes", async () => { @@ -215,7 +300,7 @@ describe("AgentsPage gateway lifecycle", () => { expect(page.chatModelCatalog).toEqual(nextModels); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); it("refreshes a settled model catalog after a same-client reconnect", async () => { @@ -242,34 +327,238 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(nextModels)); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); - it("handles a model catalog failure and retries without a stale request", async () => { + it("surfaces a rejected agent-scoped metadata RPC and retries without marking an empty catalog loaded", async () => { const models = [{ id: "new", name: "Opus 4.8", alias: "opus", provider: "anthropic" }]; - const loadModels = vi - .spyOn(chatModels, "loadModels") + const request = vi + .fn() .mockRejectedValueOnce(new Error("model catalog unavailable")) - .mockResolvedValueOnce(models); + .mockResolvedValueOnce({ models }); const page = document.createElement("openclaw-agents-page") as TestAgentsPage; page.routeData = { panel: "overview" } as AgentsRouteData; - page.client = { request: vi.fn() } as unknown as GatewayBrowserClient; + page.client = { request } as unknown as GatewayBrowserClient; page.connected = true; page.agentsSelectedId = "main"; - try { - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalogRequest).toBeNull()); - expect(page.chatModelCatalog).toEqual([]); + page.loadActivePanelData(); + await vi.waitFor(() => { + expect(page.chatModelCatalogError).toBe("model catalog unavailable"); + expect(page.chatModelCatalogRequest).toBeNull(); + }); + expect(page.chatModelCatalog).toEqual([]); - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); - expect(loadModels).toHaveBeenCalledTimes(2); - expect(loadModels).toHaveBeenLastCalledWith(page.client, { refresh: true }); - } finally { - loadModels.mockRestore(); - } + expect(page.chatModelCatalogError).toBeNull(); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); + }); + + it("requests the selected agent's implicit default cron job before the first 50 unrelated jobs", async () => { + const unrelatedJobs = Array.from({ length: 50 }, (_, index) => + cronJob(`other-${index}`, "other"), + ); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const implicitDefaultJob = { + ...cronJob("default-job"), + state: { nextRunAtMs: scopedNextWakeAtMs }, + }; + const request = vi.fn(async (method: string, params?: { agentId?: string; limit?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }; + } + if (method === "cron.list") { + const scoped = params?.agentId === "main"; + return { + jobs: scoped ? [implicitDefaultJob] : unrelatedJobs, + total: scoped ? 1 : 51, + offset: 0, + hasMore: !scoped, + }; + } + throw new Error(`Unexpected gateway method: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([implicitDefaultJob]); + expect(page.cron.cronScopedTotal).toBe(1); + expect(page.cron.cronScopedNextWakeAtMs).toBe(scopedNextWakeAtMs); + }); + expect(page.cron.cronStatus).toEqual({ + enabled: true, + jobs: 51, + nextWakeAtMs: globalNextWakeAtMs, + }); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 0 }), + ); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 1, enabled: "enabled" }), + ); + }); + + it("loads the selected agent's remaining cron jobs after preserving the first-page total", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => cronJob(`main-${index}`, "main")); + const lastJob = cronJob("main-50", "main"); + const request = vi.fn( + async (method: string, params?: { agentId?: string; limit?: number; offset?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 80, nextWakeAtMs: null }; + } + if (params?.limit === 1) { + return { jobs: [jobs[0]], total: 51 }; + } + if (params?.offset === 50) { + return { jobs: [lastJob], total: 51, offset: 50, nextOffset: null, hasMore: false }; + } + return { jobs, total: 51, offset: 0, nextOffset: 50, hasMore: true }; + }, + ); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toHaveLength(50); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + }); + expect(page.cron.cronJobsHasMore).toBe(true); + + await page.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ); + + expect(page.cron.cronJobs).toHaveLength(51); + expect(page.cron.cronJobs.at(-1)).toEqual(lastJob); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + expect(page.cron.cronJobsHasMore).toBe(false); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 50 }), + ); + }); + + it("reloads cron jobs when the selected agent changes", async () => { + const request = vi.fn(async (method: string, params?: { agentId?: string }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 2, nextWakeAtMs: null }; + } + return { jobs: [cronJob(`${params?.agentId}-job`, params?.agentId)], total: 1 }; + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("main-job")); + + page.agentsSelectedId = "other"; + page.loadActivePanelData(); + expect(page.cron.cronJobs).toEqual([]); + + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("other-job")); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "other" }), + ); + }); + + it("keeps an in-flight scoped cron request attached to a same-client gateway snapshot", async () => { + const job = cronJob("same-client-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronLoading).toBe(true)); + const inFlightState = page.cron; + + page.applyGatewaySnapshot(snapshot(client), false); + expect(page.cron).toBe(inFlightState); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([job]); + expect(page.cron.cronLoading).toBe(false); + }); + }); + + it("immediately publishes cron loading and ignores a second refresh while the first is pending", async () => { + const job = cronJob("double-refresh-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.client = client; + page.connected = true; + page.cron = { ...page.cron, client, connected: true, cronAgentId: "main" }; + const requestUpdate = vi.spyOn(page, "requestUpdate"); + + const firstRefresh = page.refreshCron(); + expect(page.cron.cronLoading).toBe(true); + expect(requestUpdate).toHaveBeenCalled(); + + await page.refreshCron(); + expect( + request.mock.calls.filter( + ([method, params]) => method === "cron.list" && params?.limit === 50, + ), + ).toHaveLength(1); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await firstRefresh; + + expect(page.cron.cronLoading).toBe(false); + expect(page.cron.cronJobs).toEqual([job]); }); it("preserves matching initial route data, then resets it on provider replacement", () => { diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 8061ff625e87..706d7ed7b7bb 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -36,14 +36,15 @@ import { currentConfigObject, findAgentConfigEntryIndex } from "../../lib/config import { createInitialCronState, loadCronJobsPage, + loadCronScopeStats, loadCronStatus, runCronJob, + type CronState, } from "../../lib/cron/index.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { normalizeStringEntries } from "../../lib/string-coerce.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { loadModels } from "../chat/models.ts"; import { loadAgentFileContent, saveAgentFile } from "./files.ts"; import { resetIdentityDraft, @@ -91,6 +92,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { @state() toolsEffectiveError: string | null = null; @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; @state() chatModelCatalog: ModelCatalogEntry[] = []; + @state() chatModelCatalogError: string | null = null; @state() agentFilesLoading = false; @state() agentFilesError: string | null = null; @state() agentFilesList: AgentsFilesListResult | null = null; @@ -121,10 +123,12 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private hasBoundSessions = false; private sessionsSource: ApplicationContext["sessions"] | null = null; private chatModelCatalogClient: GatewayBrowserClient | null = null; - private chatModelCatalogRefreshRequired = false; + private chatModelCatalogAgentId: string | null = null; + private readonly chatModelCatalogByAgentId = new Map(); private chatModelCatalogRequest: { client: GatewayBrowserClient; generation: number; + agentId: string; } | null = null; private normalizedLocation = ""; private readonly subscriptions = new SubscriptionsController(this) @@ -290,12 +294,13 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.syncGatewayState(snapshot); if (forceReset || (!initialBind && clientChanged)) { this.resetForClientChange(); - this.chatModelCatalogRefreshRequired = forceReset && !clientChanged; } else if (!initialBind && connectionChanged) { this.invalidateTransientRequests(); this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; } this.ensureInitialData(); } @@ -303,11 +308,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private syncGatewayState(snapshot: ApplicationGatewaySnapshot) { this.client = snapshot.client; this.connected = snapshot.phase === "connected"; - this.cron = { - ...this.cron, - client: snapshot.client, - connected: snapshot.phase === "connected", - }; + if (this.cron.client !== this.client || this.cron.connected !== this.connected) { + // In-flight cron loaders mutate their captured state; same-client + // snapshots must retain it or loading never clears in the visible state. + this.cron = { ...this.cron, client: this.client, connected: this.connected }; + } } private syncAgentState(agents = this.context.agents) { @@ -360,12 +365,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.agentsSelectedId = null; this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = false; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; this.resetSelectionState(); - this.cron = createInitialCronState({ - client: this.client, - connected: this.connected, - }); } private resetForAgentsSourceChange() { @@ -553,39 +556,68 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { void this.context.channels.refresh(false); return; } - if (this.agentsPanel === "cron" && !this.cron.cronLoading && !this.cron.cronStatus) { - void this.refreshCron(); + if (this.agentsPanel === "cron") { + if (this.cron.cronAgentId !== agentId) { + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); + this.cron.cronAgentId = agentId; + } + if (!this.cron.cronLoading && !this.cron.cronStatus) { + void this.refreshCron(); + } } } private ensureModelCatalog() { const client = this.client; - if (!client || !this.connected || this.chatModelCatalogClient === client) { + const agentId = this.resolveSelectedAgentId(); + if (!client || !this.connected || !agentId) { return; } + if (this.chatModelCatalogClient === client) { + const cached = this.chatModelCatalogByAgentId.get(agentId); + if (cached) { + this.chatModelCatalog = cached; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogError = null; + return; + } + } const generation = this.requestGeneration; const previousRequest = this.chatModelCatalogRequest; - if (previousRequest?.client === client && previousRequest.generation === generation) { + if ( + previousRequest?.client === client && + previousRequest.generation === generation && + previousRequest.agentId === agentId + ) { return; } - const request = { client, generation }; + if (this.chatModelCatalogAgentId !== agentId) { + this.chatModelCatalog = []; + } + const request = { client, generation, agentId }; this.chatModelCatalogRequest = request; - const refresh = this.chatModelCatalogRefreshRequired || previousRequest?.client === client; - this.chatModelCatalogRefreshRequired = false; - // A direct overview has no chat metadata. Refresh after reconnect so neither - // an in-flight request nor a settled cache can restore stale Gateway models. - void loadModels(client, refresh ? { refresh: true } : undefined) - .then((models) => { - if (this.isCurrentRequest(client, generation)) { + this.chatModelCatalogError = null; + // Only chat metadata projects the selected agent's private provider/auth + // scope; models.list always resolves against the default agent. + void client + .request<{ models?: ModelCatalogEntry[] }>("chat.metadata", { agentId }) + .then((result) => { + if (this.isCurrentRequest(client, generation, agentId)) { + const models = result.models ?? []; this.chatModelCatalog = models; this.chatModelCatalogClient = client; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogByAgentId.set(agentId, models); + this.chatModelCatalogError = null; } }) - .catch(() => { - if (this.isCurrentRequest(client, generation)) { - this.chatModelCatalog = []; - this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + .catch((error: unknown) => { + if (this.isCurrentRequest(client, generation, agentId)) { + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = error instanceof Error ? error.message : String(error); } }) .finally(() => { @@ -644,15 +676,28 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private async refreshCron() { const cronState = this.cron; - if (!cronState.connected || !cronState.client) { + if (!cronState.connected || !cronState.client || cronState.cronLoading) { return; } await Promise.all([ - loadCronStatus(cronState), - loadCronJobsPage(cronState, { tableFilters: true }), + this.runCronTask((current) => loadCronStatus(current)), + this.runCronTask((current) => loadCronScopeStats(current)), + this.runCronTask((current) => loadCronJobsPage(current, { tableFilters: true })), ]); - if (this.cron === cronState) { - this.cron = { ...cronState, cronJobs: [...cronState.cronJobs] }; + } + + private async runCronTask(task: (cronState: CronState) => Promise): Promise { + const cronState = this.cron; + try { + const result = task(cronState); + if (this.cron === cronState) { + this.requestUpdate(); + } + return await result; + } finally { + if (this.cron === cronState) { + this.requestUpdate(); + } } } @@ -680,6 +725,9 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private resetSelectionState() { this.requestGeneration += 1; + this.chatModelCatalog = []; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = null; this.agentFilesList = null; this.agentFilesError = null; this.agentFileActive = null; @@ -699,6 +747,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.toolsCatalogLoading = false; this.toolsCatalogLoadingAgentId = null; resetToolsEffectiveState(this); + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); } private findAgentIndex(agentId: string) { @@ -794,9 +846,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { if (!this.cron.cronJobs.some((entry) => entry.id === jobId)) { return; } - void runCronJob(this.cron, jobId, "force").finally(() => { - this.cron = { ...this.cron, cronJobs: [...this.cron.cronJobs] }; - }); + void this.runCronTask((cronState) => runCronJob(cronState, jobId, "force")); } override render() { @@ -836,6 +886,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { cron: { status: this.cron.cronStatus, jobs: this.cron.cronJobs, + jobsTotal: this.cron.cronJobsTotal, + jobsHasMore: this.cron.cronJobsHasMore, + jobsLoadingMore: this.cron.cronJobsLoadingMore, + scopedTotal: this.cron.cronScopedTotal, + scopedNextWakeAtMs: this.cron.cronScopedNextWakeAtMs, loading: this.cron.cronLoading, error: this.cron.cronError, }, @@ -874,6 +929,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { runtimeSessionKey: this.sessionKey, runtimeSessionMatchesSelectedAgent: selectedAgentId === this.chatAgentId(), modelCatalog: this.chatModelCatalog, + modelCatalogError: this.chatModelCatalogError, pinnedAgentIds: this.context.navigation.snapshot.pinnedAgentIds, onTogglePinnedAgent: (agentId) => togglePinnedAgent(this.context.navigation, agentId), onRefresh: () => this.refreshAgents(), @@ -947,6 +1003,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { onOpenMemorySettings: () => this.context.navigate("memory"), onOpenAgentDefaults: () => this.context.navigate("ai-agents"), onCronRefresh: () => void this.refreshCron(), + onCronLoadMore: () => + void this.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ), onCronRunNow: (jobId) => this.runCronJobNow(jobId), onSkillsFilterChange: (next) => (this.skillsFilter = next), onSkillsRefresh: () => { @@ -994,6 +1054,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { stageAgentPrimaryModel(this.context.runtimeConfig, agentId, modelId); void refreshVisibleToolsEffectiveForCurrentSession(this); }, + onModelCatalogRetry: () => this.ensureModelCatalog(), onModelFallbacksChange: (agentId, fallbacks) => stageAgentModelFallbacks(this.context.runtimeConfig, agentId, fallbacks), onSetDefault: (agentId) => { diff --git a/ui/src/pages/agents/agents-view.test-helpers.ts b/ui/src/pages/agents/agents-view.test-helpers.ts new file mode 100644 index 000000000000..efaa1c020413 --- /dev/null +++ b/ui/src/pages/agents/agents-view.test-helpers.ts @@ -0,0 +1,114 @@ +import type { renderAgents } from "./view.ts"; + +type AgentsViewProps = Parameters[0]; + +export function createAgentViewTestProps( + overrides: Partial = {}, +): AgentsViewProps { + return { + basePath: "", + authToken: null, + loading: false, + error: null, + agentsList: { + defaultId: "alpha", + mainKey: "main", + scope: "workspace", + agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], + }, + selectedAgentId: "beta", + activePanel: "overview", + config: { + form: null, + loading: false, + saving: false, + dirty: false, + }, + channels: { + snapshot: null, + loading: false, + error: null, + lastSuccess: null, + }, + cron: { + status: null, + jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: null, + scopedNextWakeAtMs: null, + loading: false, + error: null, + }, + agentFiles: { + list: null, + loading: false, + error: null, + active: null, + contents: {}, + drafts: {}, + saving: false, + }, + agentIdentityLoading: false, + agentIdentityError: null, + agentIdentityById: {}, + identityDraft: { name: null, emoji: null, avatar: null }, + identitySaving: false, + identityError: null, + agentSkills: { + report: null, + loading: false, + error: null, + agentId: null, + filter: "", + }, + toolsCatalog: { + loading: false, + error: null, + result: null, + }, + toolsEffective: { + loading: false, + error: null, + result: null, + }, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: false, + modelCatalog: [], + modelCatalogError: null, + pinnedAgentIds: [], + onRefresh: () => undefined, + onSelectAgent: () => undefined, + onCreateAgent: () => undefined, + onSelectPanel: () => undefined, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + onToolsProfileChange: () => undefined, + onToolsOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + onModelChange: () => undefined, + onModelFallbacksChange: () => undefined, + onModelCatalogRetry: () => undefined, + onChannelsRefresh: () => undefined, + onCronRefresh: () => undefined, + onCronLoadMore: () => undefined, + onCronRunNow: () => undefined, + onSkillsFilterChange: () => undefined, + onSkillsRefresh: () => undefined, + onAgentSkillToggle: () => undefined, + onAgentSkillsClear: () => undefined, + onAgentSkillsDisableAll: () => undefined, + onSetDefault: () => undefined, + onIdentityFieldChange: () => undefined, + onIdentityAvatarSelect: () => undefined, + onIdentitySave: () => undefined, + onTogglePinnedAgent: () => undefined, + onOpenAgentDefaults: () => undefined, + ...overrides, + }; +} diff --git a/ui/src/pages/agents/panels-overview.ts b/ui/src/pages/agents/panels-overview.ts index 16dfb4cef6d4..04f7c7db1589 100644 --- a/ui/src/pages/agents/panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -6,6 +6,7 @@ import type { AgentsListResult, ModelCatalogEntry, } from "../../api/types.ts"; +import { renderPanelRefreshStatus } from "../../components/panel-refresh-status.ts"; import { renderSettingsRow, renderSettingsSection } from "../../components/settings-ui.ts"; import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; @@ -46,6 +47,7 @@ export function renderAgentOverview(params: { configSaving: boolean; configDirty: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; onConfigReload: () => void; onConfigSave: () => void; onIdentityFieldChange: (field: "name" | "emoji", value: string) => void; @@ -53,6 +55,7 @@ export function renderAgentOverview(params: { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onSelectPanel: (panel: AgentsPanel) => void; }) { const { @@ -275,6 +278,14 @@ export function renderAgentOverview(params: { `, }, html` + ${renderPanelRefreshStatus({ + status: { + error: params.modelCatalogError, + hasLoaded: params.modelCatalog.length > 0, + stale: Boolean(params.modelCatalogError && params.modelCatalog.length > 0), + }, + onRetry: params.onModelCatalogRetry, + })} ${renderSettingsRow({ title: isDefault ? t("agents.overview.primaryModelDefault") diff --git a/ui/src/pages/agents/panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts index c280a263bc4c..51d8aabc98ac 100644 --- a/ui/src/pages/agents/panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -12,6 +12,7 @@ import type { CronJob, CronStatus, } from "../../api/types.ts"; +import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { renderHubTabs } from "../../components/hub-tabs.ts"; import { icons } from "../../components/icons.ts"; import "../../components/modal-dialog.ts"; @@ -296,14 +297,19 @@ export function renderAgentCron(params: { context: AgentContext; agentId: string; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; onRefresh: () => void; + onLoadMore: () => void; onRunNow: (jobId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; }) { - const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html` ${renderAgentContextSection( params.context, @@ -334,11 +340,13 @@ export function renderAgentCron(params: { })} ${renderSettingsRow({ title: t("agents.cronPanel.jobs"), - control: renderSettingsValue(params.status?.jobs ?? t("common.na")), + control: renderSettingsValue(params.scopedTotal ?? t("common.na")), })} ${renderSettingsRow({ title: t("agents.cronPanel.nextWake"), - control: renderSettingsValue(formatNextRun(params.status?.nextWakeAtMs ?? null)), + control: renderSettingsValue( + formatNextRun(params.status?.enabled === false ? null : params.scopedNextWakeAtMs), + ), })} `, )} @@ -347,34 +355,44 @@ export function renderAgentCron(params: { title: t("agents.cronPanel.agentJobsTitle"), description: t("agents.cronPanel.agentJobsSubtitle"), }, - jobs.length === 0 + params.jobs.length === 0 ? renderSettingsEmpty(t("agents.cronPanel.noJobs")) - : jobs.map((job) => { - const metaParts = [ - job.description, - formatCronSchedule(job), - job.sessionTarget, - formatCronState(job), - formatCronPayload(job), - ].filter(Boolean); - return renderSettingsRow({ - title: job.name, - description: metaParts.join(" · "), - control: html` - ${renderSettingsStatus({ - kind: job.enabled ? "ok" : "warn", - label: job.enabled ? t("common.enabled") : t("common.disabled"), - })} - - `, - }); - }), + : html` + ${params.jobs.map((job) => { + const metaParts = [ + job.description, + formatCronSchedule(job), + job.sessionTarget, + formatCronState(job), + formatCronPayload(job), + ].filter(Boolean); + return renderSettingsRow({ + title: job.name, + description: metaParts.join(" · "), + control: html` + ${renderSettingsStatus({ + kind: job.enabled ? "ok" : "warn", + label: job.enabled ? t("common.enabled") : t("common.disabled"), + })} + + `, + }); + })} + ${renderCronJobsPagination({ + jobsShown: params.jobs.length, + jobsTotal: params.jobsTotal, + hasMore: params.jobsHasMore, + loading: params.loading, + loadingMore: params.jobsLoadingMore, + onLoadMore: params.onLoadMore, + })} + `, )} `; } diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 61c41c1e294b..3b077c10d303 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -1,14 +1,16 @@ // Control UI tests cover agents behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { ChannelAccountSnapshot } from "../../api/types.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ChannelAccountSnapshot, CronJob } from "../../api/types.ts"; import { i18n, t } from "../../i18n/index.ts"; +import { createInitialCronState, loadCronJobsPage } from "../../lib/cron/index.ts"; +import { formatNextRun } from "../../lib/presenter.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import { createAgentViewTestProps as createProps } from "./agents-view.test-helpers.ts"; import { renderAgentChannels, renderAgentFiles } from "./panels-status-files.ts"; import { renderAgents } from "./view.ts"; -type AgentsProps = Parameters[0]; - function createSkill() { return { name: "Repo Skill", @@ -40,6 +42,21 @@ function createSkill() { }; } +function createCronJob(id: string, overrides: Partial = {}): CronJob { + return { + id, + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + ...overrides, + } as CronJob; +} + function directText(element: Element | null | undefined): string | undefined { return Array.from(element?.childNodes ?? []) .filter((node) => node.nodeType === Node.TEXT_NODE) @@ -58,107 +75,6 @@ function expectAgentTab(container: Element, text: string): HTMLElement & { disab return button; } -function createProps(overrides: Partial = {}): AgentsProps { - return { - basePath: "", - authToken: null, - loading: false, - error: null, - agentsList: { - defaultId: "alpha", - mainKey: "main", - scope: "workspace", - agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], - }, - selectedAgentId: "beta", - activePanel: "overview", - config: { - form: null, - loading: false, - saving: false, - dirty: false, - }, - channels: { - snapshot: null, - loading: false, - error: null, - lastSuccess: null, - }, - cron: { - status: null, - jobs: [], - loading: false, - error: null, - }, - agentFiles: { - list: null, - loading: false, - error: null, - active: null, - contents: {}, - drafts: {}, - saving: false, - }, - agentIdentityLoading: false, - agentIdentityError: null, - agentIdentityById: {}, - identityDraft: { name: null, emoji: null, avatar: null }, - identitySaving: false, - identityError: null, - agentSkills: { - report: null, - loading: false, - error: null, - agentId: null, - filter: "", - }, - toolsCatalog: { - loading: false, - error: null, - result: null, - }, - toolsEffective: { - loading: false, - error: null, - result: null, - }, - runtimeSessionKey: "main", - runtimeSessionMatchesSelectedAgent: false, - modelCatalog: [], - pinnedAgentIds: [], - onRefresh: () => undefined, - onSelectAgent: () => undefined, - onCreateAgent: () => undefined, - onSelectPanel: () => undefined, - onLoadFiles: () => undefined, - onSelectFile: () => undefined, - onFileDraftChange: () => undefined, - onFileReset: () => undefined, - onFileSave: () => undefined, - onToolsProfileChange: () => undefined, - onToolsOverridesChange: () => undefined, - onConfigReload: () => undefined, - onConfigSave: () => undefined, - onModelChange: () => undefined, - onModelFallbacksChange: () => undefined, - onChannelsRefresh: () => undefined, - onCronRefresh: () => undefined, - onCronRunNow: () => undefined, - onSkillsFilterChange: () => undefined, - onSkillsRefresh: () => undefined, - onAgentSkillToggle: () => undefined, - onAgentSkillsClear: () => undefined, - onAgentSkillsDisableAll: () => undefined, - onSetDefault: () => undefined, - onIdentityFieldChange: () => undefined, - onIdentityAvatarSelect: () => undefined, - onIdentitySave: () => undefined, - onTogglePinnedAgent: () => undefined, - onOpenAgentDefaults: () => undefined, - ...overrides, - }; -} - describe("renderAgents", () => { it("opens global Agent defaults before the per-agent tabs", () => { const container = document.createElement("div"); @@ -205,6 +121,152 @@ describe("renderAgents", () => { ).toBe("Fetched Beta"); }); + it("shows a model-catalog failure and lets the operator retry", () => { + const container = document.createElement("div"); + const onModelCatalogRetry = vi.fn(); + render( + renderAgents( + createProps({ modelCatalogError: "model catalog unavailable", onModelCatalogRetry }), + ), + container, + ); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("model catalog unavailable"); + const retry = Array.from(alert?.querySelectorAll("button") ?? []).find( + (button) => button.textContent?.trim() === t("common.retry"), + ); + retry?.click(); + + expect(onModelCatalogRetry).toHaveBeenCalledOnce(); + }); + + it("renders and counts a server-scoped default-agent cron job without an explicit agentId", () => { + const job = createCronJob("implicit-default-job", { + name: "Implicit default-agent reminder", + }); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const container = document.createElement("div"); + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }, + jobs: [job], + jobsTotal: 1, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: 1, + scopedNextWakeAtMs, + loading: false, + error: null, + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Implicit default-agent reminder"); + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("1"); + + const schedulerRows = [...container.querySelectorAll(".settings-row")]; + const jobsRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.jobs"), + ); + const nextWakeRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.nextWake"), + ); + expect(jobsRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe("1"); + expect(nextWakeRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe( + formatNextRun(scopedNextWakeAtMs), + ); + expect(nextWakeRow?.textContent).not.toContain(formatNextRun(globalNextWakeAtMs)); + }); + + it("loads and renders the selected agent's 51st cron job when Load more is clicked", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => + createCronJob(`main-${index}`, { agentId: "alpha" }), + ); + const lastJob = createCronJob("main-50", { + agentId: "alpha", + name: "Fifty-first agent reminder", + }); + const request = vi.fn(async () => ({ + jobs: [lastJob], + total: 51, + offset: 50, + nextOffset: null, + hasMore: false, + })); + const client = { request } as unknown as GatewayBrowserClient; + const cronState = { + ...createInitialCronState({ client, connected: true }), + cronAgentId: "alpha", + cronJobs: jobs, + cronJobsTotal: 51, + cronJobsHasMore: true, + cronJobsNextOffset: 50, + }; + const container = document.createElement("div"); + const renderCurrentPage = (): void => { + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 80, nextWakeAtMs: null }, + jobs: cronState.cronJobs, + jobsTotal: cronState.cronJobsTotal, + jobsHasMore: cronState.cronJobsHasMore, + jobsLoadingMore: cronState.cronJobsLoadingMore, + scopedTotal: 51, + scopedNextWakeAtMs: null, + loading: cronState.cronLoading, + error: cronState.cronError, + }, + onCronLoadMore: () => { + const nextPage = loadCronJobsPage(cronState, { + append: true, + tableFilters: true, + }); + renderCurrentPage(); + void nextPage.then(renderCurrentPage); + }, + }), + ), + container, + ); + }; + renderCurrentPage(); + + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("51"); + expect(container.textContent).not.toContain(lastJob.name); + + const loadMore = container.querySelector(".cron-load-more"); + expect(loadMore?.textContent?.trim()).toBe(t("cron.list.loadMore")); + loadMore?.click(); + expect(container.querySelector(".cron-load-more")?.disabled).toBe(true); + + await vi.waitFor(() => expect(container.textContent).toContain(lastJob.name)); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "alpha", limit: 50, offset: 50 }), + ); + expect(container.querySelector(".cron-load-more")).toBeNull(); + }); + it("renders Memory after Automations and scopes the panel to the selected agent", () => { const container = document.createElement("div"); render(renderAgents(createProps({ activePanel: "memory" })), container); diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index d8c170679eb1..e5874d440320 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -53,6 +53,11 @@ type ChannelsState = { type CronState = { status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; }; @@ -111,6 +116,7 @@ type AgentsProps = { runtimeSessionKey: string; runtimeSessionMatchesSelectedAgent: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; pinnedAgentIds: readonly string[]; onTogglePinnedAgent: (agentId: string) => void; onRefresh: () => void; @@ -131,11 +137,13 @@ type AgentsProps = { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onChannelsRefresh: () => void; onOpenMemoryImport?: () => void; onOpenMemorySettings?: () => void; onOpenAgentDefaults: () => void; onCronRefresh: () => void; + onCronLoadMore: () => void; onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; @@ -166,9 +174,7 @@ export function renderAgents(props: AgentsProps) { const channelEntryCount = props.channels.snapshot ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length : null; - const cronJobCount = selectedId - ? props.cron.jobs.filter((j) => j.agentId === selectedId).length - : null; + const cronJobCount = selectedId ? props.cron.jobsTotal : null; const tabCounts: Record = { files: props.agentFiles.list?.files?.length ?? null, skills: selectedSkillCount, @@ -279,6 +285,7 @@ export function renderAgents(props: AgentsProps) { configSaving: props.config.saving, configDirty: props.config.dirty, modelCatalog: props.modelCatalog, + modelCatalogError: props.modelCatalogError, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onIdentityFieldChange: props.onIdentityFieldChange, @@ -286,6 +293,7 @@ export function renderAgents(props: AgentsProps) { onIdentitySave: props.onIdentitySave, onModelChange: props.onModelChange, onModelFallbacksChange: props.onModelFallbacksChange, + onModelCatalogRetry: props.onModelCatalogRetry, onSelectPanel: props.onSelectPanel, }), ) @@ -378,10 +386,16 @@ export function renderAgents(props: AgentsProps) { ), agentId: selectedAgent.id, jobs: props.cron.jobs, + jobsTotal: props.cron.jobsTotal, + jobsHasMore: props.cron.jobsHasMore, + jobsLoadingMore: props.cron.jobsLoadingMore, status: props.cron.status, + scopedTotal: props.cron.scopedTotal, + scopedNextWakeAtMs: props.cron.scopedNextWakeAtMs, loading: props.cron.loading, error: props.cron.error, onRefresh: props.onCronRefresh, + onLoadMore: props.onCronLoadMore, onRunNow: props.onCronRunNow, onSelectPanel: props.onSelectPanel, }) diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index bcd66e568bec..cd32fb7ffbfb 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -14,6 +14,7 @@ import type { CronJobsSortBy, CronSortDir, } from "../../api/types.ts"; +import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { icon, icons } from "../../components/icons.ts"; import { highlightCodeHtml } from "../../components/markdown-code-blocks.ts"; import { @@ -653,25 +654,14 @@ function renderJobsTable(props: CronProps, hasAnyJobsFilters: boolean) { (job) => job.id, (job) => renderJobRow(job, props), )} - + ${renderCronJobsPagination({ + jobsShown: props.jobs.length, + jobsTotal: props.jobsTotal, + hasMore: props.jobsHasMore, + loading: props.loading, + loadingMore: props.jobsLoadingMore, + onLoadMore: props.onLoadMoreJobs, + })} `; } diff --git a/ui/src/styles/cron-jobs-pagination.css b/ui/src/styles/cron-jobs-pagination.css new file mode 100644 index 000000000000..859043c4307a --- /dev/null +++ b/ui/src/styles/cron-jobs-pagination.css @@ -0,0 +1,13 @@ +.cron-table__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: var(--control-ui-text-sm); +} + +.cron-load-more { + align-self: flex-start; +} diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index 300771733509..45ec15c153a5 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -425,16 +425,6 @@ height: 12px; } -.cron-table__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - font-size: var(--control-ui-text-sm); -} - .cron-empty-state { padding: var(--space-6) var(--space-4); display: grid; @@ -455,10 +445,6 @@ line-height: 1.45; } -.cron-load-more { - align-self: flex-start; -} - /* ── Detail view ── */ .cron-back-row { From 754fddbc798f1d7791f73cebe67069cdac3033b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:50:24 -0700 Subject: [PATCH 097/239] fix(anthropic-vertex): correct multi-region endpoints (#116757) Co-authored-by: Peter Steinberger --- extensions/anthropic-vertex/index.test.ts | 17 +++++++-- .../anthropic-vertex/provider-catalog.ts | 4 ++- extensions/anthropic-vertex/region.test.ts | 6 ++++ .../anthropic-vertex/stream-runtime.test.ts | 18 ++++++++++ .../anthropic-payload-policy.test.ts | 36 +++++++++++++++++++ .../transports/anthropic-payload-policy.ts | 2 ++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/ai/src/transports/anthropic-payload-policy.test.ts diff --git a/extensions/anthropic-vertex/index.test.ts b/extensions/anthropic-vertex/index.test.ts index 56d97a821ee5..b124d77751fc 100644 --- a/extensions/anthropic-vertex/index.test.ts +++ b/extensions/anthropic-vertex/index.test.ts @@ -102,6 +102,19 @@ describe("anthropic-vertex provider plugin", () => { expect(result.provider.models[4]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" }); }); + it.each([ + { region: "global", baseUrl: "https://aiplatform.googleapis.com" }, + { region: "us", baseUrl: "https://aiplatform.us.rep.googleapis.com" }, + { region: "eu", baseUrl: "https://aiplatform.eu.rep.googleapis.com" }, + { region: "us-east5", baseUrl: "https://us-east5-aiplatform.googleapis.com" }, + ])("publishes the SDK endpoint for the $region location", ({ region, baseUrl }) => { + expect( + buildAnthropicVertexProvider({ + env: { GOOGLE_CLOUD_LOCATION: region }, + }).baseUrl, + ).toBe(baseUrl); + }); + it.each(["global", "us", "eu"])("publishes Opus 5 for the %s endpoint", (region) => { const provider = buildAnthropicVertexProvider({ env: { GOOGLE_CLOUD_LOCATION: region }, @@ -194,7 +207,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Sonnet 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: true, input: ["text", "image"], contextWindow: 1_000_000, @@ -235,7 +248,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Opus 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: false, input: ["text"], cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, diff --git a/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts index e83408662305..da9bf0e506b7 100644 --- a/extensions/anthropic-vertex/provider-catalog.ts +++ b/extensions/anthropic-vertex/provider-catalog.ts @@ -238,7 +238,9 @@ export function buildAnthropicVertexProvider(params?: { const baseUrl = normalizeLowercaseStringOrEmpty(region) === "global" ? "https://aiplatform.googleapis.com" - : `https://${region}-aiplatform.googleapis.com`; + : region === "us" || region === "eu" + ? `https://aiplatform.${region}.rep.googleapis.com` + : `https://${region}-aiplatform.googleapis.com`; return { baseUrl, diff --git a/extensions/anthropic-vertex/region.test.ts b/extensions/anthropic-vertex/region.test.ts index 49a2ced78f94..30b8686039f1 100644 --- a/extensions/anthropic-vertex/region.test.ts +++ b/extensions/anthropic-vertex/region.test.ts @@ -25,6 +25,12 @@ describe("anthropic vertex region helpers", () => { ).toBe("europe-west4"); }); + it.each(["us", "eu"])("parses the %s multi-region Vertex endpoint", (region) => { + expect( + resolveAnthropicVertexRegionFromBaseUrl(`https://aiplatform.${region}.rep.googleapis.com`), + ).toBe(region); + }); + it("treats the global Vertex endpoint as global", () => { expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( "global", diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 00a56299ae8e..2cd043fba24a 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -589,6 +589,24 @@ describe("createAnthropicVertexStreamFn", () => { }); describe("createAnthropicVertexStreamFnForModel", () => { + it.each(["us", "eu"])("preserves the %s multi-region SDK endpoint", (region) => { + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: `https://aiplatform.${region}.rep.googleapis.com` }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, + ); + + void streamFn(makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 }), { messages: [] }, {}); + + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, + projectId: "vertex-project", + region, + baseURL: `https://aiplatform.${region}.rep.googleapis.com/v1`, + }); + }); + it("derives project and region from the model and env", () => { const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( diff --git a/packages/ai/src/transports/anthropic-payload-policy.test.ts b/packages/ai/src/transports/anthropic-payload-policy.test.ts new file mode 100644 index 000000000000..8603d1edbfbd --- /dev/null +++ b/packages/ai/src/transports/anthropic-payload-policy.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAnthropicEphemeralCacheControl } from "./anthropic-payload-policy.js"; + +describe("resolveAnthropicEphemeralCacheControl", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + "https://aiplatform.googleapis.com", + "https://us-east5-aiplatform.googleapis.com", + "https://aiplatform.us.rep.googleapis.com", + "https://aiplatform.eu.rep.googleapis.com", + ])("preserves env-configured long retention for the official %s endpoint", (baseUrl) => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect(resolveAnthropicEphemeralCacheControl(baseUrl, undefined)).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + + it("keeps env-configured long retention restricted for custom proxy endpoints", () => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", undefined), + ).toEqual({ type: "ephemeral" }); + }); + + it("preserves explicitly configured long retention for custom proxy endpoints", () => { + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", "long"), + ).toEqual({ type: "ephemeral", ttl: "1h" }); + }); +}); diff --git a/packages/ai/src/transports/anthropic-payload-policy.ts b/packages/ai/src/transports/anthropic-payload-policy.ts index 265b1dfa1633..f17a1f4c67b0 100644 --- a/packages/ai/src/transports/anthropic-payload-policy.ts +++ b/packages/ai/src/transports/anthropic-payload-policy.ts @@ -56,6 +56,8 @@ function isLongTtlEligibleEndpoint(baseUrl: string | undefined): boolean { return ( hostname === "api.anthropic.com" || hostname === "aiplatform.googleapis.com" || + hostname === "aiplatform.us.rep.googleapis.com" || + hostname === "aiplatform.eu.rep.googleapis.com" || hostname.endsWith("-aiplatform.googleapis.com") ); } From 5f5a871f39c41f40b336d96767fee72d4cbeb381 Mon Sep 17 00:00:00 2001 From: Penchan <5032148+p3nchan@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:51:12 +0800 Subject: [PATCH 098/239] fix(outbound): strip echoed inbound metadata before delivery (#50520) Co-authored-by: Penchan --- src/infra/outbound/payloads.test.ts | 38 +++++++++++++++++++++++++++++ src/infra/outbound/payloads.ts | 3 ++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index de13be4efd26..c2f608d88a97 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -2,6 +2,7 @@ // interactive blocks, mirror text, and suppressed relay status payloads. import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { describe, expect, it } from "vitest"; +import { markInboundContextLabel } from "../../auto-reply/reply/inbound-context-marker.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { @@ -53,6 +54,43 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); + it("strips leading echoed inbound metadata before parsing reply directives", () => { + const text = [ + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + "", + markInboundContextLabel("Plugin context:"), + "```json", + '{"source":"example","payload":{"mode":"test"}}', + "```", + "", + "[[reply_to: 123]] Visible reply", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([ + { + text: "Visible reply", + replyToId: "123", + replyToTag: true, + }, + ]); + }); + + it("preserves marked metadata examples after visible reply text", () => { + const text = [ + "Here is the metadata format:", + "", + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([{ text }]); + }); + it("strips unsupported citation control markers from reply payload text", () => { const payloads: ReplyPayload[] = [{ text: "v2026.5.20 release note citeturn2view0" }]; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 04a21b6d1379..889eca89ec66 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -10,6 +10,7 @@ import { isRenderablePayload, shouldSuppressReasoningPayload, } from "../../auto-reply/reply/reply-payloads.js"; +import { stripLeadingInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -231,7 +232,7 @@ function createOutboundPayloadPlanEntry( if (shouldSuppressReasoningPayload(payload)) { return null; } - const parsed = parseReplyDirectives(payload.text ?? "", { + const parsed = parseReplyDirectives(stripLeadingInboundMetadata(payload.text ?? ""), { extractMarkdownImages: context.extractMarkdownImages, }); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; From eb55c8ea8fcb69f9357c7c27c9eaaab71a2c0800 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 19:52:33 +0800 Subject: [PATCH 099/239] feat(plugins): externalize Voyage embeddings (#116785) * feat(voyage): externalize embedding provider * fix(voyage): drop stale bundled description --- docs/plugins/plugin-inventory.md | 8 ++--- docs/plugins/reference/voyage.md | 2 +- extensions/voyage/README.md | 14 ++++++++ extensions/voyage/index.ts | 2 +- extensions/voyage/package.json | 26 ++++++++++++-- package.json | 1 + .../official-external-provider-catalog.json | 36 +++++++++++++++++++ src/cli/plugins-location-bridges.test.ts | 1 + .../official-external-plugin-catalog.test.ts | 28 +++++++++++++++ .../bundled-plugin-build-entries.test.ts | 8 +++++ 10 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 extensions/voyage/README.md diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 2492b9334048..e8a1162c8435 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -65 plugins +64 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -169,8 +169,6 @@ Each entry lists the package, distribution route, and description. - **[volcengine](/plugins/reference/volcengine)** (`@openclaw/volcengine-provider`) - included in OpenClaw. Adds Volcengine, Volcengine Plan model provider support to OpenClaw. -- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - included in OpenClaw. Adds memory embedding provider support. - - **[vydra](/plugins/reference/vydra)** (`@openclaw/vydra-provider`) - included in OpenClaw. Adds Vydra model provider support to OpenClaw. - **[web-readability](/plugins/reference/web-readability)** (`@openclaw/web-readability-plugin`) - included in OpenClaw. Extract readable article content from local HTML web fetch responses. @@ -185,7 +183,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -80 plugins +81 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -337,6 +335,8 @@ Each entry lists the package, distribution route, and description. - **[voice-call](/plugins/reference/voice-call)** (`@openclaw/voice-call`) - npm; ClawHub. OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls. +- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - npm; ClawHub: `clawhub:@openclaw/voyage-provider`. Adds memory embedding provider support. + - **[whatsapp](/plugins/reference/whatsapp)** (`@openclaw/whatsapp`) - ClawHub: `clawhub:@openclaw/whatsapp`; npm. OpenClaw WhatsApp channel plugin for WhatsApp Web chats. - **[zai](/plugins/reference/zai)** (`@openclaw/zai-provider`) - npm; ClawHub: `clawhub:@openclaw/zai-provider`. Adds Z.AI model provider support to OpenClaw. diff --git a/docs/plugins/reference/voyage.md b/docs/plugins/reference/voyage.md index c4952f1d67fd..c4220ee48600 100644 --- a/docs/plugins/reference/voyage.md +++ b/docs/plugins/reference/voyage.md @@ -12,7 +12,7 @@ Adds memory embedding provider support. ## Distribution - Package: `@openclaw/voyage-provider` -- Install route: included in OpenClaw +- Install route: npm; ClawHub: `clawhub:@openclaw/voyage-provider` ## Surface diff --git a/extensions/voyage/README.md b/extensions/voyage/README.md new file mode 100644 index 000000000000..37b074cb890e --- /dev/null +++ b/extensions/voyage/README.md @@ -0,0 +1,14 @@ +# OpenClaw Voyage Provider + +Official OpenClaw memory embedding provider plugin for Voyage AI. + +Install from OpenClaw: + +```bash +openclaw plugins install @openclaw/voyage-provider +openclaw gateway restart +``` + +Set `VOYAGE_API_KEY`, then configure memory search with `provider: "voyage"`. +See for setup and +configuration. diff --git a/extensions/voyage/index.ts b/extensions/voyage/index.ts index 8fadd002c3b1..bfdb7048e033 100644 --- a/extensions/voyage/index.ts +++ b/extensions/voyage/index.ts @@ -5,7 +5,7 @@ import { voyageMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter export default definePluginEntry({ id: "voyage", name: "Voyage Embeddings", - description: "Bundled Voyage memory embedding provider plugin", + description: "Voyage memory embedding provider plugin", register(api) { api.registerMemoryEmbeddingProvider(voyageMemoryEmbeddingProviderAdapter); }, diff --git a/extensions/voyage/package.json b/extensions/voyage/package.json index 20569589e823..e7db3033e9ed 100644 --- a/extensions/voyage/package.json +++ b/extensions/voyage/package.json @@ -1,8 +1,11 @@ { "name": "@openclaw/voyage-provider", "version": "2026.7.2", - "private": true, - "description": "OpenClaw Voyage embedding provider plugin", + "description": "OpenClaw Voyage embedding provider plugin.", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" @@ -10,6 +13,23 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/package.json b/package.json index e53ba0b2a464..28b0fed71bc7 100644 --- a/package.json +++ b/package.json @@ -316,6 +316,7 @@ "!dist/extensions/venice/**", "!dist/extensions/vercel-ai-gateway/**", "!dist/extensions/voice-call/**", + "!dist/extensions/voyage/**", "!dist/extensions/whatsapp/**", "!dist/extensions/zai/**", "!dist/extensions/zalo/**", diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 882145a98922..9388b8164643 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -1708,6 +1708,42 @@ } } }, + { + "name": "@openclaw/voyage-provider", + "description": "OpenClaw Voyage embedding provider plugin.", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "voyage", + "label": "Voyage" + }, + "providers": [ + { + "id": "voyage", + "name": "Voyage", + "docs": "/reference/memory-config", + "categories": [ + "cloud" + ], + "envVars": [ + "VOYAGE_API_KEY" + ] + } + ], + "contracts": { + "memoryEmbeddingProviders": [ + "voyage" + ] + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + } + } + }, { "name": "@openclaw/stepfun-provider", "description": "OpenClaw StepFun provider plugin.", diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 106bc2d3cfb9..f6c1a75a78bb 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -168,6 +168,7 @@ describe("listPersistedBundledPluginLocationBridges", () => { ["duckduckgo", "@openclaw/duckduckgo-plugin", false], ["synthetic", "@openclaw/synthetic-provider", true], ["teams-meetings", "@openclaw/teams-meetings", true], + ["voyage", "@openclaw/voyage-provider", true], ["zoom-meetings", "@openclaw/zoom-meetings", true], ] as const)( "externalizes the shipped bundled %s plugin using official install metadata", diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index f19de92439d3..c4a04dd73614 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -2000,6 +2000,26 @@ describe("official external plugin catalog", () => { ]); }); + it("lists Voyage as an official external memory embedding provider", () => { + const voyage = expectCatalogEntry("voyage"); + const manifest = getOfficialExternalPluginCatalogManifest(voyage); + + expect(resolveOfficialExternalPluginId(voyage)).toBe("voyage"); + expect(resolveOfficialExternalPluginInstall(voyage)).toEqual({ + clawhubSpec: "clawhub:@openclaw/voyage-provider", + npmSpec: "@openclaw/voyage-provider", + defaultChoice: "npm", + minHostVersion: ">=2026.7.2", + }); + expect(manifest?.contracts?.memoryEmbeddingProviders).toEqual(["voyage"]); + expect(manifest?.providers).toEqual([ + expect.objectContaining({ + id: "voyage", + envVars: ["VOYAGE_API_KEY"], + }), + ]); + }); + it.each([ ["teams-meetings", "@openclaw/teams-meetings", "teams_meetings", "teams"], ["zoom-meetings", "@openclaw/zoom-meetings", "zoom_meetings", "zoom"], @@ -2067,6 +2087,12 @@ describe("official external plugin catalog", () => { providerIds: new Set(["groq", "moonshot", "zai"]), }), ).toEqual(["groq", "moonshot", "zai"]); + expect( + resolveOfficialExternalProviderContractPluginIds({ + contract: "memoryEmbeddingProviders", + providerIds: new Set(["voyage"]), + }), + ).toEqual(["voyage"]); }); it("maps env-only web-fetch credentials to external plugin owners", () => { @@ -2116,6 +2142,7 @@ describe("official external plugin catalog", () => { TOKENPLAN_API_KEY: "tokenplan-key", VENICE_API_KEY: "venice-key", AI_GATEWAY_API_KEY: "gateway-key", + VOYAGE_API_KEY: "voyage-key", ZAI_API_KEY: "zai-key", }), ).toEqual([ @@ -2138,6 +2165,7 @@ describe("official external plugin catalog", () => { "tencent", "venice", "vercel-ai-gateway", + "voyage", "zai", ]); expect(resolveOfficialExternalProviderPluginIdsForEnv({ GROQ_API_KEY: " " })).toEqual([]); diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 84df1bcc921c..d8f337a6d754 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -375,6 +375,14 @@ describe("bundled plugin build entries", () => { expect(artifacts).not.toContain("dist/extensions/duckduckgo/package.json"); }); + it("excludes the externalized Voyage provider from bundled artifacts", () => { + const artifacts = listBundledPluginPackArtifacts(); + + expect(artifacts).not.toContain("dist/extensions/voyage/index.js"); + expect(artifacts).not.toContain("dist/extensions/voyage/openclaw.plugin.json"); + expect(artifacts).not.toContain("dist/extensions/voyage/package.json"); + }); + it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const excludedPackageDirs = collectRootPackageExcludedExtensionDirs(); From 4ec46e0ff1ed8f31460dfbe8eb79118553e92f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:54:03 -0700 Subject: [PATCH 100/239] fix(qa-lab): support groups and isolate thread timelines (#116803) Co-authored-by: Peter Steinberger --- extensions/qa-lab/web/src/app.browser.test.ts | 69 +++++++- extensions/qa-lab/web/src/app.ts | 13 +- .../qa-lab/web/src/ui-render-content.ts | 36 +++- extensions/qa-lab/web/src/ui-render.test.ts | 166 ++++++++++++++++++ extensions/qa-lab/web/src/ui-types.ts | 4 +- 5 files changed, 271 insertions(+), 17 deletions(-) diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index 47ed732933c4..0ab4dd5c950d 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Bootstrap, RunnerSelection } from "./ui-types.js"; +import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js"; const httpMock = vi.hoisted(() => { class QaLabHttpError extends Error { @@ -103,14 +103,17 @@ function createBootstrap(selection: RunnerSelection): Bootstrap { }; } -async function mountRunner(selection: RunnerSelection) { +async function mountRunner( + selection: RunnerSelection, + snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] }, +) { let bootstrap = createBootstrap(selection); httpMock.getJson.mockImplementation(async (url: string) => { if (url === "/api/bootstrap") { return bootstrap; } if (url === "/api/state") { - return { conversations: [], events: [], messages: [], threads: [] }; + return snapshot; } if (url === "/api/report") { return { report: null }; @@ -195,6 +198,66 @@ afterEach(() => { }); describe("QA Lab runner browser interactions", () => { + it("sends group conversation messages from the interactive chat composer", async () => { + const root = await mountRunner( + { + alternateModel: "mock-openai/gpt-5.6-luna-alt", + channel: null, + channelDriver: "qa-channel", + evidenceMode: "full", + fastMode: false, + primaryModel: "mock-openai/gpt-5.6-luna", + profile: "all", + providerMode: "mock-openai", + runtimePair: null, + runtimePairLane: null, + scenarioIds: ["dm-chat-baseline"], + }, + { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }, + ); + httpMock.postJson.mockResolvedValue({ message: { id: "group-message" } }); + + root.querySelector("[data-thread-select='owned-thread']")?.click(); + selectValue(root, "#conversation-kind", "group"); + const conversationInput = root.querySelector("#conversation-id"); + if (!conversationInput) { + throw new Error("missing group conversation input"); + } + conversationInput.value = "qa-group"; + conversationInput.dispatchEvent(new Event("input", { bubbles: true })); + const composer = root.querySelector("#composer-text"); + if (!composer) { + throw new Error("missing group message composer"); + } + composer.value = "hello group"; + composer.dispatchEvent(new Event("input", { bubbles: true })); + root.querySelector("[data-action='send']")?.click(); + + await vi.waitFor(() => expect(httpMock.postJson).toHaveBeenCalledTimes(1)); + expect(httpMock.postJson).toHaveBeenCalledWith( + "/api/inbound/message", + expect.objectContaining({ + accountId: "default", + conversation: { id: "qa-group", kind: "group", title: "qa-group" }, + text: "hello group", + }), + ); + const submittedPayload = httpMock.postJson.mock.calls[0]?.[1] as Record; + expect(submittedPayload).not.toHaveProperty("threadId"); + }); + it("keeps scenario rows from collapsing inside the scrolling list", async () => { const root = await mountRunner({ alternateModel: "mock-openai/gpt-5.6-luna-alt", diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index c89a0382a019..0ff300627144 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -579,23 +579,29 @@ export async function createQaLabApp(root: HTMLDivElement) { state.selectedConversationKey, ); const accountId = selectedConversation?.accountId ?? "default"; + const selectedThreadId = + selectedConversation?.id === conversationId && + selectedConversation.kind === state.composer.conversationKind + ? state.selectedThreadId + : null; await postJson("/api/inbound/message", { accountId, conversation: { id: conversationId, kind: state.composer.conversationKind, - ...(state.composer.conversationKind === "channel" ? { title: conversationId } : {}), + ...(state.composer.conversationKind !== "direct" ? { title: conversationId } : {}), }, senderId: state.composer.senderId.trim() || "alice", senderName: state.composer.senderName.trim() || undefined, text, - ...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}), + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), }); state.selectedConversationKey = conversationSelectionKey({ accountId, id: conversationId, kind: state.composer.conversationKind, }); + state.selectedThreadId = selectedThreadId; state.composer.text = ""; chatScrollLocked = true; await refresh(); @@ -1737,8 +1743,9 @@ export async function createQaLabApp(root: HTMLDivElement) { /* Composer form */ root.querySelector("#conversation-kind")?.addEventListener("change", (e) => { + const selectedKind = (e.currentTarget as HTMLSelectElement).value; state.composer.conversationKind = - (e.currentTarget as HTMLSelectElement).value === "channel" ? "channel" : "direct"; + selectedKind === "channel" || selectedKind === "group" ? selectedKind : "direct"; }); root.querySelector("#conversation-id")?.addEventListener("input", (e) => { state.composer.conversationId = (e.currentTarget as HTMLInputElement).value; diff --git a/extensions/qa-lab/web/src/ui-render-content.ts b/extensions/qa-lab/web/src/ui-render-content.ts index 27807ed69faf..8f4d83b02dd3 100644 --- a/extensions/qa-lab/web/src/ui-render-content.ts +++ b/extensions/qa-lab/web/src/ui-render-content.ts @@ -69,6 +69,11 @@ function deriveSelectedThread(state: UiState): string | null { function filteredMessages(state: UiState) { const messages = state.snapshot?.messages ?? []; + const selectedConversationThreadIds = new Set( + (state.snapshot?.threads ?? []) + .filter((thread) => threadConversationSelectionKey(thread) === state.selectedConversationKey) + .map((thread) => thread.id), + ); return messages.filter((message) => { if ( state.selectedConversationKey && @@ -76,10 +81,12 @@ function filteredMessages(state: UiState) { ) { return false; } - if (state.selectedThreadId && message.threadId !== state.selectedThreadId) { - return false; + if (state.selectedThreadId) { + return message.threadId === state.selectedThreadId; } - return true; + // External thread ids have no sidebar record, even when the conversation + // also owns navigable threads, so keep their messages in the root view. + return !message.threadId || !selectedConversationThreadIds.has(message.threadId); }); } @@ -88,18 +95,28 @@ function formatConversationLabel( conversations: Conversation[], ): string { const label = conversation.title || conversation.id; - const hasAccountCollision = conversations.some( + const sidebarCollisions = conversations.filter( (candidate) => - candidate.accountId !== conversation.accountId && - candidate.kind === conversation.kind && - candidate.id === conversation.id, + candidate !== conversation && + candidate.id === conversation.id && + (candidate.kind === "direct") === (conversation.kind === "direct"), ); - return hasAccountCollision ? `${label} (${conversation.accountId})` : label; + const hasAccountCollision = sidebarCollisions.some( + (candidate) => candidate.accountId !== conversation.accountId, + ); + const hasKindCollision = sidebarCollisions.some( + (candidate) => candidate.kind !== conversation.kind, + ); + const disambiguators = [ + ...(hasKindCollision ? [conversation.kind] : []), + ...(hasAccountCollision ? [conversation.accountId] : []), + ]; + return disambiguators.length > 0 ? `${label} (${disambiguators.join(", ")})` : label; } export function renderChatView(state: UiState): string { const conversations = state.snapshot?.conversations ?? []; - const channels = conversations.filter((c) => c.kind === "channel"); + const channels = conversations.filter((c) => c.kind === "channel" || c.kind === "group"); const dms = conversations.filter((c) => c.kind === "direct"); const threads = (state.snapshot?.threads ?? []).filter( (thread) => @@ -205,6 +222,7 @@ export function renderChatView(state: UiState): string { as diff --git a/extensions/qa-lab/web/src/ui-render.test.ts b/extensions/qa-lab/web/src/ui-render.test.ts index 8052fb327677..ca85a0b2dde8 100644 --- a/extensions/qa-lab/web/src/ui-render.test.ts +++ b/extensions/qa-lab/web/src/ui-render.test.ts @@ -158,6 +158,172 @@ describe("QA Lab UI evidence render", () => { expect(html).toContain( `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, ); + + const crossAccountKindHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "channel" }, + ], + events: [], + messages: [], + threads: [], + }, + }), + ); + expect(crossAccountKindHtml).toContain("shared (group, account-a)"); + expect(crossAccountKindHtml).toContain("shared (channel, account-b)"); + }); + + it("shows group conversations in the sidebar and composer without leaking same-id rooms", () => { + const selectedConversationKey = JSON.stringify(["account-a", "group", "shared"]); + const html = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + composer: { + conversationId: "shared", + conversationKind: "group", + senderId: "alice", + senderName: "Alice", + text: "", + }, + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "group" }, + { accountId: "account-a", id: "shared", kind: "channel" }, + { accountId: "account-a", id: "shared", kind: "direct" }, + ], + events: [], + messages: [ + { + accountId: "account-a", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "selected-group-message", + reactions: [], + senderId: "alice", + text: "selected group message", + timestamp: 1, + }, + { + accountId: "account-b", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "foreign-group-message", + reactions: [], + senderId: "bob", + text: "foreign group message", + timestamp: 2, + }, + { + accountId: "account-a", + conversation: { id: "shared", kind: "channel" }, + direction: "outbound", + id: "same-id-channel-message", + reactions: [], + senderId: "openclaw", + text: "same-id channel message", + timestamp: 3, + }, + ], + threads: [], + }, + }), + ); + + expect(html).toContain("shared (group, account-a)"); + expect(html).toContain("shared (group, account-b)"); + expect(html).toContain("shared (channel, account-a)"); + expect(html).toContain("selected group message"); + expect(html).not.toContain("foreign group message"); + expect(html).not.toContain("same-id channel message"); + expect(html).toContain(''); + expect(html).toContain( + `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, + ); + }); + + it("keeps thread replies out of the root timeline when thread navigation exists", () => { + const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]); + const snapshot: NonNullable = { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [ + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "root-message", + reactions: [], + senderId: "openclaw", + text: "root timeline message", + timestamp: 1, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "thread-message", + reactions: [], + senderId: "openclaw", + text: "thread-only reply", + threadId: "owned-thread", + timestamp: 2, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "external-thread-message", + reactions: [], + senderId: "openclaw", + text: "externally observed thread reply", + threadId: "external-thread", + timestamp: 3, + }, + ], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }; + + const rootHtml = renderQaLabUi( + evidenceState({ activeTab: "chat", selectedConversationKey, snapshot }), + ); + expect(rootHtml).toContain("Main timeline"); + expect(rootHtml).toContain("root timeline message"); + expect(rootHtml).not.toContain("thread-only reply"); + expect(rootHtml).toContain("externally observed thread reply"); + + const threadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + selectedThreadId: "owned-thread", + snapshot, + }), + ); + expect(threadHtml).not.toContain("root timeline message"); + expect(threadHtml).toContain("thread-only reply"); + expect(threadHtml).not.toContain("externally observed thread reply"); + + const externalThreadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + snapshot: { ...snapshot, threads: [] }, + }), + ); + expect(externalThreadHtml).toContain("thread-only reply"); }); it("renders capture startup commands without personal home paths", () => { diff --git a/extensions/qa-lab/web/src/ui-types.ts b/extensions/qa-lab/web/src/ui-types.ts index 04ee7759af51..5c86096abecf 100644 --- a/extensions/qa-lab/web/src/ui-types.ts +++ b/extensions/qa-lab/web/src/ui-types.ts @@ -18,7 +18,7 @@ import type { export type Conversation = { accountId: string; id: string; - kind: "direct" | "channel"; + kind: "direct" | "channel" | "group"; title?: string; }; @@ -371,7 +371,7 @@ export type UiState = { runnerDraftDirty: boolean; runnerPlanOverride: RunnerResolvedPlan | null; composer: { - conversationKind: "direct" | "channel"; + conversationKind: "direct" | "channel" | "group"; conversationId: string; senderId: string; senderName: string; From 4d7d710c483a7ba50aa512a069a07fbcede11392 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:56:07 -0700 Subject: [PATCH 101/239] fix(qa-channel): enforce message and thread lifecycle ownership (#116801) Co-authored-by: Peter Steinberger --- extensions/qa-channel/src/channel-actions.ts | 3 + extensions/qa-channel/src/channel.test.ts | 115 +++++++++++++++++++ extensions/qa-lab/src/bus-queries.ts | 2 +- extensions/qa-lab/src/bus-state.test.ts | 104 +++++++++++++++++ extensions/qa-lab/src/bus-state.ts | 35 +++++- extensions/qa-lab/src/self-check.test.ts | 9 +- 6 files changed, 263 insertions(+), 5 deletions(-) diff --git a/extensions/qa-channel/src/channel-actions.ts b/extensions/qa-channel/src/channel-actions.ts index 7761d0f2c957..16ace743ce75 100644 --- a/extensions/qa-channel/src/channel-actions.ts +++ b/extensions/qa-channel/src/channel-actions.ts @@ -163,6 +163,9 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = { // QA evidence must not validate a host target while the bus acts on a // foreign immutable message owner. assertQaMessageMatchesTarget(message, target); + if (message.deleted) { + throw new Error(`qa-channel message was deleted: ${message.id}`); + } return message; }; diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 0220a9a0ad81..4fce75457e4e 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -706,6 +706,121 @@ describe("qa-channel plugin", () => { } }); + it("keeps deleted messages out of channel actions and makes reactions idempotent", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl }); + const handleAction = requireQaActionHandler(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + const actionContext = { + channel: "qa-channel" as const, + cfg, + accountId: "default", + }; + const reactionParams = { + to: "channel:qa-room", + messageId: deleted.id, + emoji: "eyes", + }; + + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + const cursorAfterReaction = state.getSnapshot().cursor; + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + expect(state.readMessage({ messageId: deleted.id }).reactions).toHaveLength(1); + + await handleAction({ + ...actionContext, + action: "delete", + params: { to: "channel:qa-room", messageId: deleted.id }, + }); + + for (const action of ["read", "reactions", "react", "edit", "delete"] as const) { + await expect( + handleAction({ + ...actionContext, + action, + params: { + to: "channel:qa-room", + messageId: deleted.id, + ...(action === "react" ? { emoji: "eyes" } : {}), + ...(action === "edit" ? { text: "edited after deletion" } : {}), + }, + }), + ).rejects.toThrow("qa-channel message was deleted"); + } + + const result = await handleAction({ + ...actionContext, + action: "search", + params: { query: "needle", channelId: "qa-room" }, + }); + const payload = extractToolPayload(result) as { messages: Array<{ id: string }> }; + expect(payload.messages.map((message) => message.id)).toEqual([live.id]); + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + } finally { + await bus.stop(); + } + }); + + it("rejects thread replies outside the owning account and conversation", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = { + channels: { + "qa-channel": { + baseUrl: bus.baseUrl, + accounts: { other: { baseUrl: bus.baseUrl } }, + }, + }, + }; + const handleAction = requireQaActionHandler(); + const thread = state.createThread({ conversationId: "qa-room", title: "Owned thread" }); + + for (const attempt of [ + { accountId: "other", channelId: "qa-room" }, + { accountId: "default", channelId: "other-room" }, + ]) { + await expect( + handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: attempt.accountId, + params: { + channelId: attempt.channelId, + threadId: thread.id, + text: "foreign reply", + }, + }), + ).rejects.toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot().messages).toEqual([]); + expect(state.getSnapshot().conversations).toEqual([ + { accountId: "default", id: "qa-room", kind: "channel" }, + ]); + + const result = await handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: "default", + params: { channelId: "qa-room", threadId: thread.id, text: "owned reply" }, + }); + const payload = extractToolPayload(result) as { message: { threadId: string } }; + expect(payload.message.threadId).toBe(thread.id); + } finally { + await bus.stop(); + } + }); + it("binds message-id actions and searches to the selected account and conversation", async () => { installQaChannelTestRegistry(); const state = createQaBusState(); diff --git a/extensions/qa-lab/src/bus-queries.ts b/extensions/qa-lab/src/bus-queries.ts index 28a787c80996..28d3e978edf2 100644 --- a/extensions/qa-lab/src/bus-queries.ts +++ b/extensions/qa-lab/src/bus-queries.ts @@ -115,7 +115,7 @@ export function searchQaBusMessages(params: { const limit = Math.max(1, Math.min(params.input.limit ?? 20, 100)); const query = normalizeOptionalLowercaseString(params.input.query); return Array.from(params.messages.values()) - .filter((message) => message.accountId === accountId) + .filter((message) => message.accountId === accountId && !message.deleted) .filter((message) => params.input.conversationId !== undefined ? message.conversation.id === params.input.conversationId diff --git a/extensions/qa-lab/src/bus-state.test.ts b/extensions/qa-lab/src/bus-state.test.ts index 425f8573b5fc..5ae1b2bd5c41 100644 --- a/extensions/qa-lab/src/bus-state.test.ts +++ b/extensions/qa-lab/src/bus-state.test.ts @@ -113,6 +113,110 @@ describe("qa-bus state", () => { expect(typeof snapshot.messages[0]?.reactions[0]?.timestamp).toBe("number"); }); + it("keeps deleted messages inspectable but removes them from mutations and search", () => { + const state = createQaBusState(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + + state.deleteMessage({ messageId: deleted.id }); + const cursorAfterDelete = state.getSnapshot().cursor; + + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + expect(state.getSnapshot().messages.map((message) => message.id)).toEqual([ + live.id, + deleted.id, + ]); + expect(state.searchMessages({ query: "needle", limit: 1 })).toEqual([ + expect.objectContaining({ id: live.id }), + ]); + + expect(() => + state.editMessage({ messageId: deleted.id, text: "edited after deletion" }), + ).toThrow("qa-bus message was deleted"); + expect(() => state.reactToMessage({ messageId: deleted.id, emoji: "eyes" })).toThrow( + "qa-bus message was deleted", + ); + expect(() => state.deleteMessage({ messageId: deleted.id })).toThrow( + "qa-bus message was deleted", + ); + expect(state.getSnapshot().cursor).toBe(cursorAfterDelete); + }); + + it("adds each sender and emoji reaction at most once", () => { + const state = createQaBusState(); + const message = state.addOutboundMessage({ to: "channel:qa-room", text: "react once" }); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: " alice " }); + const cursorAfterReaction = state.getSnapshot().cursor; + + const repeated = state.reactToMessage({ + messageId: message.id, + emoji: "eyes", + senderId: "alice", + }); + expect(repeated.reactions).toHaveLength(1); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: "bob" }); + state.reactToMessage({ messageId: message.id, emoji: "wave", senderId: "alice" }); + expect(state.readMessage({ messageId: message.id }).reactions).toEqual([ + expect.objectContaining({ emoji: "eyes", senderId: "alice" }), + expect.objectContaining({ emoji: "eyes", senderId: "bob" }), + expect.objectContaining({ emoji: "wave", senderId: "alice" }), + ]); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction + 2); + }); + + it("keeps owned threads scoped to their account, channel, and conversation", () => { + const state = createQaBusState(); + const thread = state.createThread({ + accountId: "account-a", + conversationId: "qa-room", + title: "Owned thread", + }); + const originalSnapshot = state.getSnapshot(); + + expect(() => + state.addOutboundMessage({ + accountId: "account-b", + to: `thread:qa-room/${thread.id}`, + text: "cross-account reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + expect(() => + state.addOutboundMessage({ + accountId: "account-a", + to: `thread:other-room/${thread.id}`, + text: "wrong-room reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + for (const kind of ["direct", "group"] as const) { + expect(() => + state.addInboundMessage({ + accountId: "account-a", + conversation: { id: "qa-room", kind }, + senderId: "alice", + text: "wrong-kind reply", + threadId: thread.id, + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot()).toEqual(originalSnapshot); + + const reply = state.addOutboundMessage({ + accountId: "account-a", + to: `thread:qa-room/${thread.id}`, + text: "owned reply", + }); + const external = state.addOutboundMessage({ + accountId: "account-b", + to: "thread:other-room/external-thread", + text: "externally observed reply", + }); + expect(reply.threadId).toBe(thread.id); + expect(external.threadId).toBe("external-thread"); + }); + it("rejects cross-account message reads and mutations", () => { const state = createQaBusState(); const message = state.addOutboundMessage({ diff --git a/extensions/qa-lab/src/bus-state.ts b/extensions/qa-lab/src/bus-state.ts index ffcb227197ca..f28fe1eed097 100644 --- a/extensions/qa-lab/src/bus-state.ts +++ b/extensions/qa-lab/src/bus-state.ts @@ -122,6 +122,16 @@ export function createQaBusState() { return created; }; + const requireActiveMessageForAccount = ( + input: Pick, + ): QaBusMessage => { + const message = requireQaBusMessageForAccount({ messages, input }); + if (message.deleted) { + throw new Error(`qa-bus message was deleted: ${input.messageId}`); + } + return message; + }; + const createMessage = (params: { direction: QaBusMessage["direction"]; accountId: string; @@ -137,6 +147,17 @@ export function createQaBusState() { nativeCommand?: QaBusInboundMessageInput["nativeCommand"]; toolCalls?: QaBusToolCall[]; }): QaBusMessage => { + const thread = params.threadId ? threads.get(params.threadId) : undefined; + if ( + thread && + (thread.accountId !== params.accountId || + thread.conversationId !== params.conversation.id || + params.conversation.kind !== "channel") + ) { + // Unknown ids can represent externally observed threads; owned records + // must never cross account, conversation, or channel-kind boundaries. + throw new Error("qa-bus thread not found in selected account and conversation"); + } const storedConversation = ensureConversation(params.accountId, params.conversation); const toolCalls = sanitizeQaBusToolCalls(params.toolCalls); const message: QaBusMessage = { @@ -257,12 +278,20 @@ export function createQaBusState() { }, reactToMessage(input: QaBusReactToMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); const reaction = { emoji: input.emoji, senderId: input.senderId?.trim() || DEFAULT_BOT_ID, timestamp: input.timestamp ?? Date.now(), }; + if ( + message.reactions.some( + (existing) => + existing.emoji === reaction.emoji && existing.senderId === reaction.senderId, + ) + ) { + return cloneMessage(message); + } message.reactions.push(reaction); pushEvent({ kind: "reaction-added", @@ -275,7 +304,7 @@ export function createQaBusState() { }, editMessage(input: QaBusEditMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.text = input.text; message.editedAt = input.timestamp ?? Date.now(); pushEvent({ @@ -287,7 +316,7 @@ export function createQaBusState() { }, deleteMessage(input: QaBusDeleteMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.deleted = true; pushEvent({ kind: "message-deleted", diff --git a/extensions/qa-lab/src/self-check.test.ts b/extensions/qa-lab/src/self-check.test.ts index 0e1121bf2924..1aff1be8e461 100644 --- a/extensions/qa-lab/src/self-check.test.ts +++ b/extensions/qa-lab/src/self-check.test.ts @@ -125,6 +125,13 @@ describe("createQaSelfCheckScenario", () => { "thread:qa-room/thread-1", "thread:qa-room/thread-1", ]); - expect(state.searchMessages({ query: "inside thread" }).at(-1)?.deleted).toBe(true); + const deletedMessage = state.getSnapshot().messages.find((message) => message.deleted); + if (!deletedMessage) { + throw new Error("self-check did not preserve its deleted message tombstone"); + } + expect(state.readMessage({ messageId: deletedMessage.id }).deleted).toBe(true); + expect( + state.searchMessages({ query: "inside thread" }).map((message) => message.id), + ).not.toContain(deletedMessage.id); }); }); From c52bc53745257557b3384eb96ca21a74934ab6b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:57:29 -0700 Subject: [PATCH 102/239] fix(plugins): clean up partial service startup once (#116804) Co-authored-by: Peter Steinberger --- src/plugins/services.test.ts | 95 ++++++++++++++++++++++++++++++++++++ src/plugins/services.ts | 44 ++++++++++------- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 7162464fd877..4e40b5e0ad7c 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -169,6 +169,72 @@ describe("startPluginServices", () => { expectServiceLifecycleState({ starts, stops, contexts, config }); }); + it("rolls back partially started services before starting their siblings", async () => { + const acquired = new Set(); + const received = vi.fn(); + const siblingStart = vi.fn(); + const rollback = vi.fn((ctx: OpenClawPluginServiceContext) => { + acquired.delete("failed-service"); + ctx.gatewayEvents?.emit("rolled-back", {}, { scope: "operator.read" }); + }); + const broadcastPluginEvent = vi.fn(); + + const handle = await startPluginServices({ + registry: createRegistry([ + { + id: "failed-service", + start: (ctx) => { + acquired.add("failed-service"); + ctx.gatewayEvents?.onSessionsChanged(received); + throw new Error("start failed after acquiring resources"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ]), + config: createServiceConfig(), + broadcastPluginEvent, + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(acquired.size).toBe(0); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(broadcastPluginEvent).toHaveBeenCalledWith( + "plugin.plugin:test.rolled-back", + {}, + "operator.read", + ); + + queuePluginSessionsChanged({ sessionKey: "agent:main:main" }); + await Promise.resolve(); + expect(received).not.toHaveBeenCalled(); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it("runs concurrent and repeated shutdowns through one cleanup operation", async () => { + let releaseStop: (() => void) | undefined; + const stopping = new Promise((resolve) => { + releaseStop = resolve; + }); + const stop = vi.fn(() => stopping); + const handle = await startTrackingServices({ + services: [{ id: "service", start: () => {}, stop }], + }); + + const firstStop = handle.stop(); + const secondStop = handle.stop(); + releaseStop?.(); + await Promise.all([firstStop, secondStop]); + + expect(firstStop).toBe(secondStop); + expect(stop).toHaveBeenCalledOnce(); + + await handle.stop(); + expect(stop).toHaveBeenCalledOnce(); + }); + it("binds gateway events to the owning plugin namespace and scope", async () => { const broadcastPluginEvent = vi.fn(); await startPluginServices({ @@ -445,6 +511,35 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); + it("continues starting siblings when rollback also fails", async () => { + const rollback = vi.fn(() => { + throw new Error("rollback failed"); + }); + const siblingStart = vi.fn(); + + const handle = await startTrackingServices({ + services: [ + { + id: "failed-service", + start: () => { + throw new Error("start failed"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ], + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + "plugin service stop failed (failed-service): Error: rollback failed", + ); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + it("emits per-service startup trace spans and summary", async () => { const measured: string[] = []; const details: Array<{ diff --git a/src/plugins/services.ts b/src/plugins/services.ts index fdbc65cf57a9..ead9ac142b04 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -174,6 +174,17 @@ export async function startPluginServices(params: { stop?: () => void | Promise; revokeGatewayEvents: () => void; }> = []; + const stopService = async (entry: (typeof running)[number]) => { + try { + if (entry.stop) { + await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); + } + } catch (err) { + log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); + } finally { + entry.revokeGatewayEvents(); + } + }; let failedCount = 0; for (const entry of params.registry.services) { const service = entry.service; @@ -189,6 +200,11 @@ export async function startPluginServices(params: { service: entry, gatewayEvents: scopedGatewayEvents.gatewayEvents, }); + const runningService = { + id: service.id, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, + revokeGatewayEvents: scopedGatewayEvents.revoke, + }; try { const startService = () => withPluginHttpRouteRegistry(params.registry, () => service.start(serviceContext)); @@ -197,18 +213,15 @@ export async function startPluginServices(params: { } else { await startService(); } - running.push({ - id: service.id, - stop: service.stop ? () => service.stop?.(serviceContext) : undefined, - revokeGatewayEvents: scopedGatewayEvents.revoke, - }); + running.push(runningService); } catch (err) { - scopedGatewayEvents.revoke(); failedCount += 1; const error = err as Error; log.error( `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}`, ); + // A failed start can already own resources; revoke events only after its cleanup runs. + await stopService(runningService); } } params.startupTrace?.detail?.("sidecars.plugin-services.summary", [ @@ -217,19 +230,14 @@ export async function startPluginServices(params: { ["failedCount", failedCount], ]); + let stopPromise: Promise | undefined; return { - stop: async () => { - for (const entry of running.toReversed()) { - try { - if (entry.stop) { - await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); - } - } catch (err) { - log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); - } finally { - entry.revokeGatewayEvents(); + stop: () => + // Store the shared promise before plugin cleanup runs so shutdown cannot start twice. + (stopPromise ??= Promise.resolve().then(async () => { + for (const entry of running.toReversed()) { + await stopService(entry); } - } - }, + })), }; } From 708c4d68dc50a64b688df10aac55fd5cdd82a318 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:59:06 -0700 Subject: [PATCH 103/239] fix(ui): preserve chat attachments and run ownership (#116752) Co-authored-by: Peter Steinberger --- ui/src/pages/chat/chat-gateway.test.ts | 105 ++++++++++++++++- ui/src/pages/chat/chat-gateway.ts | 14 ++- ui/src/pages/chat/chat-send-submit.test.ts | 128 +++++++++++++++++++++ ui/src/pages/chat/chat-send-submit.ts | 6 +- ui/src/pages/chat/run-lifecycle.test.ts | 47 ++++++++ ui/src/pages/chat/run-lifecycle.ts | 4 + 6 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 ui/src/pages/chat/chat-send-submit.test.ts diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 7e527fa19ab0..5e8c9fb8800f 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -853,6 +853,107 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamSegments).toEqual([]); }); + it.each([ + { + name: "provider timeout", + event: { + state: "error", + errorKind: "timeout", + errorMessage: "agent provider timeout", + }, + projectionStatus: "timeout", + sessionStatus: "timeout", + errorSummary: "Error: agent provider timeout", + }, + { + name: "provider failure", + event: { + state: "error", + errorMessage: "agent provider failure", + }, + projectionStatus: "error", + sessionStatus: "failed", + errorSummary: "Error: agent provider failure", + }, + { + name: "operator cancellation", + event: { state: "aborted" }, + projectionStatus: "aborted", + sessionStatus: "killed", + errorSummary: null, + }, + ] as const)( + "projects the canonical $name status onto the selected session", + ({ event, projectionStatus, sessionStatus, errorSummary }) => { + vi.useFakeTimers(); + try { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Partial assistant reply", + chatStreamStartedAt: 100, + }) as ChatState & { + chatRunStatus?: { phase: string; runId: string | null; sessionKey: string } | null; + lastLocalTerminalReconcile?: { sessionStatus: string } | null; + sessionsResult?: { + ts: number; + path: string; + count: number; + defaults: Record; + sessions: Array>; + }; + }; + state.sessionsResult = { + ts: 0, + path: "", + count: 1, + defaults: {}, + sessions: [ + { + key: "main", + kind: "direct", + updatedAt: 1, + hasActiveRun: true, + activeRunIds: ["run-1"], + status: "running", + startedAt: 100, + }, + ], + }; + + expect( + handleChatGatewayEvent(state, { + runId: "run-1", + sessionKey: "main", + ...event, + }), + ).toBe(event.state); + + expect( + getChatSessionProjection(state, state.chatMessages, { sessionKey: "main" }).runs["run-1"] + ?.status, + ).toBe(projectionStatus); + expect(state.sessionsResult.sessions[0]).toMatchObject({ + activeRunIds: [], + hasActiveRun: false, + status: sessionStatus, + }); + expect(state.lastLocalTerminalReconcile?.sessionStatus).toBe(sessionStatus); + expect(state.chatRunStatus).toMatchObject({ + phase: "interrupted", + runId: "run-1", + sessionKey: "main", + }); + expect(state.chatRunError?.summary ?? null).toBe(errorSummary); + expect(state.chatRunId).toBeNull(); + expect(state.chatStream).toBeNull(); + expect(state.chatStreamStartedAt).toBeNull(); + } finally { + vi.useRealTimers(); + } + }, + ); + it("reconciles cached run and indicator state on terminal events", () => { vi.useFakeTimers(); try { @@ -1950,7 +2051,7 @@ describe("handleChatGatewayEvent", () => { }, ); - it("does not let a completed run's late error interrupt a newer response", () => { + it("does not label a newer response with a completed run's late error", () => { const state = createState({ sessionKey: "main", chatRunId: "run-completed" }); expect( @@ -1982,7 +2083,7 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStream).toBe("Newer response"); expect(state.chatMessages).toHaveLength(1); expectTextChatMessage(state.chatMessages[0], "assistant", "Delivered once."); - expect(state.chatRunError).toEqual({ summary: "Error: late provider failure" }); + expect(state.chatRunError).toBeNull(); }); it("upgrades an empty final to one authoritative assistant reply", () => { diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index 0fa41cfc08ff..5589e7b8dfb6 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -273,11 +273,12 @@ function handleChatEvent( } if (payload.state === "error") { if ( + (!state.chatRunId || state.chatRunId === payload.runId) && payload.errorMessage?.trim() && projectedRun.currentRun?.errorMessage !== previousTerminalRun.errorMessage ) { - // A completed transcript is immutable; retain provider guidance without - // adopting its old run or interrupting a newer in-flight response. + // Completed-run diagnostics belong to an idle composer or that same run; + // publishing them over a newer response falsely marks the new run failed. setChatRunError(state, resolveGatewayErrorText(payload, null)); } return "error"; @@ -329,7 +330,7 @@ function handleChatEvent( }); const reconcileTerminalRun = ( outcome: "done" | "interrupted", - sessionStatus: "done" | "failed" | "killed", + sessionStatus: "done" | "failed" | "killed" | "timeout", ) => reconcileChatRunLifecycle(state as unknown as Parameters[0], { outcome, @@ -459,7 +460,12 @@ function handleChatEvent( state.chatMessages = materializeVisibleStream({ includeCurrent: true }); } } - reconcileTerminalRun("interrupted", "failed"); + // The shared Gateway projection owns timeout classification; preserve it + // when publishing selected-session and sidebar terminal status. + reconcileTerminalRun( + "interrupted", + projectedRun?.currentRun?.status === "timeout" ? "timeout" : "failed", + ); setChatRunError( state, resolveGatewayErrorText(payload, projectedErrorMessage ? visiblePayloadMessage : null), diff --git a/ui/src/pages/chat/chat-send-submit.test.ts b/ui/src/pages/chat/chat-send-submit.test.ts new file mode 100644 index 000000000000..da1c2dd6938a --- /dev/null +++ b/ui/src/pages/chat/chat-send-submit.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; +import { createSessionCapability } from "../../lib/sessions/index.ts"; +import { + getChatAttachmentDataUrl, + registerChatAttachmentPayload, + releaseChatAttachmentPayloads, +} from "./attachment-payload-store.ts"; +import type { ChatHost } from "./chat-send-contract.ts"; +import { handleSendChat } from "./chat-send-submit.ts"; + +const attachmentsToRelease: ChatAttachment[] = []; +const attachmentDataUrl = "data:application/pdf;base64,JVBERi0xLjQK"; + +afterEach(() => { + releaseChatAttachmentPayloads(attachmentsToRelease); + attachmentsToRelease.length = 0; +}); + +function createStagedAttachment(id: string): ChatAttachment { + const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); + const attachment = registerChatAttachmentPayload({ + attachment: { + id, + mimeType: "application/pdf", + fileName: "brief.pdf", + sizeBytes: file.size, + }, + dataUrl: attachmentDataUrl, + file, + }); + attachmentsToRelease.push(attachment); + return attachment; +} + +function createImmediateCommandHost( + command: string, + attachment: ChatAttachment, + overrides: Partial = {}, +): ChatHost { + const host = { + sessions: createSessionCapability({ + snapshot: { client: null, phase: "reconnecting", hello: null }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }), + client: null, + connected: true, + sessionKey: "agent:main", + chatLoading: false, + chatMessage: command, + chatMessages: [], + chatLocalInputHistoryBySession: {}, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + chatAttachments: [attachment], + chatQueue: [], + chatRunId: null, + chatSending: false, + chatStream: null, + chatModelCatalog: [], + hello: null, + refreshSessionsAfterChat: new Map(), + ...overrides, + } satisfies Partial; + return host as ChatHost; +} + +describe("handleSendChat immediate local commands", () => { + it.each(["/export-session", "/export"])( + "preserves staged attachments while %s exports the chat", + async (command) => { + const attachment = createStagedAttachment("export-att"); + const exportCurrentChat = vi.fn(); + const host = createImmediateCommandHost(command, attachment, { exportCurrentChat }); + + await handleSendChat(host); + + expect(exportCurrentChat).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe(""); + expect(host.chatAttachments).toEqual([attachment]); + expect(getChatAttachmentDataUrl(attachment)).toBe(attachmentDataUrl); + expect(host.chatQueue).toStrictEqual([]); + }, + ); + + it("does not duplicate staged attachments into both old and new session composers", async () => { + const attachment = createStagedAttachment("new-session-att"); + const attachmentsBySession = new Map(); + const host = createImmediateCommandHost("/new", attachment); + host.createChatSession = vi.fn(async () => { + const previousSessionKey = host.sessionKey; + const nextSessionKey = "agent:main:new"; + // Session creation captures the next composer before route switching + // decides whether the old session's attachment needs a memory fallback. + const createdSessionAttachments = [...host.chatAttachments]; + attachmentsBySession.set(previousSessionKey, [...host.chatAttachments]); + host.sessionKey = nextSessionKey; + host.chatAttachments = createdSessionAttachments; + attachmentsBySession.set(nextSessionKey, [...host.chatAttachments]); + return true; + }); + + await handleSendChat(host); + + expect(host.createChatSession).toHaveBeenCalledOnce(); + expect(attachmentsBySession.get("agent:main")).toStrictEqual([]); + expect(attachmentsBySession.get("agent:main:new")).toStrictEqual([]); + expect(host.chatAttachments).toStrictEqual([]); + }); + + it("restores staged attachments when creating a new session is cancelled", async () => { + const attachment = createStagedAttachment("cancelled-new-session-att"); + const createChatSession = vi.fn(async () => false); + const host = createImmediateCommandHost("/new", attachment, { createChatSession }); + + await handleSendChat(host); + + expect(createChatSession).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe("/new"); + expect(host.chatAttachments).toHaveLength(1); + expect(host.chatAttachments[0]).toMatchObject(attachment); + expect(getChatAttachmentDataUrl(host.chatAttachments[0]!)).toBe(attachmentDataUrl); + }); +}); diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index d1de90a6f902..055d44fb8449 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -380,7 +380,11 @@ export async function handleSendChat( ).previousDraft; } else { host.chatMessage = ""; - host.chatAttachments = []; + // Export leaves the composer in its current session; /new must clear + // attachments before its handoff can capture them under both routes. + if (parsed.command.key !== "export-session") { + host.chatAttachments = []; + } resetChatInputHistoryNavigation(host); } } diff --git a/ui/src/pages/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts index 565fe579c133..fb39efc4411d 100644 --- a/ui/src/pages/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -6,6 +6,7 @@ import type { SessionsListResult } from "../../api/types.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { CHAT_RUN_STATUS_TOAST_DURATION_MS, + handleAbortChat, hasAbortableSessionRun, reconcileChatRunFromCurrentSessionRow, reconcileChatRunFromSessionRow, @@ -62,6 +63,52 @@ function makeAbortHost(over: Partial = {}): AbortHost { }; } +describe("handleAbortChat", () => { + it("shows reconnect guidance when an offline session run has no browser run identity", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const host = makeAbortHost({ + client, + connected: false, + chatMessage: "keep this draft", + sessionsResult: makeSessionsResult([ + { key: "agent:main", hasActiveRun: true, status: "running" }, + ]), + }); + + expect(hasAbortableSessionRun(host)).toBe(true); + await handleAbortChat(host, { preserveDraft: true }); + + expect(host.chatError).toBe("Not connected. Try again after reconnecting."); + expect(host.lastError).toBe(host.chatError); + expect(host.chatMessage).toBe("keep this draft"); + expect(host.pendingAbort).toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + }); + + it("keeps offline exact-run stops safely queued for reconnect", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const host = makeAbortHost({ + client, + connected: false, + chatRunId: "run-main", + chatMessage: "keep this draft", + }); + + await handleAbortChat(host, { preserveDraft: true }); + + expect(host.pendingAbort).toEqual({ + sourceClient: client, + sessionKey: "agent:main", + runId: "run-main", + }); + expect(host.chatMessage).toBe("keep this draft"); + expect(host.chatError ?? null).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }); +}); + describe("replayPendingChatAbort", () => { it("dispatches a queued exact browser run stop through chat.abort", async () => { const request = vi.fn(async () => ({ aborted: true })); diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 6f13c3acec5f..c2761e8168e5 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -1,5 +1,6 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { reconcileSessionRunTerminal, @@ -260,6 +261,9 @@ export async function handleAbortChat(host: ChatAbortHost, opts?: ChatAbortOptio : null; const pendingAbort = disconnectedIntent?.runId ? disconnectedIntent : null; if (!host.connected && !pendingAbort) { + // Session-only stops cannot be replayed safely against a later run. + // Explain the blocked action instead of leaving the visible Stop inert. + setChatError(host, t("chat.questions.disconnected")); return; } if (!opts?.preserveDraft) { From 9812380f10021e24d3a0b2258af4bd7132c22916 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:00:21 -0700 Subject: [PATCH 104/239] fix(ui): coalesce profile identity refresh (#116764) * fix(ui): coalesce profile identity refresh * style(ui): format profile refresh e2e --- ui/src/e2e/profile-page.e2e.test.ts | 55 ++++++++++ ui/src/pages/profile/profile-page.test.ts | 119 ++++++++++++++++++++++ ui/src/pages/profile/profile-page.ts | 30 ++++-- 3 files changed, 193 insertions(+), 11 deletions(-) diff --git a/ui/src/e2e/profile-page.e2e.test.ts b/ui/src/e2e/profile-page.e2e.test.ts index 91d4874114f5..6c1f2d609ed0 100644 --- a/ui/src/e2e/profile-page.e2e.test.ts +++ b/ui/src/e2e/profile-page.e2e.test.ts @@ -365,4 +365,59 @@ describeControlUiE2e("Control UI profile page mocked Gateway E2E", () => { await context.close(); } }); + + it("keeps identity refresh single-flight and retries after a failed request", async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["users.self"], + presenceUsers: testPresenceUsers, + methodResponses: { + "users.self": { profile: testProfile }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/profile`); + expect(response?.status()).toBe(200); + + const refresh = page.locator(".profile-refresh"); + await gateway.waitForRequest("users.self"); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + const button = element as HTMLButtonElement; + button.click(); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + + await gateway.rejectDeferred("users.self", { message: "identity unavailable" }); + await page.getByText("identity unavailable", { exact: true }).waitFor({ timeout: 10_000 }); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + + await gateway.deferNext("users.self"); + await refresh.click(); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + (element as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + + await gateway.resolveDeferred("users.self", { profile: testProfile }); + const displayName = page.locator('.identity-name-control input[type="text"]'); + await displayName.waitFor({ timeout: 10_000 }); + await expect(displayName.inputValue()).resolves.toBe(testProfile.displayName); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/pages/profile/profile-page.test.ts b/ui/src/pages/profile/profile-page.test.ts index cbfe73032e84..7ad89fcc60b2 100644 --- a/ui/src/pages/profile/profile-page.test.ts +++ b/ui/src/pages/profile/profile-page.test.ts @@ -317,6 +317,125 @@ it("retries the identity bootstrap when users.self returns no profile", async () ); }); +it("keeps identity refresh single-flight and allows retry after settlement", async () => { + const profile: UserProfile = { + id: "profile-1", + displayName: "Ada", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + let rejectIdentity: ((reason: Error) => void) | undefined; + const firstIdentity = new Promise((_resolve, reject) => { + rejectIdentity = reject; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + if (request.mock.calls.length === 1) { + return await firstIdentity; + } + return { profile }; + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: profile.id, + email: profile.emails[0], + name: profile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1), + ); + await page.updateComplete; + const refresh = page.querySelector(".profile-refresh")!; + expect(refresh.disabled).toBe(true); + expect(refresh.textContent?.trim()).toBe(t("common.refreshing")); + + const pageWithIdentity = page as unknown as { loadIdentity: () => Promise }; + await Promise.all([pageWithIdentity.loadIdentity(), pageWithIdentity.loadIdentity()]); + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1); + + rejectIdentity?.(new Error("identity unavailable")); + await waitForFast(() => expect(refresh.disabled).toBe(false)); + expect(refresh.textContent?.trim()).toBe(t("common.refresh")); + expect(page.textContent).toContain("identity unavailable"); + + refresh.click(); + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(2), + ); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe("Ada"), + ); +}); + +it("replaces an in-flight identity request after a same-client reconnect", async () => { + const staleProfile: UserProfile = { + id: "profile-1", + displayName: "Stale identity", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + const freshProfile = { ...staleProfile, displayName: "Fresh identity", updatedAt: 3 }; + let resolveStale: ((value: { profile: UserProfile }) => void) | undefined; + let resolveFresh: ((value: { profile: UserProfile }) => void) | undefined; + const staleRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveStale = resolve; + }); + const freshRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveFresh = resolve; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + return await (request.mock.calls.length === 1 ? staleRequest : freshRequest); + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: staleProfile.id, + email: staleProfile.emails[0], + name: staleProfile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => expect(request).toHaveBeenCalledTimes(1)); + harness.emitConnected(false); + await page.updateComplete; + harness.emitConnected(true); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + + resolveFresh?.({ profile: freshProfile }); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ), + ); + resolveStale?.({ profile: staleProfile }); + await staleRequest; + await Promise.resolve(); + await page.updateComplete; + + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ); + expect(request).toHaveBeenCalledTimes(2); +}); + it("bootstraps and refreshes the connected user's profile through users.self", async () => { let profile: UserProfile = { id: "profile-1", diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index 8694f834b163..6577ebb46c7f 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -85,19 +85,21 @@ export class ProfilePage extends OpenClawLightDomElement { private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { const clientChanged = snapshot.client !== this.client; - const nextSelfUser = - snapshot.phase === "connected" - ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) - : null; + const nextConnected = snapshot.phase === "connected"; + const connectionChanged = nextConnected !== this.connected; + const nextSelfUser = nextConnected + ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) + : null; const selfProfileChanged = nextSelfUser?.id !== this.selfUser?.id; + const identitySourceChanged = clientChanged || connectionChanged || selfProfileChanged; this.client = snapshot.client; - this.connected = snapshot.phase === "connected"; + this.connected = nextConnected; this.selfUser = nextSelfUser; // connected/client are plain fields; an unidentified (token-auth) connect or // disconnect changes no @state, so the render branch must be invalidated // explicitly or the page sticks on the stale offline/connected view. this.requestUpdate(); - if (clientChanged || selfProfileChanged) { + if (identitySourceChanged) { this.identityRequestId += 1; this.ownProfile = null; this.displayName = ""; @@ -105,10 +107,10 @@ export class ProfilePage extends OpenClawLightDomElement { this.identityBusy = null; this.identityError = null; } - if (snapshot.phase !== "connected" || !snapshot.client) { + if (!nextConnected || !snapshot.client) { return; } - if (nextSelfUser && (clientChanged || selfProfileChanged)) { + if (nextSelfUser && identitySourceChanged) { void this.loadIdentity(); } void this.context.agents.ensureList().then((list) => { @@ -120,7 +122,9 @@ export class ProfilePage extends OpenClawLightDomElement { private async loadIdentity() { const client = this.client; - if (!client || !this.connected) { + // One active request owns the generation; reconnects clear loading before + // starting their replacement so stale responses cannot win out of order. + if (!client || !this.connected || this.identityLoading) { return; } const requestId = ++this.identityRequestId; @@ -300,7 +304,7 @@ export class ProfilePage extends OpenClawLightDomElement { } private refreshManually() { - if (this.selfUser && !this.identityBusy) { + if (this.selfUser && !this.identityBusy && !this.identityLoading) { void this.loadIdentity(); } } @@ -380,7 +384,11 @@ export class ProfilePage extends OpenClawLightDomElement { ${this.selfUser - ? html`` : nothing} From 28744126fcaacd76a55467f3f79fb1079dd8c7fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:01:46 -0700 Subject: [PATCH 105/239] fix(ui): restore mobile navigation and accessible usage filters (#116751) Co-authored-by: Peter Steinberger --- .../sessions-hub-header.browser.test.ts | 41 ++++++++++++++++--- ui/src/pages/usage/metrics.test.ts | 28 +++++++++++++ ui/src/pages/usage/metrics.ts | 7 +++- ui/src/styles/hub-tabs.css | 14 +++---- ui/src/styles/layout.mobile.css | 5 --- ui/src/styles/usage.css | 8 ++++ 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/ui/src/components/sessions-hub-header.browser.test.ts b/ui/src/components/sessions-hub-header.browser.test.ts index 125449e517ef..7bffa070c03b 100644 --- a/ui/src/components/sessions-hub-header.browser.test.ts +++ b/ui/src/components/sessions-hub-header.browser.test.ts @@ -1,5 +1,5 @@ import { html, render } from "lit"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import "../styles.css"; import { renderSessionsHubHeader } from "./sessions-hub-header.ts"; @@ -11,7 +11,11 @@ async function useViewport(width: number, height = 800) { await page.viewport(width, height); } -async function mount(active: "sessions" | "worktrees", withActions: boolean) { +async function mount( + active: "sessions" | "worktrees", + withActions: boolean, + onSelect: (tab: "sessions" | "worktrees") => void = () => undefined, +) { const container = document.createElement("div"); container.style.width = "calc(100vw - 32px)"; container.style.maxWidth = "1120px"; @@ -21,7 +25,7 @@ async function mount(active: "sessions" | "worktrees", withActions: boolean) { active, title: "Threads", actions: withActions ? html`
Agent selector
` : undefined, - onSelect: () => undefined, + onSelect, }), container, ); @@ -79,10 +83,35 @@ describe.skipIf(!hasBrowserLayout)("Sessions hub header browser layout", () => { }, ); - it("keeps the page header hidden on mobile", async () => { + it("keeps session navigation and operational headers available on mobile", async () => { await useViewport(414, 800); - const sessions = await mount("sessions", true); + const onSelect = vi.fn(); + const sessions = await mount("sessions", true, onSelect); const header = sessions.querySelector(".hub-page-header"); - expect(getComputedStyle(header!).display).toBe("none"); + const tabs = sessions.querySelector(".sessions-hub-tabs"); + const actions = sessions.querySelector(".hub-page-header__actions"); + expect(getComputedStyle(header!).display).toBe("grid"); + expect(tabs?.getBoundingClientRect().width).toBeGreaterThan(0); + expect(actions?.getBoundingClientRect().width).toBeGreaterThan(0); + + const worktreesTab = sessions.querySelector("#sessions-tab-worktrees"); + expect(worktreesTab?.getBoundingClientRect().width).toBeGreaterThan(0); + worktreesTab?.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + expect(onSelect).toHaveBeenCalledWith("worktrees"); + + const operationalHeader = document.createElement("section"); + operationalHeader.className = "content-header"; + operationalHeader.innerHTML = ''; + document.body.append(operationalHeader); + expect(getComputedStyle(operationalHeader).display).toBe("flex"); + expect( + operationalHeader.querySelector("button")?.getBoundingClientRect().width, + ).toBeGreaterThan(0); + + const chatContent = document.createElement("main"); + chatContent.className = "content content--chat"; + chatContent.innerHTML = '
'; + document.body.append(chatContent); + expect(getComputedStyle(chatContent.querySelector(".content-header")!).display).toBe("none"); }); }); diff --git a/ui/src/pages/usage/metrics.test.ts b/ui/src/pages/usage/metrics.test.ts index 9131cec172f4..4b2f106e2f32 100644 --- a/ui/src/pages/usage/metrics.test.ts +++ b/ui/src/pages/usage/metrics.test.ts @@ -358,6 +358,34 @@ describe("usage mosaic token buckets", () => { expect(container.querySelector(".usage-mosaic-total")?.textContent).toContain("10.0K"); }); + it("renders named, focusable hour toggles and preserves shift selection", () => { + const session = makeSessionWithTokenBuckets([ + { date: "2026-02-01", quarterIndex: 40, totalTokens: 10_000 }, + ]); + const onSelectHour = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render(renderUsageMosaic([session], "utc", [10], onSelectHour), container); + + const cells = container.querySelectorAll(".usage-hour-cell"); + const selectedHour = cells[10]; + const unselectedHour = cells[11]; + expect(selectedHour).toBeInstanceOf(HTMLButtonElement); + expect(selectedHour?.type).toBe("button"); + expect(selectedHour?.getAttribute("aria-label")).toBe("10:00 · 10.0K tokens"); + expect(selectedHour?.getAttribute("aria-pressed")).toBe("true"); + expect(unselectedHour?.getAttribute("aria-pressed")).toBe("false"); + + selectedHour?.focus(); + expect(document.activeElement).toBe(selectedHour); + selectedHour?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })); + expect(onSelectHour).toHaveBeenCalledWith(10, true); + unselectedHour?.click(); + expect(onSelectHour).toHaveBeenCalledWith(11, false); + + container.remove(); + }); + it("renders precise UTC buckets in their local hour", () => { vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) { return (this.getUTCHours() + 8) % 24; diff --git a/ui/src/pages/usage/metrics.ts b/ui/src/pages/usage/metrics.ts index 7ddd6cd67722..ce124d05e6d1 100644 --- a/ui/src/pages/usage/metrics.ts +++ b/ui/src/pages/usage/metrics.ts @@ -425,12 +425,15 @@ function renderUsageMosaic( : "color-mix(in srgb, var(--accent) 24%, transparent)"; const selected = selectedHours.includes(hour); return html` -
onSelectHour(hour, e.shiftKey)} - >
+ > `; })} diff --git a/ui/src/styles/hub-tabs.css b/ui/src/styles/hub-tabs.css index ab1cdef194c5..9a5501c8042d 100644 --- a/ui/src/styles/hub-tabs.css +++ b/ui/src/styles/hub-tabs.css @@ -120,11 +120,7 @@ wa-tab.hub-tab:focus-visible::part(base) { } @media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { - .content-header.sessions-hub-header { - display: none; - } - - .content-header.hub-page-header:not(.sessions-hub-header) { + .content-header.hub-page-header { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -136,20 +132,20 @@ wa-tab.hub-tab:focus-visible::part(base) { max-height: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__title { + .hub-page-header .hub-page-header__title { grid-area: intro; justify-self: stretch; } - .hub-page-header:not(.sessions-hub-header) .page-title { + .hub-page-header .page-title { display: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__tabs { + .hub-page-header .hub-page-header__tabs { grid-area: tabs; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__actions { + .hub-page-header .hub-page-header__actions { grid-area: actions; justify-self: center; } diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 61b515ca92b7..23cf82a4c37f 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -338,11 +338,6 @@ html.openclaw-native-macos body .shell--mobile-nav .topnav-shell__actions { font-size: 12px; } - /* Content */ - .content-header { - display: none; - } - /* Hide the entire content-header on mobile chat — controls are in mobile gear menu */ .content--chat .content-header { display: none; diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index a36a74de7fa3..4fb478307f2d 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -1158,6 +1158,9 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-hour-cell { min-height: 46px; + padding: 0; + cursor: pointer; + appearance: none; transition: transform 0.18s var(--ease-out), border-color 0.18s var(--ease-out), @@ -1169,6 +1172,11 @@ details.usage-filter-select summary::-webkit-details-marker, box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); } +.usage-hour-cell:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 2px; +} + .usage-hour-cell.selected { border-color: color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); From f061b82d116f4291755f5c4abb36066834b7fccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:05:16 -0700 Subject: [PATCH 106/239] fix(matrix): preserve message-tool room thread routing (#116802) Co-authored-by: Peter Steinberger --- .../src/channel.message-adapter.test.ts | 74 ++++++++ .../matrix/src/channel.threading.test.ts | 159 ++++++++++++++++++ extensions/matrix/src/channel.ts | 31 +++- extensions/matrix/src/session-route.test.ts | 30 ++++ extensions/matrix/src/session-route.ts | 2 + 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 extensions/matrix/src/channel.threading.test.ts diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 53798b5ae83b..3541cb8445dd 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("./matrix/send.js", () => ({ + editMessageMatrix: vi.fn(), + reactMatrixMessage: vi.fn(), + resolveMatrixRoomId: vi.fn(), sendMessageMatrix: mocks.sendMessageMatrix, sendPollMatrix: vi.fn(), sendTypingMatrix: vi.fn(), @@ -65,6 +68,77 @@ describe("matrix channel message adapter", () => { expect(matrixPlugin.meta.markdownCapable).toBe(true); }); + it.each([ + { + name: "the current room with reply quoting disabled", + to: "room:!room:example", + replyToMode: "off" as const, + expectedThreadId: "$thread", + }, + { + name: "an equivalent room target prefix", + to: "matrix:channel:!room:example", + replyToMode: "all" as const, + expectedThreadId: "$thread", + }, + { + name: "a different room", + to: "room:!another:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + { + name: "a direct user target without proven room identity", + to: "user:@alice:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + ])("routes a native Matrix message action in $name", async (testCase) => { + const threading = matrixPlugin.threading; + const handleAction = matrixPlugin.actions?.handleAction; + if (!threading?.resolveAutoThreadId || !handleAction) { + throw new Error("Expected Matrix threaded message action adapters"); + } + const toolContext = { + currentChannelProvider: "matrix" as const, + currentChannelId: "room:!room:example", + currentThreadTs: "$thread", + currentMessageId: "$reply", + replyToMode: testCase.replyToMode, + hasRepliedRef: { value: true }, + }; + const threadId = threading.resolveAutoThreadId({ + cfg, + accountId: "default", + to: testCase.to, + toolContext, + replyToId: "$explicit-reply", + }); + + await handleAction({ + cfg, + channel: "matrix", + action: "send", + accountId: "default", + toolContext, + params: { + to: testCase.to, + message: "threaded native action", + replyTo: "$explicit-reply", + ...(threadId ? { threadId } : {}), + }, + }); + + expect(mocks.sendMessageMatrix).toHaveBeenCalledOnce(); + expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe(testCase.to); + expect(lastMatrixSendOptions()).toMatchObject({ + cfg, + accountId: "default", + replyToId: "$explicit-reply", + threadId: testCase.expectedThreadId, + }); + }); + beforeEach(() => { mocks.sendMessageMatrix.mockReset(); mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" }); diff --git a/extensions/matrix/src/channel.threading.test.ts b/extensions/matrix/src/channel.threading.test.ts new file mode 100644 index 000000000000..8e665d2372bf --- /dev/null +++ b/extensions/matrix/src/channel.threading.test.ts @@ -0,0 +1,159 @@ +// Matrix threading tests keep room-affinity coverage isolated from account/env fixtures. +import { describe, expect, it } from "vitest"; +import { matrixPlugin } from "./channel.js"; +import type { CoreConfig } from "./types.js"; + +function requireMatrixAutoThreadIdResolver() { + const resolveAutoThreadId = matrixPlugin.threading?.resolveAutoThreadId; + if (!resolveAutoThreadId) { + throw new Error("expected Matrix automatic thread resolver"); + } + return resolveAutoThreadId; +} + +function requireMatrixToolContextTargetMatcher() { + const matchesToolContextTarget = matrixPlugin.threading?.matchesToolContextTarget; + if (!matchesToolContextTarget) { + throw new Error("expected Matrix tool context target matcher"); + } + return matchesToolContextTarget; +} + +describe("matrix message-tool threading", () => { + it.each([ + { + name: "the exact current room", + currentChannelId: "room:!room:example.org", + target: "room:!room:example.org", + expected: true, + }, + { + name: "an equivalent Matrix room prefix", + currentChannelId: "matrix:room:!room:example.org", + target: "channel:!room:example.org", + expected: true, + }, + { + name: "a raw current room id", + currentChannelId: "!room:example.org", + target: "matrix:room:!room:example.org", + expected: true, + }, + { + name: "a different room", + currentChannelId: "room:!room:example.org", + target: "room:!another:example.org", + expected: false, + }, + { + name: "a room alias without verified room resolution", + currentChannelId: "room:!room:example.org", + target: "#room:example.org", + expected: false, + }, + { + name: "a direct user target without verified room identity", + currentChannelId: "room:!dm:example.org", + target: "user:@alice:example.org", + expected: false, + }, + { + name: "a room id with different case", + currentChannelId: "room:!Room:example.org", + target: "room:!room:example.org", + expected: false, + }, + { + name: "two user targets rather than a room", + currentChannelId: "user:@alice:example.org", + target: "user:@alice:example.org", + expected: false, + }, + ])("only matches $name by canonical Matrix room identity", (testCase) => { + const toolContext = { + currentChannelId: testCase.currentChannelId, + currentThreadTs: "$thread", + replyToMode: "off" as const, + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: testCase.target, + toolContext, + }), + ).toBe(testCase.expected); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: testCase.target, + toolContext, + }), + ).toBe(testCase.expected ? "$thread" : undefined); + }); + + it.each(["off", "first", "all", "batched"] as const)( + "preserves an existing Matrix room thread when replyToMode is %s", + (replyToMode) => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + replyToId: "$reply", + toolContext: { + currentChannelId: "matrix:room:!room:example.org", + currentThreadTs: "$thread", + replyToMode, + hasRepliedRef: { value: true }, + }, + }), + ).toBe("$thread"); + }, + ); + + it("does not infer a Matrix room thread without an existing thread root", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext: { currentChannelId: "room:!room:example.org" }, + }), + ).toBeUndefined(); + }); + + it("does not inherit Matrix room threads from another channel provider", () => { + const toolContext = { + currentChannelProvider: "slack" as const, + currentChannelId: "room:!room:example.org", + currentThreadTs: "$thread", + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: "room:!room:example.org", + toolContext, + }), + ).toBe(false); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext, + }), + ).toBeUndefined(); + }); + + it("does not infer Matrix DM room identity from a matching user messaging target", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "user:@alice:example.org", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "room:!dm:example.org", + currentMessagingTarget: "user:@alice:example.org", + currentThreadTs: "$thread", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 4722fc91afd6..546bef41bd5f 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -4,7 +4,10 @@ import { adaptScopedAccountAccessor, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelDoctorAdapter, + ChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createChannelMessageAdapterFromOutbound, @@ -339,6 +342,24 @@ function resolveMatrixDeliveryTarget(params: { return null; } +function matchesMatrixToolContextRoom(params: { + target: string; + toolContext: ChannelThreadingToolContext; +}): boolean { + const { toolContext } = params; + if (toolContext.currentChannelProvider && toolContext.currentChannelProvider !== "matrix") { + return false; + } + const currentTarget = toolContext.currentChannelId + ? resolveMatrixTargetIdentity(toolContext.currentChannelId) + : null; + const target = resolveMatrixTargetIdentity(params.target); + // A Matrix user target can select a different DM room; only verified room IDs may share threads. + return ( + currentTarget?.kind === "room" && target?.kind === "room" && currentTarget.id === target.id + ); +} + const matrixChannelOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkTextForOutbound, @@ -665,6 +686,14 @@ export const matrixPlugin: ChannelPlugin = ), }, threading: { + matchesToolContextTarget: matchesMatrixToolContextRoom, + resolveAutoThreadId: ({ to, toolContext }) => { + const threadId = normalizeOptionalString(toolContext?.currentThreadTs); + if (!threadId || !toolContext) { + return undefined; + } + return matchesMatrixToolContextRoom({ target: to, toolContext }) ? threadId : undefined; + }, resolveReplyToMode: createScopedAccountReplyToModeResolver< ReturnType >({ diff --git a/extensions/matrix/src/session-route.test.ts b/extensions/matrix/src/session-route.test.ts index 7c5292ffb587..6738c43a332c 100644 --- a/extensions/matrix/src/session-route.test.ts +++ b/extensions/matrix/src/session-route.test.ts @@ -296,6 +296,36 @@ describe("resolveMatrixOutboundSessionRoute", () => { expect(channelRoute.threadId).toBe("$RootEvent:Example.Org"); }); + it.each([ + { + name: "uses the Matrix thread root when replying to a child event", + threadId: "$ThreadRoot:Example.Org", + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ThreadRoot:Example.Org", + }, + { + name: "keeps reply-only session routing when no Matrix thread exists", + threadId: undefined, + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ReplyChild:Example.Org", + }, + ])("$name", ({ threadId, replyToId, expectedThreadId }) => { + const route = expectRoute( + resolveMatrixOutboundSessionRoute({ + cfg: {}, + agentId: "main", + target: "room:!ops:example.org", + threadId, + replyToId, + }), + ); + + expect(route.threadId).toBe(expectedThreadId); + expect(route.sessionKey).toBe( + `agent:main:matrix:channel:!ops:example.org:thread:${expectedThreadId}`, + ); + }); + it("does not claim room aliases as canonical inbound session ids", () => { const route = resolveMatrixOutboundSessionRoute({ cfg: {}, diff --git a/extensions/matrix/src/session-route.ts b/extensions/matrix/src/session-route.ts index c4006262f8ad..50269d116017 100644 --- a/extensions/matrix/src/session-route.ts +++ b/extensions/matrix/src/session-route.ts @@ -121,6 +121,8 @@ export function resolveMatrixOutboundSessionRoute(params: ChannelOutboundSession replyToId: params.replyToId, threadId: params.threadId, currentSessionKey: params.currentSessionKey, + // Matrix m.thread identifies the session; m.in_reply_to may name a different child event. + precedence: ["threadId", "replyToId", "currentSession"], normalizeThreadId: (threadId) => threadId, canRecoverCurrentThread: ({ route }) => route.peer.kind !== "direct" || (params.cfg.session?.dmScope ?? "main") !== "main", From c2b0def1375e20c7776f34850f55ee1b961ff860 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:08:10 -0700 Subject: [PATCH 107/239] fix(ollama): stream native tool call lifecycle (#116809) Co-authored-by: Peter Steinberger --- extensions/ollama/src/stream-runtime.test.ts | 247 ++++++++++++++++++- extensions/ollama/src/stream.test.ts | 4 +- extensions/ollama/src/stream.ts | 94 +++++-- 3 files changed, 312 insertions(+), 33 deletions(-) diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 01926cd5444c..737546be70d1 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1747,7 +1747,7 @@ describe("createOllamaStreamFn streaming events", () => { ); }); - it("emits only done for tool-call-only responses (no text content)", async () => { + it("streams the complete lifecycle for tool-call-only responses", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', @@ -1757,12 +1757,36 @@ describe("createOllamaStreamFn streaming events", () => { const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }); const events = await collectStreamEvents(stream); - // No text content means no start/text_start/text_delta/text_end events const types = events.map((e) => e.type); - expect(types).toEqual(["done"]); - const doneEvent = requireEntry(events, 0, "tool-call-only done event"); + expect(types).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[1]).toMatchObject({ + type: "toolcall_start", + contentIndex: 0, + partial: { content: [{ type: "toolCall", name: "bash", arguments: {} }] }, + }); + expect(events[2]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 0, + delta: '{"command":"ls"}', + }); + expect(events[3]).toMatchObject({ + type: "toolcall_end", + contentIndex: 0, + toolCall: { name: "bash", arguments: { command: "ls" } }, + }); + const doneEvent = requireEntry(events, 4, "tool-call-only done event"); if (doneEvent.type === "done") { expect(doneEvent.reason).toBe("toolUse"); + expect(doneEvent.message.content[0]).toMatchObject({ + type: "toolCall", + id: events[3]?.type === "toolcall_end" ? events[3].toolCall.id : undefined, + }); } }, ); @@ -1839,7 +1863,21 @@ describe("createOllamaStreamFn streaming events", () => { const events = await collectStreamEvents(stream); const types = events.map((e) => e.type); - expect(types).toEqual(["start", "text_start", "text_delta", "text_end", "done"]); + expect(types).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[5]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 1, + delta: '{"command":"ls"}', + }); const doneEvent = events.at(-1); if (doneEvent?.type === "done") { expect(doneEvent.reason).toBe("toolUse"); @@ -1848,6 +1886,104 @@ describe("createOllamaStreamFn streaming events", () => { ); }); + it("streams multiple native calls with stable provider ids across chunks", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-read","function":{"name":"read","arguments":{"path":"/tmp/a"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-bash","function":{"name":"bash","arguments":"{\\"command\\":\\"ls\\"}"}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + const toolCallEnds = events.filter((event) => event.type === "toolcall_end"); + expect(toolCallEnds).toMatchObject([ + { + contentIndex: 0, + toolCall: { id: "call-read", name: "read", arguments: { path: "/tmp/a" } }, + }, + { + contentIndex: 1, + toolCall: { id: "call-bash", name: "bash", arguments: { command: "ls" } }, + }, + ]); + expect(events.filter((event) => event.type === "toolcall_delta")).toMatchObject([ + { contentIndex: 0, delta: '{"path":"/tmp/a"}' }, + { contentIndex: 1, delta: '{"command":"ls"}' }, + ]); + expect(events.filter((event) => event.type === "toolcall_start")).toMatchObject([ + { partial: { content: [{ arguments: {} }] } }, + { + partial: { + content: [{ arguments: { path: "/tmp/a" } }, { arguments: {} }], + }, + }, + ]); + const done = events.at(-1); + if (done?.type !== "done") { + throw new Error("missing terminal Ollama message"); + } + expect(done.message.content).toMatchObject([ + { type: "toolCall", id: "call-read" }, + { type: "toolCall", id: "call-bash" }, + ]); + }, + ); + }); + + it("does not stream non-executable calls from a token-limited final chunk", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + + it("never exposes an intermediate native call invalidated by a later length terminal", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + it("emits text_end as soon as Ollama switches from text to tool calls", async () => { const controlledFetch = createControlledNdjsonFetch(); fetchWithSsrFGuardMock.mockImplementation(controlledFetch.fetchImpl); @@ -1899,6 +2035,20 @@ describe("createOllamaStreamFn streaming events", () => { ); controlledFetch.close(); + const toolCallStartEvent = await nextEventWithin(iterator); + const toolCallDeltaEvent = await nextEventWithin(iterator); + const toolCallEndEvent = await nextEventWithin(iterator); + expect(toolCallStartEvent).not.toBe("timeout"); + expect(toolCallDeltaEvent).not.toBe("timeout"); + expect(toolCallEndEvent).not.toBe("timeout"); + expectIteratorEvent(toolCallStartEvent, { type: "toolcall_start", done: false }); + expectIteratorEvent(toolCallDeltaEvent, { + type: "toolcall_delta", + delta: '{"command":"ls"}', + done: false, + }); + expectIteratorEvent(toolCallEndEvent, { type: "toolcall_end", done: false }); + const doneEvent = await nextEventWithin(iterator); expect(doneEvent).not.toBe("timeout"); if (doneEvent !== "timeout" && doneEvent.done === false) { @@ -2343,7 +2493,14 @@ describe("createOllamaStreamFn streaming events", () => { }); const events = await collectStreamEvents(stream); - expect(events.map((e) => e.type)).toEqual(["done"]); + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(JSON.stringify(events)).not.toContain("I should think privately"); const doneEvent = events.at(-1); expect(doneEvent?.type).toBe("done"); if (doneEvent?.type === "done") { @@ -2360,6 +2517,84 @@ describe("createOllamaStreamFn streaming events", () => { }, ); }); + + it("flushes buffered visible Kimi text before streaming its native tool call", async () => { + await withMockNdjsonFetch( + [ + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(events[4]).toMatchObject({ type: "toolcall_start", contentIndex: 1 }); + expect(events[6]).toMatchObject({ type: "toolcall_end", contentIndex: 1 }); + expect(events.at(-1)).toMatchObject({ + type: "done", + message: { + content: [ + { type: "text", text: "Visible answer" }, + { type: "toolCall", name: "bash" }, + ], + }, + }); + }, + ); + }); + + it("does not reveal buffered Kimi reasoning for an empty tool-call chunk", async () => { + const hiddenPrefix = + "I should think privately and not leak this planning text in the answer. " + + "I need to keep deciding what to say next."; + await withMockNdjsonFetch( + [ + JSON.stringify({ + model: "kimi-k2.6:cloud", + created_at: "t", + message: { role: "assistant", content: hiddenPrefix }, + done: false, + }), + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":" ️ Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(JSON.stringify(events)).not.toContain("I should think privately"); + }, + ); + }); }); describe("createOllamaStreamFn", () => { diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index e8aa0ec4532b..ebe011d5393f 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -325,9 +325,7 @@ describe("createOllamaStreamFn thinking events", () => { }; expect(done.reason).toBe("length"); expect(done.message?.stopReason).toBe("length"); - expect(done.message?.content).toEqual([ - expect.objectContaining({ type: "toolCall", name: "read" }), - ]); + expect(done.message?.content).toEqual([]); }); it("uses generic stream timeout for Ollama request timeout", async () => { diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 01ef98b8f216..508bc7edd9a3 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1262,6 +1262,7 @@ function createRawOllamaStreamFn( let accumulatedThinking = ""; let suppressedThinking = ""; const accumulatedToolCalls: OllamaToolCall[] = []; + const streamedToolCalls: ToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; let pendingFinalVisibleContent: string | undefined; const modelInfo = { @@ -1291,9 +1292,24 @@ function createRawOllamaStreamFn( if (accumulatedVisibleContent) { parts.push({ type: "text", text: accumulatedVisibleContent }); } + parts.push(...streamedToolCalls); return parts; }; + const ensureStreamStarted = () => { + if (streamStarted) { + return; + } + streamStarted = true; + const emptyPartial = buildStreamAssistantMessage({ + model: modelInfo, + content: [], + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "start", partial: emptyPartial }); + }; + const closeThinkingBlock = () => { if (!thinkingStarted || thinkingEnded) { return; @@ -1345,16 +1361,7 @@ function createRawOllamaStreamFn( closeThinkingBlock(); } - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!textBlockStarted) { textBlockStarted = true; const partial = buildStreamAssistantMessage({ @@ -1392,16 +1399,7 @@ function createRawOllamaStreamFn( refreshTimeout?.(); const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning; if (thinkingDelta && shouldEmitThinking) { - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!thinkingStarted) { thinkingStarted = true; const partial = buildStreamAssistantMessage({ @@ -1435,10 +1433,18 @@ function createRawOllamaStreamFn( accumulatedRawContent += rawDelta; flushVisibleText(resolveVisibleContent(false)); } - if (chunk.message?.tool_calls) { + if (chunk.message?.tool_calls?.length) { + // Kimi holds short visible prefixes until a terminal boundary; + // settle them now so later tool indices cannot overwrite text. + flushVisibleText(resolveVisibleContent(true)); closeThinkingBlock(); closeTextBlock(); - accumulatedToolCalls.push(...chunk.message.tool_calls); + for (const rawToolCall of chunk.message.tool_calls) { + // Ollama can report a length stop in a later chunk, so no call + // becomes executable until its authoritative terminal arrives. + const id = readOllamaToolCallId(rawToolCall.id) ?? `ollama_call_${randomUUID()}`; + accumulatedToolCalls.push({ ...rawToolCall, id }); + } } if (chunk.done) { pendingFinalVisibleContent = resolveVisibleContent(true); @@ -1473,7 +1479,11 @@ function createRawOllamaStreamFn( if (accumulatedThinking) { finalResponse.message.thinking = accumulatedThinking; } - if (accumulatedToolCalls.length > 0) { + if (finalResponse.done_reason === "length") { + // All consumers inspect terminal content, not only lifecycle events; + // a token-limit stop must never retain an executable-looking call. + delete finalResponse.message.tool_calls; + } else if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } @@ -1491,9 +1501,45 @@ function createRawOllamaStreamFn( closeThinkingBlock(); closeTextBlock(); + const reason = resolveOllamaStopReason(finalResponse); + if (reason === "toolUse") { + for (const completedToolCall of assistantMessage.content) { + if (completedToolCall.type !== "toolCall") { + continue; + } + ensureStreamStarted(); + const placeholder: ToolCall = { ...completedToolCall, arguments: {} }; + streamedToolCalls.push(placeholder); + const contentIndex = buildCurrentContent().length - 1; + const partial = () => + buildStreamAssistantMessage({ + model: modelInfo, + content: buildCurrentContent(), + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "toolcall_start", contentIndex, partial: partial() }); + // Replace the placeholder instead of mutating it: queued start + // snapshots must not see arguments before their delta arrives. + streamedToolCalls[streamedToolCalls.length - 1] = completedToolCall; + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(completedToolCall.arguments), + partial: partial(), + }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: completedToolCall, + partial: partial(), + }); + } + } + stream.push({ type: "done", - reason: resolveOllamaStopReason(finalResponse), + reason, message: assistantMessage, }); } finally { From 410c1633283d728e87e3a392964e41c03c615ccd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:08:22 -0700 Subject: [PATCH 108/239] fix(browser): preserve doctor JSON failure status (#116811) * fix(browser): preserve doctor JSON exit status * fix(browser): defer doctor failure exit * test(browser): harden doctor JSON assertions --- .../src/cli/browser-cli-manage.test.ts | 91 ++++++++++++++++++- .../browser/src/cli/browser-cli-manage.ts | 7 +- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index f6f8f5eaba7f..c3867f5d9438 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -1,5 +1,5 @@ // Browser tests cover browser cli manage plugin behavior. -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createBrowserManageProgram, getBrowserManageCallBrowserRequestMock, @@ -15,10 +15,26 @@ function lastRuntimeLog(): string { return value; } +function parseSingleRuntimeJson(): unknown { + const logs = getBrowserCliRuntimeCapture().runtimeLogs; + expect(logs).toHaveLength(1); + return JSON.parse(logs[0] ?? ""); +} + describe("browser manage output", () => { + let previousExitCode: typeof process.exitCode; + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; getBrowserManageCallBrowserRequestMock().mockClear(); getBrowserCliRuntimeCapture().resetRuntimeCapture(); + getBrowserCliRuntime().exit.mockClear(); + getBrowserCliRuntime().writeJson.mockClear(); + }); + + afterEach(() => { + process.exitCode = previousExitCode; }); it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => { @@ -524,6 +540,72 @@ describe("browser manage output", () => { expect(output).toContain("OK gateway: browser control endpoint reachable"); expect(output).toContain("OK graphics: software"); expect(output).toContain("OK tabs: 1 visible, use tab reference t1"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints one complete JSON browser doctor failure before setting exit status", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: false, + profile: "openclaw", + transport: "cdp", + running: false, + }; + } + if (req.path === "/profiles") { + return { profiles: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toEqual( + expect.objectContaining({ + ok: false, + checks: expect.arrayContaining([ + expect.objectContaining({ name: "gateway", ok: true }), + expect.objectContaining({ name: "plugin", ok: false }), + ]), + }), + ); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("prints one JSON browser doctor report and succeeds when every check passes", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: true, + profile: "openclaw", + transport: "cdp", + running: true, + }; + } + if (req.path === "/profiles") { + return { profiles: [{ name: "openclaw", running: true }] }; + } + if (req.path === "/tabs") { + return { running: true, tabs: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toMatchObject({ ok: true }); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); }); it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => { @@ -534,9 +616,7 @@ describe("browser manage output", () => { getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error); const program = createBrowserManageProgram(); - await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow( - "__exit__:1", - ); + await program.parseAsync(["browser", "doctor"], { from: "user" }); const output = lastRuntimeLog(); expect(output).toContain( @@ -544,5 +624,8 @@ describe("browser manage output", () => { ); expect(output).toContain("OPENCLAW_GATEWAY_TOKEN"); expect(output).not.toContain("GatewaySecretRefUnavailableError"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 0071eda3e73a..1ba6fd525eeb 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -413,12 +413,11 @@ export function registerBrowserManageCommands( const profile = parent?.browserProfile; await runBrowserCommand(async () => { const result = await runBrowserDoctor(parent, profile, opts.deep === true); - if (printJsonResult(parent, result)) { - return; + if (!printJsonResult(parent, result)) { + defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); } - defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); if (!result.ok) { - defaultRuntime.exit(1); + process.exitCode = 1; } }); }); From 7af2bb62622e1999c0dc2ecc7ef7bf4290987894 Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Fri, 31 Jul 2026 20:09:28 +0800 Subject: [PATCH 109/239] fix(file-transfer): keep fetched media attachable in sandboxed replies (#116400) Fixes #116338 Co-authored-by: wangmiao0668000666 --- .../file-transfer/src/tools/descriptors.ts | 6 +- .../src/tools/dir-fetch-tool.test.ts | 19 +++- .../file-transfer/src/tools/dir-fetch-tool.ts | 2 +- .../src/tools/file-fetch-tool.test.ts | 13 +-- .../src/tools/file-write-tool.test.ts | 38 +++++++- src/agents/sandbox-paths.test.ts | 96 ++++++++++++++----- 6 files changed, 134 insertions(+), 40 deletions(-) diff --git a/extensions/file-transfer/src/tools/descriptors.ts b/extensions/file-transfer/src/tools/descriptors.ts index b7bb1ea9ab38..4c0675168252 100644 --- a/extensions/file-transfer/src/tools/descriptors.ts +++ b/extensions/file-transfer/src/tools/descriptors.ts @@ -8,9 +8,9 @@ type FileTransferToolDescriptor = Pick< "label" | "name" | "description" | "parameters" >; -// Stash fetched files in a non-TTL subdir so follow-up tool calls within -// the same turn can still reference them. -export const FILE_TRANSFER_SUBDIR = "file-transfer"; +// Keep fetched files in the managed tool-media namespace so sandboxed replies +// can attach them and follow-up file_write calls can reuse the media id. +export const FILE_TRANSFER_SUBDIR = "tool-file-transfer"; export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts index 656d5880122b..8fa3cd1a20dc 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DIR_FETCH_HARD_MAX_BYTES, FILE_TRANSFER_SUBDIR } from "./descriptors.js"; let tmpRoot: string; @@ -37,12 +38,13 @@ async function createTarBuffer(params: { async function importTool(tarBuffer: Buffer) { const archivePath = path.join(tmpRoot, `archive-${randomUUID()}.tar.gz`); const appendFileTransferAudit = vi.fn(async () => undefined); + const saveMediaBuffer = vi.fn(async () => { + await fs.writeFile(archivePath, tarBuffer); + return { path: archivePath }; + }); vi.resetModules(); vi.doMock("openclaw/plugin-sdk/media-store", () => ({ - saveMediaBuffer: vi.fn(async () => { - await fs.writeFile(archivePath, tarBuffer); - return { path: archivePath }; - }), + saveMediaBuffer, })); vi.doMock("../shared/audit.js", () => ({ appendFileTransferAudit })); vi.doMock("./node-tool-invoke.js", () => ({ @@ -67,6 +69,7 @@ async function importTool(tarBuffer: Buffer) { return { archivePath, appendFileTransferAudit, + saveMediaBuffer, module: await import("./dir-fetch-tool.js"), }; } @@ -86,7 +89,7 @@ describe("dir.fetch archive extraction", () => { await fs.writeFile(path.join(sourceDir, "ok.txt"), "ok"); }, }); - const { appendFileTransferAudit, module } = await importTool(tarBuffer); + const { appendFileTransferAudit, module, saveMediaBuffer } = await importTool(tarBuffer); const result = await executeDirFetch(module); @@ -106,6 +109,12 @@ describe("dir.fetch archive extraction", () => { const localPath = (result.details as { files: Array<{ localPath: string }> }).files[0] ?.localPath; await expect(fs.readFile(localPath!, "utf8")).resolves.toBe("ok"); + expect(saveMediaBuffer).toHaveBeenCalledWith( + tarBuffer, + "application/gzip", + FILE_TRANSFER_SUBDIR, + DIR_FETCH_HARD_MAX_BYTES, + ); expect(appendFileTransferAudit).toHaveBeenLastCalledWith( expect.objectContaining({ decision: "allowed" }), ); diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 265d6369e4dc..9c196a41d0d5 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -169,7 +169,7 @@ export function createDirFetchTool(): AnyAgentTool { throw new Error("dir.fetch sha256 mismatch (integrity failure)"); } - // Save tarball under the file-transfer subdir (no 2-min TTL). + // Keep the tarball and extracted paths under the same managed tool namespace. const savedTar = await saveMediaBuffer( tarBuffer, "application/gzip", diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts index 221872335c2f..c64bf4154b25 100644 --- a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { FILE_TRANSFER_SUBDIR } from "./descriptors.js"; import { createFileFetchTool } from "./file-fetch-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -57,7 +58,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/report.md", + path: "/gateway/media/tool-file-transfer/report.md", size: Buffer.byteLength(fileText), contentType: "text/markdown", }); @@ -95,7 +96,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/bom.md", + path: "/gateway/media/tool-file-transfer/bom.md", size: originalBuffer.byteLength, contentType: "text/markdown", }); @@ -111,7 +112,7 @@ describe("file_fetch tool", () => { expect(saveMediaBuffer).toHaveBeenCalledWith( originalBuffer, "text/markdown", - expect.any(String), + FILE_TRANSFER_SUBDIR, expect.any(Number), ); const details = result.details as { sha256: string; size: number }; @@ -134,7 +135,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/empty.png", + path: "/gateway/media/tool-file-transfer/empty.png", size: 0, contentType: "image/png", }); @@ -148,7 +149,7 @@ describe("file_fetch tool", () => { expect(result.content[0]?.type).toBe("text"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).toContain("Fetched /tmp/empty.png"); - expect(text).toContain("saved at /gateway/media/file-transfer/empty.png"); + expect(text).toContain("saved at /gateway/media/tool-file-transfer/empty.png"); }); it("still inlines a non-empty image payload", async () => { @@ -167,7 +168,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/photo.png", + path: "/gateway/media/tool-file-transfer/photo.png", size: buffer.byteLength, contentType: "image/png", }); diff --git a/extensions/file-transfer/src/tools/file-write-tool.test.ts b/extensions/file-transfer/src/tools/file-write-tool.test.ts index add0520f4ee3..d34cb7177343 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.test.ts @@ -1,12 +1,14 @@ // File Transfer tests cover file write tool plugin behavior. +import crypto from "node:crypto"; import { callGatewayTool, listNodes, resolveNodeIdFromList, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { humanSize } from "../shared/params.js"; -import { FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; +import { FILE_TRANSFER_SUBDIR, FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; import { createFileWriteTool } from "./file-write-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -101,4 +103,38 @@ describe("file_write tool", () => { expect(callGatewayTool).toHaveBeenCalledOnce(); }); + + it("reads file_fetch media from the shared managed tool namespace", async () => { + const buffer = Buffer.from("copied"); + vi.mocked(readMediaBuffer).mockResolvedValue({ + id: "media-1", + buffer, + path: "/gateway/media/tool-file-transfer/media-1.bin", + size: buffer.byteLength, + }); + vi.mocked(listNodes).mockResolvedValue([{ nodeId: "node-1", displayName: "Node 1" }]); + vi.mocked(resolveNodeIdFromList).mockReturnValue("node-1"); + vi.mocked(callGatewayTool).mockResolvedValue({ + payload: { + ok: true, + path: "/tmp/out.bin", + size: buffer.byteLength, + sha256: crypto.createHash("sha256").update(buffer).digest("hex"), + overwritten: false, + }, + }); + + const result = await createFileWriteTool().execute("tool-call-1", { + node: "node-1", + path: "/tmp/out.bin", + sourceMediaId: "media-1", + }); + + expect(readMediaBuffer).toHaveBeenCalledWith( + "media-1", + FILE_TRANSFER_SUBDIR, + FILE_WRITE_HARD_MAX_BYTES, + ); + expect(result.details).toMatchObject({ source: "media", size: buffer.byteLength }); + }); }); diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 102d9bfd8272..79d5f940feeb 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -41,6 +41,7 @@ async function withManagedMediaRoot(run: (ctx: { stateDir: string }) => Promi try { return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { await fs.mkdir(path.join(stateDir, "media", "outbound"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "media", "tool-file-transfer"), { recursive: true }); await fs.mkdir(path.join(stateDir, "media", "tool-image-generation"), { recursive: true }); return await run({ stateDir }); }); @@ -242,6 +243,10 @@ describe("resolveSandboxedMediaSource", () => { name: "managed outbound media", relative: path.join("media", "outbound", "reply.png"), }, + { + name: "managed file-transfer tool media", + relative: path.join("media", "tool-file-transfer", "fetched.png"), + }, { name: "managed tool media", relative: path.join("media", "tool-image-generation", "generated.png"), @@ -475,47 +480,90 @@ describe("resolveSandboxedMediaSource", () => { ); }); - it("rejects symlinked managed media paths escaping the managed media root", async () => { - if (process.platform === "win32") { - return; - } - await withManagedMediaRoot(async ({ stateDir }) => { - await withSandboxRoot(async (sandboxDir) => { + it.each(["outbound", "tool-file-transfer"])( + "rejects symlinked managed media paths escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); + const outsideFile = path.join(outsideDir, "secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.symlink(outsideFile, symlinkPath); + + await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + } finally { + await fs.rm(symlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + }, + ); + + it.each(["outbound", "tool-file-transfer"])( + "rejects checked managed media symlinks escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); try { await fs.writeFile(outsideFile, "secret", "utf8"); await fs.symlink(outsideFile, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( + /managed media root|symlink/i, + ); } finally { await fs.rm(symlinkPath, { force: true }); await fs.rm(outsideDir, { recursive: true, force: true }); } }); - }); - }); + }, + ); - it("rejects checked managed media symlinks escaping the managed media root", async () => { + it("rejects hardlinked file-transfer media that aliases a file outside managed media", async () => { if (process.platform === "win32") { return; } await withManagedMediaRoot(async ({ stateDir }) => { - const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); - const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); - try { - await fs.writeFile(outsideFile, "secret", "utf8"); - await fs.symlink(outsideFile, symlinkPath); - - await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( - /managed media root|symlink/i, + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp( + path.join(path.dirname(stateDir), "managed-media-hardlink-outside-"), ); - } finally { - await fs.rm(symlinkPath, { force: true }); - await fs.rm(outsideDir, { recursive: true, force: true }); - } + const outsideFile = path.join(outsideDir, "secret.png"); + const hardlinkPath = path.join( + stateDir, + "media", + "tool-file-transfer", + "linked-secret.png", + ); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + + await expect(resolveAllowedManagedMediaPath(hardlinkPath)).rejects.toThrow(/hard.?link/i); + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|managed media root/i); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); }); }); From a91d92796fd3c6ff31230de9337d895ecb8fbb43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:11:15 -0700 Subject: [PATCH 110/239] fix(whatsapp): preserve interactive replies and normalize media MIME (#116816) Co-authored-by: Peter Steinberger --- .../whatsapp/src/inbound/extract.test.ts | 122 +++++++++++++++++- extensions/whatsapp/src/inbound/extract.ts | 35 ++++- .../whatsapp/src/inbound/send-api.test.ts | 20 +++ .../whatsapp/src/outbound-media-contract.ts | 4 +- extensions/whatsapp/src/send.test.ts | 9 +- 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/extensions/whatsapp/src/inbound/extract.test.ts b/extensions/whatsapp/src/inbound/extract.test.ts index 484e621bbd0d..7e5a7b8a6566 100644 --- a/extensions/whatsapp/src/inbound/extract.test.ts +++ b/extensions/whatsapp/src/inbound/extract.test.ts @@ -1,7 +1,12 @@ // Whatsapp tests cover extract plugin behavior. import type { proto } from "baileys"; import { describe, expect, it } from "vitest"; -import { describeReplyContext, extractMentionedJids, hasInboundUserContent } from "./extract.js"; +import { + describeReplyContext, + extractMentionedJids, + extractText, + hasInboundUserContent, +} from "./extract.js"; describe("extractMentionedJids", () => { const botJid = "5511999999999@s.whatsapp.net"; @@ -153,6 +158,121 @@ describe("describeReplyContext", () => { }); }); +describe("extractText", () => { + it.each([ + { + name: "button display text", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: "Yes" }, + }, + expected: "Yes", + }, + { + name: "button identifier when display text is unavailable", + message: { buttonsResponseMessage: { selectedButtonId: "yes" } }, + expected: "yes", + }, + { + name: "button identifier when display text is blank", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: " " }, + }, + expected: "yes", + }, + { + name: "list selection title", + message: { + listResponseMessage: { title: "Option A", singleSelectReply: { selectedRowId: "a" } }, + }, + expected: "Option A", + }, + { + name: "list row identifier when its title is unavailable", + message: { listResponseMessage: { singleSelectReply: { selectedRowId: "a" } } }, + expected: "a", + }, + { + name: "template button display text", + message: { + templateButtonReplyMessage: { selectedId: "button-1", selectedDisplayText: "Confirm" }, + }, + expected: "Confirm", + }, + { + name: "template button identifier when display text is unavailable", + message: { templateButtonReplyMessage: { selectedId: "button-1" } }, + expected: "button-1", + }, + { + name: "interactive response body", + message: { + interactiveResponseMessage: { + body: { text: "Continue" }, + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{}" }, + }, + }, + expected: "Continue", + }, + { + name: "native-flow selection title when the interactive body is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express","title":"Express shipping"}', + }, + }, + }, + expected: "Express shipping", + }, + { + name: "native-flow selection identifier when its title is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express"}', + }, + }, + }, + expected: "shipping-express", + }, + { + name: "ephemeral button response", + message: { + ephemeralMessage: { + message: { + buttonsResponseMessage: { selectedButtonId: "ok", selectedDisplayText: "OK" }, + }, + }, + }, + expected: "OK", + }, + ])("preserves $name as inbound message text", ({ message, expected }) => { + expect(extractText(message as proto.IMessage)).toBe(expected); + }); + + it("ignores malformed native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); + + it("ignores non-record native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "[]" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); +}); + describe("hasInboundUserContent", () => { it("returns true for plain text conversation", () => { expect(hasInboundUserContent({ conversation: "hello" })).toBe(true); diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 5ea7312c3c75..2b0a45d7c580 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -7,7 +7,7 @@ import { type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js"; import { jidToE164 } from "../text-runtime.js"; import { parseVcard } from "../vcard.js"; @@ -136,6 +136,26 @@ export function extractMentionedJids(rawMessage: proto.IMessage | undefined): st return uniqueStrings(flattened); } +function extractNativeFlowResponseText( + response: proto.Message.IInteractiveResponseMessage | null | undefined, +): string | undefined { + const paramsJson = response?.nativeFlowResponseMessage?.paramsJson; + if (!paramsJson) { + return undefined; + } + try { + const params: unknown = JSON.parse(paramsJson); + if (!isRecord(params)) { + return undefined; + } + return [params.title, params.id].find( + (value): value is string => typeof value === "string" && Boolean(value.trim()), + ); + } catch { + return undefined; + } +} + export function extractText(rawMessage: proto.IMessage | undefined): string | undefined { const message = unwrapMessage(rawMessage); if (!message) { @@ -161,6 +181,19 @@ export function extractText(rawMessage: proto.IMessage | undefined): string | un if (caption?.trim()) { return caption.trim(); } + const interactiveSelection = [ + candidate.buttonsResponseMessage?.selectedDisplayText, + candidate.buttonsResponseMessage?.selectedButtonId, + candidate.listResponseMessage?.title, + candidate.listResponseMessage?.singleSelectReply?.selectedRowId, + candidate.templateButtonReplyMessage?.selectedDisplayText, + candidate.templateButtonReplyMessage?.selectedId, + candidate.interactiveResponseMessage?.body?.text, + extractNativeFlowResponseText(candidate.interactiveResponseMessage), + ].find((value) => Boolean(value?.trim())); + if (interactiveSelection) { + return interactiveSelection.trim(); + } } const contactPlaceholder = extractContactPlaceholder(message) ?? diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index 852d4dd01c10..feff6a7e6253 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage } from "baileys"; import { listMessageReceiptPlatformIds } from "openclaw/plugin-sdk/channel-outbound"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prepareWhatsAppOutboundMedia } from "../outbound-media-contract.js"; import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; import { createWebSendApi } from "./send-api.js"; import type { WhatsAppSendResult } from "./send-result.js"; @@ -329,6 +330,25 @@ describe("createWebSendApi", () => { }); }); + it.each([ + { kind: "image", contentType: " Image/PNG; charset=binary ", mimetype: "image/png" }, + { kind: "video", contentType: " Video/MP4; charset=binary ", mimetype: "video/mp4" }, + ])( + "preserves the native $kind payload after canonicalizing mixed-case media MIME", + async ({ kind, contentType, mimetype }) => { + const payload = Buffer.from(kind); + const media = await prepareWhatsAppOutboundMedia({ buffer: payload, contentType }); + + await api.sendMessage("+1555", "cap", media.buffer, media.mimetype); + + expectSendContentFields(0, { + [kind]: payload, + caption: "cap", + mimetype, + }); + }, + ); + it("prepopulates image thumbnails and dimensions before Baileys media upload", async () => { const payload = Buffer.from("img"); const thumbnail = Buffer.from("thumb"); diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index 3d487bf308a9..1d62c30c7d63 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -152,8 +152,8 @@ function normalizeWhatsAppLoadedMedia( const normalizedContentType = normalizeMimeType(media.contentType); const resolvedContentType = !normalizedContentType || normalizedContentType === "application/octet-stream" - ? (filenameMimeType ?? media.contentType) - : media.contentType; + ? (filenameMimeType ?? normalizedContentType) + : normalizedContentType; const kind = inferWhatsAppMediaKind(media, resolvedContentType); // Match the existing URL/filename voice rule used by the transcode decision; // otherwise native .ogg/.opus uploads carry an inconsistent payload MIME. diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index e625c381016b..00102bb500d5 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -409,7 +409,7 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenNthCalledWith(2, "+1555", "voice note", undefined, undefined); }); - it("normalizes MIME parameters when inferring media kind", async () => { + it("normalizes MIME parameters before handing media to the socket transport", async () => { const buf = Buffer.from("image"); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, @@ -422,12 +422,7 @@ describe("web outbound", () => { mediaUrl: "/tmp/image.png", }); - expect(sendMessage).toHaveBeenLastCalledWith( - "+1555", - "caption", - buf, - " Image/PNG; charset=binary ", - ); + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/png"); }); it("reports the accepted voice send before a caption failure", async () => { From b9e55935f879d04543969de3e958f8276fd368b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:13:01 -0700 Subject: [PATCH 111/239] fix(telegram): preserve edit previews, message cache, and group history (#116818) * test(telegram): cover edited message preview and cache ownership * fix(telegram): preserve edit previews and refresh message context * fix(telegram): keep edited messages out of new group history --------- Co-authored-by: Peter Steinberger --- .../telegram/src/outbound-message-context.ts | 20 +- extensions/telegram/src/rich-message.ts | 2 + extensions/telegram/src/send-edit.ts | 44 +++- extensions/telegram/src/send.test.ts | 197 ++++++++++++++++++ 4 files changed, 247 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index 3d0a57acb178..4ae88f0176e7 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -136,6 +136,8 @@ export async function recordOutboundMessageForPromptContext(params: { successfulSendThread?: TelegramThreadSpec; promptContextTimestampMs?: number; promptContextProjection?: TelegramPromptContextProjection; + /** Edits refresh an existing cache entry without inserting another self-history turn. */ + recordGroupHistory?: boolean; }): Promise { try { const providerGeneralTopicId = @@ -169,14 +171,16 @@ export async function recordOutboundMessageForPromptContext(params: { ...(providerObservedThreadId !== undefined ? { providerObservedThreadId } : {}), ...(messageThreadId !== undefined ? { threadId: messageThreadId } : {}), }); - const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); - outboundGroupHistoryRecorders.get(params.account.accountId)?.({ - chatId: params.chatId, - messageId: params.messageId, - text: params.text ?? cacheMessage.text ?? cacheMessage.caption, - ...(messageThreadId !== undefined ? { messageThreadId } : {}), - ...(timestamp !== undefined ? { timestamp } : {}), - }); + if (params.recordGroupHistory !== false) { + const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); + outboundGroupHistoryRecorders.get(params.account.accountId)?.({ + chatId: params.chatId, + messageId: params.messageId, + text: params.text ?? cacheMessage.text ?? cacheMessage.caption, + ...(messageThreadId !== undefined ? { messageThreadId } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + }); + } return true; } catch (error) { logVerbose(`telegram: failed to record outbound message context: ${String(error)}`); diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index f3f149ecb84a..237bb4406147 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -2,6 +2,7 @@ import type { Bot } from "grammy"; import type { ForceReply, InlineKeyboardMarkup, + LinkPreviewOptions, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, @@ -82,6 +83,7 @@ export type TelegramEditRichMessageTextParams = { message_id?: number; inline_message_id?: string; rich_message: TelegramInputRichMessage; + link_preview_options?: LinkPreviewOptions; reply_markup?: InlineKeyboardMarkup; }; diff --git a/extensions/telegram/src/send-edit.ts b/extensions/telegram/src/send-edit.ts index 25876985a632..8c2fc49d3337 100644 --- a/extensions/telegram/src/send-edit.ts +++ b/extensions/telegram/src/send-edit.ts @@ -4,6 +4,10 @@ import type { TelegramInlineButtons } from "./button-types.js"; import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { isRecoverableTelegramNetworkError, isTelegramServerError } from "./network-errors.js"; +import { + recordOutboundMessageForPromptContext, + type TelegramOutboundPromptContextMessage, +} from "./outbound-message-context.js"; import { buildTelegramRichMarkdownPlan, getTelegramRichRawApi, @@ -26,6 +30,7 @@ import { import { prepareTelegramOutbound } from "./send-outbound.js"; import type { OpenClawConfig } from "./send.runtime.js"; import { resolveMarkdownTableMode } from "./send.runtime.js"; +import { resolveTelegramBotUserIdFromToken } from "./token.js"; type TelegramEditMessageTextParams = Parameters[3]; type TelegramEditMessageCaptionParams = Parameters< @@ -148,6 +153,7 @@ async function editMessageTelegramWithContext( ) => request(fn, label, shouldLog ? { shouldLog } : undefined); const textMode = opts.textMode ?? "markdown"; + const linkPreviewEnabled = opts.linkPreview ?? account.config.linkPreview ?? true; // Caller-authored HTML edits keep legacy parse_mode HTML semantics too. const useRichMessages = account.config.richMessages === true && textMode !== "html"; const tableMode = resolveMarkdownTableMode({ @@ -161,7 +167,7 @@ async function editMessageTelegramWithContext( const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined; const richMessagePlan = useRichMessages ? buildTelegramRichMarkdownPlan(text, { - skipEntityDetection: opts.linkPreview === false, + skipEntityDetection: !linkPreviewEnabled, tableMode, }) : undefined; @@ -177,14 +183,14 @@ async function editMessageTelegramWithContext( const textEditParams: TelegramEditMessageTextParams = { parse_mode: "HTML", }; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { textEditParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { textEditParams.reply_markup = replyMarkup; } const plainTextParams: TelegramEditMessageTextParams = {}; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { plainTextParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { @@ -206,8 +212,13 @@ async function editMessageTelegramWithContext( const performTextEdit = () => { if (richRawApi && richMessagePlan) { - const richEditParams: Pick = - replyMarkup === undefined ? {} : { reply_markup: replyMarkup }; + const richEditParams: Pick< + TelegramEditRichMessageTextParams, + "link_preview_options" | "reply_markup" + > = { + ...(linkPreviewEnabled ? {} : { link_preview_options: { is_disabled: true } }), + ...(replyMarkup === undefined ? {} : { reply_markup: replyMarkup }), + }; warnTelegramRichBlocksDegradations({ context: "editMessage", reasons: richMessagePlan.degradationReasons, @@ -282,16 +293,17 @@ async function editMessageTelegramWithContext( ), }); + let editedMessage: TelegramOutboundPromptContextMessage | true | undefined; try { const editMode = opts.editMode ?? "text"; if (editMode === "caption") { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { try { - await performTextEdit(); + editedMessage = await performTextEdit(); } catch (err) { if (editMode === "auto" && isTelegramMessageHasNoTextError(err)) { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { throw err; } @@ -305,6 +317,22 @@ async function editMessageTelegramWithContext( } } + if (editedMessage && editedMessage !== true && typeof editedMessage.message_id === "number") { + const botUserId = resolveTelegramBotUserIdFromToken(opts.token || account.token); + await recordOutboundMessageForPromptContext({ + cfg, + account, + chatId, + message: editedMessage, + messageId: editedMessage.message_id, + recordGroupHistory: false, + ...(botUserId !== undefined ? { botUserId } : {}), + ...(editedMessage.message_thread_id !== undefined + ? { messageThreadId: editedMessage.message_thread_id } + : {}), + }); + } + logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); return { ok: true, messageId: String(messageId), chatId }; } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ae293c76c199..58d2491168c7 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -10,12 +10,17 @@ import { import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "./format.js"; +import { + recordTelegramGroupHistoryEntry, + selectTelegramGroupHistoryAfterLastSelf, +} from "./group-history-window.js"; import { buildTelegramConversationContext, createTelegramMessageCache, hasProviderObservedTelegramThreadBinding, resolveTelegramMessageCacheScope, } from "./message-cache.js"; +import { registerTelegramOutboundGroupHistoryRecorder } from "./outbound-message-context.js"; import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js"; import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -4583,6 +4588,198 @@ describe("editMessageTelegram", () => { ); expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); + + it.each([ + { + name: "inherits the disabled account default", + accountLinkPreview: false, + linkPreview: undefined, + expectedDisabled: true, + }, + { + name: "lets an explicit enabled value override the account default", + accountLinkPreview: false, + linkPreview: true, + expectedDisabled: false, + }, + { + name: "lets an explicit disabled value override the account default", + accountLinkPreview: true, + linkPreview: false, + expectedDisabled: true, + }, + ])("$name for edited Telegram messages", async (testCase) => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { linkPreview: testCase.accountLinkPreview } } }, + ...(testCase.linkPreview !== undefined ? { linkPreview: testCase.linkPreview } : {}), + }); + + const params = requireRecord( + firstMockCall(botApi.editMessageText, "editMessageText preview call")[3], + "edited Telegram preview params", + ); + if (testCase.expectedDisabled) { + expect(params.link_preview_options).toEqual({ is_disabled: true }); + } else { + expect(params).not.toHaveProperty("link_preview_options"); + } + }); + + it("preserves disabled previews when editing rich Telegram messages", async () => { + botRawApi.editMessageText.mockResolvedValue({ + message_id: 1, + chat: { id: "123", type: "private" }, + text: "https://example.com", + }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { richMessages: true } } }, + linkPreview: false, + }); + + expect(botRawApi.editMessageText).toHaveBeenCalledWith( + expect.objectContaining({ + chat_id: "123", + message_id: 1, + link_preview_options: { is_disabled: true }, + }), + ); + }); + + it.each([ + { name: "text", editMode: "text" as const, field: "text" as const }, + { name: "caption", editMode: "caption" as const, field: "caption" as const }, + ])("refreshes cached $name from Telegram's authoritative edit response", async (testCase) => { + const storePath = `/tmp/openclaw-telegram-edited-context-${process.pid}-${Date.now()}-${testCase.name}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const cache = createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }); + await cache.record({ + accountId: "default", + chatId: chat.id, + threadId: 77, + msg: { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "outdated content", + }, + }); + const editedMessage = { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + edit_date: 1_779_394_750, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "authoritative edited content", + }; + if (testCase.editMode === "caption") { + botApi.editMessageCaption.mockResolvedValue(editedMessage); + } else { + botApi.editMessageText.mockResolvedValue(editedMessage); + } + + await editMessageTelegram(chat.id, 902, "authoritative edited content", { + token: "42:test-token", + cfg, + editMode: testCase.editMode, + }); + + const cached = await cache.get({ + accountId: "default", + chatId: chat.id, + messageId: "902", + }); + expect(cached?.body).toBe("authoritative edited content"); + expect(hasProviderObservedTelegramThreadBinding(cached, 77)).toBe(true); + }); + + it("refreshes edited group messages without duplicating self history or hiding later replies", async () => { + const storePath = `/tmp/openclaw-telegram-edit-history-${process.pid}-${Date.now()}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const historyKey = `${chat.id}:topic:77`; + const groupHistory = new Map< + string, + Array<{ sender: string; body: string; messageId: string; timestamp: number }> + >(); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: "original response", + messageId: "902", + timestamp: 1_779_394_740_000, + }, + }); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "Teammate", + body: "context that must remain visible", + messageId: "903", + timestamp: 1_779_394_741_000, + }, + }); + const unregister = registerTelegramOutboundGroupHistoryRecorder({ + accountId: "default", + recorder: (record) => + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: record.text ?? "", + messageId: String(record.messageId), + timestamp: record.timestamp ?? 0, + }, + }), + }); + botApi.editMessageText.mockResolvedValue({ + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + text: "authoritative edited response", + }); + + try { + await editMessageTelegram(chat.id, 902, "authoritative edited response", { + token: "42:test-token", + cfg, + }); + } finally { + unregister(); + } + + const entries = groupHistory.get(historyKey) ?? []; + expect(entries.map((entry) => entry.messageId)).toEqual(["902", "903"]); + expect(selectTelegramGroupHistoryAfterLastSelf(entries)).toEqual([ + expect.objectContaining({ + sender: "Teammate", + body: "context that must remain visible", + }), + ]); + const cached = await createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }).get({ accountId: "default", chatId: chat.id, messageId: "902" }); + expect(cached?.body).toBe("authoritative edited response"); + }); }); describe("sendPollTelegram", () => { From 6540e5ac79c46fca5eef420544f57ec692b55252 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:15:34 -0700 Subject: [PATCH 112/239] fix(setup): isolate failed channel probes and report doctor diagnostics (#116824) Co-authored-by: Peter Steinberger --- src/commands/doctor-gateway-health.test.ts | 40 ++++++++ src/commands/doctor-gateway-health.ts | 13 ++- src/flows/channel-setup.status.test.ts | 106 +++++++++++++++++++++ src/flows/channel-setup.status.ts | 46 +++++---- 4 files changed, 187 insertions(+), 18 deletions(-) diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index f4db3a74892f..d77d5e348668 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -82,11 +82,51 @@ describe("checkGatewayHealth", () => { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: cfg, }); expect(runtime.error).not.toHaveBeenCalled(); expect(note.mock.calls.map(([, title]) => title)).not.toContain("OpenClaw version mismatch"); }); + it("reports failed channel diagnostics without marking a reachable gateway unhealthy", async () => { + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error("channel probe timed out")); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await expect( + checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }), + ).resolves.toEqual({ authenticated: true, healthOk: true, status: { ok: true } }); + + expect(note).toHaveBeenCalledWith( + [ + "Channel status probe failed: channel probe timed out", + "Retry: openclaw channels status --probe", + ].join("\n"), + "Channel warnings", + ); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("redacts credentials and terminal controls in channel probe failures", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce( + new Error(`\u001B[31mchannel probe failed\nAuthorization: Bearer ${token}`), + ); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await checkGatewayHealth({ runtime: runtime as never, cfg }); + + const [message, title] = note.mock.calls.at(-1) ?? []; + expect(title).toBe("Channel warnings"); + expect(message).toContain("channel probe failed\\nAuthorization: Bearer"); + expect(message).not.toContain(token); + expect(message).not.toContain("\u001B"); + expect(message.split("\n")).toHaveLength(2); + }); + it("notes CLI and gateway version mismatch when the gateway reports another runtime version", async () => { callGateway.mockResolvedValueOnce({ runtimeVersion: "2026.4.23" }).mockResolvedValueOnce({}); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index f449e3c4221c..6b97b74dd9be 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -1,5 +1,7 @@ /** Gateway health probes used by doctor before deeper daemon and memory diagnostics. */ import { note } from "../../packages/terminal-core/src/note.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { probeGatewayStatus } from "../cli/daemon-cli/probe.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -115,6 +117,7 @@ export async function checkGatewayHealth(params: { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: params.cfg, }); const issues = collectChannelStatusIssues(statusLocal); if (issues.length > 0) { @@ -130,8 +133,14 @@ export async function checkGatewayHealth(params: { "Channel warnings", ); } - } catch { - // ignore: doctor already reported gateway health + } catch (error) { + note( + [ + `Channel status probe failed: ${sanitizeTerminalText(formatErrorMessage(error))}`, + `Retry: ${formatCliCommand("openclaw channels status --probe")}`, + ].join("\n"), + "Channel warnings", + ); } return { healthOk, authenticated: true, status }; } catch (err) { diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 250d18b1953a..5f9b9a34ffbd 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -14,6 +14,7 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh type FormatChannelSelectionLine = typeof import("../channels/registry.js").formatChannelSelectionLine; type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; type NoteChannelPrimerChannels = Parameters< typeof import("./channel-setup.status.js").noteChannelPrimer >[1]; @@ -260,6 +261,111 @@ describe("resolveChannelSetupSelectionContributions", () => { ]); }); + it.each(["rejected status check", "synchronous status check", "adapter resolution"] as const)( + "keeps healthy channels selectable after a %s failure", + async (failurePoint) => { + const installedPlugins = [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + { + id: "telegram", + meta: makeMeta("telegram", "Telegram"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ] satisfies ChannelSetupPlugin[]; + listChatChannels.mockReturnValue([ + makeMeta("matrix", "Matrix"), + makeMeta("telegram", "Telegram"), + ]); + isChannelConfigured.mockImplementation((_, channelId) => channelId === "matrix"); + + const failure = new Error("lazy Matrix setup module unavailable"); + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins, + resolveAdapter: (channel) => { + if (channel === "matrix" && failurePoint === "adapter resolution") { + throw failure; + } + return { + channel, + getStatus: + channel === "matrix" + ? failurePoint === "synchronous status check" + ? () => { + throw failure; + } + : async () => { + throw failure; + } + : async () => ({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }), + } as never; + }, + }); + + expect(summary.statusByChannel.get("matrix")).toEqual({ + channel: "matrix", + configured: true, + statusLines: ["Matrix: status unavailable (lazy Matrix setup module unavailable)"], + selectionHint: "status unavailable", + }); + expect(summary.statusByChannel.get("telegram")).toEqual({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }); + expect(summary.statusLines).toEqual([ + "Matrix: status unavailable (lazy Matrix setup module unavailable)", + "Telegram: configured", + ]); + }, + ); + + it("redacts credentials and terminal controls in failed channel status checks", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ], + resolveAdapter: (channel) => + ({ + channel, + getStatus: async () => { + throw new Error(`\u001B[31mloader failed\nAuthorization: Bearer ${token}`); + }, + }) as never, + }); + + const statusLine = summary.statusLines[0]; + expect(statusLine).toContain( + "Matrix: status unavailable (loader failed\\nAuthorization: Bearer", + ); + expect(statusLine).not.toContain(token); + expect(statusLine).not.toContain("\u001B"); + expect(statusLine).not.toContain("\n"); + }); + it("localizes channel status note labels", async () => { listChatChannels.mockReturnValue([ makeMeta("discord", "Discord"), diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index cf4116a9fc24..964a20d4a94a 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -20,6 +20,7 @@ import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-s import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { findBundledPluginSourceInMap, resolveBundledPluginSources, @@ -355,22 +356,35 @@ export async function collectChannelStatus(params: { resolveChannelSetupWizardAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); - const statusEntries = await Promise.all( - installedPlugins.flatMap((plugin) => { - if (!shouldShowChannelInSetup(plugin.meta)) { - return []; - } - const adapter = resolveAdapter(plugin.id); - if (!adapter) { - return []; - } - return adapter.getStatus({ - cfg: params.cfg, - options: params.options, - accountOverrides: params.accountOverrides, - }); - }), - ); + const statusEntries = ( + await Promise.all( + installedPlugins + .filter((plugin) => shouldShowChannelInSetup(plugin.meta)) + .map(async (plugin): Promise => { + try { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return undefined; + } + return await adapter.getStatus({ + cfg: params.cfg, + options: params.options, + accountOverrides: params.accountOverrides, + }); + } catch (error) { + const detail = formatSetupFreeText(formatErrorMessage(error)); + return { + channel: plugin.id, + configured: isChannelConfigured(params.cfg, plugin.id), + statusLines: [ + `${formatSetupSelectionLabel(plugin.meta.label, plugin.id)}: status unavailable (${detail})`, + ], + selectionHint: "status unavailable", + }; + } + }), + ) + ).filter((status): status is ChannelSetupStatus => status !== undefined); const statusByChannel = new Map( statusEntries.map((entry: ChannelSetupStatus) => [entry.channel, entry]), ); From 18cd6ee654bbc10ef8923f19b9267ab6e7a39d10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:20:27 -0700 Subject: [PATCH 113/239] fix(ui): own model provider config mutation lifecycle (#116829) Co-authored-by: Peter Steinberger --- .../pages/model-providers/config-mutation.ts | 103 ++++++++ .../model-providers/default-models-view.ts | 12 +- .../model-providers-page.test.ts | 238 +++++++++++++++++- .../model-providers/model-providers-page.ts | 207 ++++++++------- ui/src/pages/model-providers/view.test.ts | 175 +++++++++++++ ui/src/pages/model-providers/view.ts | 63 +++-- 6 files changed, 678 insertions(+), 120 deletions(-) create mode 100644 ui/src/pages/model-providers/config-mutation.ts diff --git a/ui/src/pages/model-providers/config-mutation.ts b/ui/src/pages/model-providers/config-mutation.ts new file mode 100644 index 000000000000..e1e0eb7223e3 --- /dev/null +++ b/ui/src/pages/model-providers/config-mutation.ts @@ -0,0 +1,103 @@ +import { t } from "../../i18n/index.ts"; +import type { RuntimeConfigCapability } from "../../lib/config/index.ts"; +import type { ModelProviderRowMessage } from "./view.ts"; + +export type ModelProviderConfigMutation = { + key: string; + raw: Record; + note: string; + success: string; + replacePaths?: string[]; +}; + +export type ModelProviderConfigMutationResult = + | { ok: false } + | { ok: true; agentEpoch: number; warning: string | null }; + +type ModelProviderConfigMutationOwner = { + runtimeConfig: RuntimeConfigCapability; + agentEpoch: number; + isCurrentClient: () => boolean; + isCurrentAgent: () => boolean; + refreshProviders: () => Promise; + setBusy: (busy: boolean) => void; + setMessage: (message: ModelProviderRowMessage | null) => void; +}; + +export function modelProviderErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return typeof error === "string" && error.trim() ? error : t("modelProviders.requestFailed"); +} + +/** + * Config patches are global; the initiating agent owns only busy/message UI. + * Refresh warnings must preserve an already acknowledged mutation. + */ +export async function runModelProviderConfigMutation( + owner: ModelProviderConfigMutationOwner, + params: ModelProviderConfigMutation, +): Promise { + const { agentEpoch, runtimeConfig } = owner; + owner.setBusy(true); + owner.setMessage(null); + try { + await runtimeConfig.ensureLoaded(); + if (!owner.isCurrentClient()) { + return { ok: false }; + } + const patched = await runtimeConfig.patch({ + raw: params.raw, + note: params.note, + ...(params.replacePaths ? { replacePaths: params.replacePaths } : {}), + }); + if (!owner.isCurrentClient()) { + return { ok: false }; + } + if (!patched) { + if (owner.isCurrentAgent()) { + owner.setMessage({ + kind: "error", + text: runtimeConfig.state.lastError ?? t("modelProviders.configUnavailable"), + }); + } + return { ok: false }; + } + + let warning: string | null = null; + try { + await runtimeConfig.refresh(); + // The config owner records ordinary config.get failures in lastError + // and resolves refresh(), so rejection alone cannot detect them. + warning = runtimeConfig.state.lastError; + if (!warning && owner.isCurrentClient()) { + await owner.refreshProviders(); + } + } catch (error) { + // An acknowledged config patch is already committed; a later refresh + // failure must not turn it into a failed credential edit. + warning = modelProviderErrorMessage(error); + } + if (!owner.isCurrentClient()) { + return { ok: false }; + } + if (owner.isCurrentAgent()) { + owner.setMessage({ + kind: "success", + text: params.success, + ...(warning ? { warning } : {}), + }); + } + return { ok: true, agentEpoch, warning }; + } catch (error) { + if (owner.isCurrentClient() && owner.isCurrentAgent()) { + owner.setMessage({ kind: "error", text: modelProviderErrorMessage(error) }); + } + return { ok: false }; + } finally { + if (owner.isCurrentClient() && owner.isCurrentAgent()) { + owner.setBusy(false); + } + } +} diff --git a/ui/src/pages/model-providers/default-models-view.ts b/ui/src/pages/model-providers/default-models-view.ts index 2ddf8fe8c85d..955c9d690048 100644 --- a/ui/src/pages/model-providers/default-models-view.ts +++ b/ui/src/pages/model-providers/default-models-view.ts @@ -11,7 +11,7 @@ type DefaultModelsViewProps = { mutationBlockedReason: string | null; dirty: boolean; busy: Record; - message?: { kind: "success" | "error"; text: string }; + message?: { kind: "success" | "error"; text: string; warning?: string }; onPrimaryChange: (model: string) => void; onFallbackAdd: (model: string) => void; onFallbackRemove: (index: number) => void; @@ -164,7 +164,15 @@ export function renderDefaultModels(props: DefaultModelsViewProps) { ${props.message - ? html`
${props.message.text}
` + ? html`
+ ${props.message.text} +
` + : nothing} + ${props.message?.warning + ? html`
${props.message.warning}
` : nothing} `; diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index ee350cf6a555..67bea60ce2af 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelsProbeResult } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import type { DefaultModelSelection, ModelProviderLogoutTarget } from "./data.ts"; import { EMPTY_MODEL_PROVIDERS_DATA, type ModelProvidersData } from "./load.ts"; import type { ModelProvidersRouteData } from "./model-providers-page.ts"; import "./model-providers-page.ts"; @@ -13,9 +14,21 @@ type ModelProvidersPageTestElement = HTMLElement & { updateComplete: Promise; busy: Record; data: ModelProvidersData | null; + addProvider: () => Promise; + addProviderId: string; + addProviderKey: string; + addProviderOpen: boolean; + defaultsDraft: DefaultModelSelection | null; + keyDraft: string; + keyEditorProvider: string | null; + logout: (cardId: string, targets: ModelProviderLogoutTarget[]) => Promise; + messages: Record; + pendingLogoutProvider: string | null; probe: (cardId: string, providers: string[]) => Promise; probeResults: Record; routeData: ModelProvidersRouteData | undefined; + saveDefaultModels: () => Promise; + saveKey: (provider: string, configKey: string) => Promise; selectedAgentId: string; }; @@ -96,9 +109,12 @@ function createHarness(initialScopeId: string) { configFormMode: "form", configFormDirty: false, configAutoSaveStatus: "idle", + lastError: null as string | null, }, - ensureLoaded: vi.fn(async () => undefined), + ensureLoaded: vi.fn(async (): Promise => undefined), + patch: vi.fn(async () => true), patchForm: vi.fn(), + refresh: vi.fn(async () => undefined), save: vi.fn(async () => true), apply: vi.fn(async () => true), discardDraft: vi.fn(async () => undefined), @@ -190,6 +206,226 @@ describe("ModelProvidersPage agent scope", () => { ); }); + it("keeps a committed provider-key save successful when config refresh fails", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after provider-key commit"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.keyEditorProvider = "openai"; + page.keyDraft = "replacement"; + + await page.saveKey("openai", "openai"); + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.keyEditorProvider).toBeNull(); + expect(page.messages.openai).toEqual({ + kind: "success", + text: "Secret saved.", + warning: "config.get failed after provider-key commit", + }); + }); + + it("keeps committed provider-add feedback visible when its refresh fails", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after provider add"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "new-provider-key"; + + await page.addProvider(); + await page.updateComplete; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.addProviderOpen).toBe(true); + expect(page.addProviderKey).toBe(""); + const form = page.querySelector(".model-providers__add-form")?.parentElement; + expect( + [...form!.querySelectorAll('[role="status"]')].map((message) => message.textContent?.trim()), + ).toEqual(["Provider anthropic added.", "config.get failed after provider add"]); + }); + + it("keeps committed default models visible until their authoritative refresh succeeds", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.refresh.mockImplementationOnce(async () => { + runtimeConfig.state.lastError = "config.get failed after saving default models"; + }); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + const selection: DefaultModelSelection = { + primary: "openai/gpt-5", + fallbacks: [], + utilityModel: null, + }; + page.defaultsDraft = selection; + + await page.saveDefaultModels(); + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.defaultsDraft).toBe(selection); + expect(page.messages.defaults).toEqual({ + kind: "success", + text: "Default models saved.", + warning: "config.get failed after saving default models", + }); + }); + + it("keeps a replacement agent's default-model draft after a global model write", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + const selection: DefaultModelSelection = { + primary: "openai/gpt-5", + fallbacks: [], + utilityModel: null, + }; + page.defaultsDraft = selection; + + const saving = page.saveDefaultModels(); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + gate.resolve(); + await saving; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.defaultsDraft).toBe(selection); + expect(page.messages.defaults).toBeUndefined(); + }); + + it("keeps global provider writes without clearing a replacement agent's credential draft", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.keyEditorProvider = "openai"; + page.keyDraft = "main-agent-key"; + + const saving = page.saveKey("openai", "openai"); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + page.keyEditorProvider = "anthropic"; + page.keyDraft = "writer-agent-unsaved-key"; + gate.resolve(); + await saving; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(runtimeConfig.patch).toHaveBeenCalledWith( + expect.objectContaining({ + raw: { models: { providers: { openai: { apiKey: "main-agent-key" } } } }, + }), + ); + expect(page.keyEditorProvider).toBe("anthropic"); + expect(page.keyDraft).toBe("writer-agent-unsaved-key"); + expect(page.messages.openai).toBeUndefined(); + }); + + it("keeps a replacement agent's matching add-provider draft after a global write", async () => { + const { agentSelection, context, notifySelection, runtimeConfig } = createHarness("main"); + const gate = deferred(); + runtimeConfig.ensureLoaded.mockImplementationOnce(async () => gate.promise); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "shared-provider-key"; + + const adding = page.addProvider(); + await vi.waitFor(() => expect(runtimeConfig.ensureLoaded).toHaveBeenCalledOnce()); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + page.addProviderOpen = true; + page.addProviderId = "anthropic"; + page.addProviderKey = "shared-provider-key"; + gate.resolve(); + await adding; + + expect(runtimeConfig.patch).toHaveBeenCalledOnce(); + expect(page.addProviderOpen).toBe(true); + expect(page.addProviderId).toBe("anthropic"); + expect(page.addProviderKey).toBe("shared-provider-key"); + expect(page.messages.add).toBeUndefined(); + }); + + it("stops queued agent-scoped logouts after the selected agent changes", async () => { + const { agentSelection, context, notifySelection, request } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + request.mockClear(); + const firstLogout = deferred(); + request.mockImplementationOnce(async () => firstLogout.promise); + + const loggingOut = page.logout("openai", [ + { provider: "openai", profileIds: ["openai:first"] }, + { provider: "alias", profileIds: ["openai:second"] }, + ]); + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authLogout", { + provider: "openai", + profileIds: ["openai:first"], + agentId: "main", + }), + ); + agentSelection.state.scopeId = "writer"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("writer")); + agentSelection.state.scopeId = "main"; + notifySelection(); + await vi.waitFor(() => expect(page.selectedAgentId).toBe("main")); + firstLogout.resolve({}); + await loggingOut; + + expect(request.mock.calls.filter(([method]) => method === "models.authLogout")).toHaveLength(1); + }); + + it("stops queued agent-scoped logouts when route data changes the selected agent", async () => { + const { agentSelection, context, request, snapshot } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + request.mockClear(); + const firstLogout = deferred(); + request.mockImplementationOnce(async () => firstLogout.promise); + + const loggingOut = page.logout("openai", [ + { provider: "openai", profileIds: ["openai:first"] }, + { provider: "alias", profileIds: ["openai:second"] }, + ]); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + page.pendingLogoutProvider = "openai"; + page.messages = { openai: { kind: "error", text: "Previous agent failure" } }; + page.probeResults = { + openai: { provider: "openai", status: "ok", results: [] }, + }; + agentSelection.state.scopeId = "writer"; + page.routeData = { + data: { ...EMPTY_MODEL_PROVIDERS_DATA, config: {}, updatedAt: 1 }, + client: snapshot.client, + agentId: "writer", + }; + await page.updateComplete; + expect(page.selectedAgentId).toBe("writer"); + expect(page.busy).toEqual({}); + expect(page.pendingLogoutProvider).toBeNull(); + expect(page.messages).toEqual({}); + expect(page.probeResults).toEqual({}); + firstLogout.resolve({}); + await loggingOut; + + expect(request.mock.calls.filter(([method]) => method === "models.authLogout")).toHaveLength(1); + }); + it("reloads credential status when the agent selector changes", async () => { const { agentSelection, context, notifySelection, request } = createHarness("main"); const page = appendPage(context); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 66c3e498e37c..3aaf40147757 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -17,6 +17,12 @@ import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { + modelProviderErrorMessage, + runModelProviderConfigMutation, + type ModelProviderConfigMutation, + type ModelProviderConfigMutationResult, +} from "./config-mutation.ts"; import { buildModelProviderCards, buildSelectableDefaultModels, @@ -48,15 +54,10 @@ export type ModelProvidersRouteData = { agentId: string; }; -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - return typeof error === "string" && error.trim() ? error : t("modelProviders.requestFailed"); -} - function isMissingMethodError(error: unknown): boolean { - return /method (?:not found|not supported)|unknown method/iu.test(errorMessage(error)); + return /method (?:not found|not supported)|unknown method/iu.test( + modelProviderErrorMessage(error), + ); } const PROBE_FAILURE_PRIORITY: readonly ModelsProbeResult["status"][] = [ @@ -116,6 +117,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement { private dataClient: GatewayBrowserClient | null = null; private observedClient: GatewayBrowserClient | null = null; private clientEpoch = 0; + // Global config writes survive agent switches; their card state does not. + private agentEpoch = 0; private probeEpochs = new Map(); private readonly refreshTask = new Task(this, { autoRun: false, @@ -177,7 +180,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { override willUpdate(changed: PropertyValues) { if (changed.has("routeData") && this.routeData) { const selectedAgentId = this.resolveSelectedAgentId(); - this.selectedAgentId = selectedAgentId; + this.setSelectedAgent(selectedAgentId); if (this.routeData.agentId === selectedAgentId) { this.data = this.routeData.data; this.dataClient = this.routeData.client; @@ -249,18 +252,26 @@ export class ModelProvidersPage extends OpenClawLightDomElement { return normalizeAgentId(agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? "main"); } - private syncSelectedAgent() { - const agentId = this.resolveSelectedAgentId(); + private setSelectedAgent(agentId: string): boolean { if (agentId === this.selectedAgentId) { - return; + return false; } this.selectedAgentId = agentId; - void this.refreshTask.run([null, agentId, false]); - this.data = null; + this.agentEpoch += 1; this.busy = {}; this.pendingLogoutProvider = null; this.messages = {}; this.probeResults = {}; + return true; + } + + private syncSelectedAgent() { + const agentId = this.resolveSelectedAgentId(); + if (!this.setSelectedAgent(agentId)) { + return; + } + void this.refreshTask.run([null, agentId, false]); + this.data = null; // probeEpochs stays: per-card counters must remain monotonic across agent // switches, or an in-flight probe from the old agent can reuse an epoch // and clobber a newer probe's state (A->B->A ABA race). @@ -290,7 +301,19 @@ export class ModelProvidersPage extends OpenClawLightDomElement { } private canMutate(): boolean { - return this.mutationBlockedReason() === null; + return this.mutationBlockedReason() === null && !this.configBusy(); + } + + private configBusy(): boolean { + const runtimeState = this.context.runtimeConfig.state; + const update = this.context.overlays.snapshot; + return ( + runtimeState.configLoading || + runtimeState.configSaving || + runtimeState.configApplying || + update.updateRunning || + update.updateReconciliationPending + ); } private setBusy(key: string, value: boolean) { @@ -321,64 +344,30 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.probeResults = next; } - private async patchConfig(params: { - key: string; - raw: Record; - note: string; - success: string; - replacePaths?: string[]; - }): Promise { + private async patchConfig( + params: ModelProviderConfigMutation, + ): Promise { if (!this.canMutate() || this.busy[params.key]) { - return false; + return { ok: false }; } const client = this.context.gateway.snapshot.client; if (!client) { - return false; + return { ok: false }; } const clientEpoch = this.clientEpoch; - const runtimeConfig = this.context.runtimeConfig; - this.setBusy(params.key, true); - this.setMessage(params.key, null); - try { - await runtimeConfig.ensureLoaded(); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - const patched = await runtimeConfig.patch({ - raw: params.raw, - note: params.note, - ...(params.replacePaths ? { replacePaths: params.replacePaths } : {}), - }); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - if (!patched) { - this.setMessage(params.key, { - kind: "error", - text: runtimeConfig.state.lastError ?? t("modelProviders.configUnavailable"), - }); - return false; - } - await runtimeConfig.refresh(); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - await this.refresh({ force: true }); - if (!this.isCurrentClient(client, clientEpoch)) { - return false; - } - this.setMessage(params.key, { kind: "success", text: params.success }); - return true; - } catch (error) { - if (this.isCurrentClient(client, clientEpoch)) { - this.setMessage(params.key, { kind: "error", text: errorMessage(error) }); - } - return false; - } finally { - if (this.isCurrentClient(client, clientEpoch)) { - this.setBusy(params.key, false); - } - } + const agentEpoch = this.agentEpoch; + return runModelProviderConfigMutation( + { + runtimeConfig: this.context.runtimeConfig, + agentEpoch, + isCurrentClient: () => this.isCurrentClient(client, clientEpoch), + isCurrentAgent: () => this.agentEpoch === agentEpoch, + refreshProviders: () => this.refresh({ force: true }), + setBusy: (busy) => this.setBusy(params.key, busy), + setMessage: (message) => this.setMessage(params.key, message), + }, + params, + ); } private openKeyEditor(provider: string) { @@ -400,16 +389,22 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.clearProbe(provider); this.setMessage(provider, null); this.setMessage(`key:${provider}`, null); - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: `key:${provider}`, raw: buildProviderApiKeyPatch(configKey, apiKey), note: t("modelProviders.notes.saveKey", { provider }), success: t("modelProviders.apiKey.saved"), }); - if (ok) { + if (result.ok && this.agentEpoch === result.agentEpoch) { this.setMessage(`key:${provider}`, null); - this.closeKeyEditor(); - this.setMessage(provider, { kind: "success", text: t("modelProviders.apiKey.saved") }); + if (this.keyEditorProvider === provider && this.keyDraft.trim() === apiKey) { + this.closeKeyEditor(); + } + this.setMessage(provider, { + kind: "success", + text: t("modelProviders.apiKey.saved"), + ...(result.warning ? { warning: result.warning } : {}), + }); } } @@ -417,16 +412,22 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.clearProbe(provider); this.setMessage(provider, null); this.setMessage(`key:${provider}`, null); - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: `key:${provider}`, raw: buildProviderApiKeyPatch(configKey, null), note: t("modelProviders.notes.removeKey", { provider }), success: t("modelProviders.apiKey.removed"), }); - if (ok) { + if (result.ok && this.agentEpoch === result.agentEpoch) { this.setMessage(`key:${provider}`, null); - this.closeKeyEditor(); - this.setMessage(provider, { kind: "success", text: t("modelProviders.apiKey.removed") }); + if (this.keyEditorProvider === provider) { + this.closeKeyEditor(); + } + this.setMessage(provider, { + kind: "success", + text: t("modelProviders.apiKey.removed"), + ...(result.warning ? { warning: result.warning } : {}), + }); } } @@ -474,7 +475,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { text: t("modelProviders.probe.unavailable"), }); } else { - this.setMessage(cardId, { kind: "error", text: errorMessage(error) }); + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(error) }); } } finally { if ( @@ -494,37 +495,43 @@ export class ModelProvidersPage extends OpenClawLightDomElement { } const clientEpoch = this.clientEpoch; const agentId = this.selectedAgentId; + const agentEpoch = this.agentEpoch; this.clearProbe(cardId); this.setBusy(key, true); this.setMessage(cardId, null); try { let firstError: unknown; for (const target of targets) { + // OAuth profiles are agent-owned; stop undispatched targets after any + // scope change, including a switch away from and back to this agent. + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { + return; + } try { await client.request("models.authLogout", { ...target, agentId }); } catch (error) { firstError ??= error; } } - if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { return; } await this.refresh({ force: true }); - if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { + if (!this.isCurrentClient(client, clientEpoch) || this.agentEpoch !== agentEpoch) { return; } if (firstError) { - this.setMessage(cardId, { kind: "error", text: errorMessage(firstError) }); + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(firstError) }); return; } this.pendingLogoutProvider = null; this.setMessage(cardId, { kind: "success", text: t("modelProviders.logout.done") }); } catch (error) { - if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { - this.setMessage(cardId, { kind: "error", text: errorMessage(error) }); + if (this.isCurrentClient(client, clientEpoch) && this.agentEpoch === agentEpoch) { + this.setMessage(cardId, { kind: "error", text: modelProviderErrorMessage(error) }); } } finally { - if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { + if (this.isCurrentClient(client, clientEpoch) && this.agentEpoch === agentEpoch) { this.setBusy(key, false); } } @@ -536,19 +543,26 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!provider || !apiKey) { return; } - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: "add", raw: buildProviderApiKeyPatch(provider, apiKey), note: t("modelProviders.notes.addProvider", { provider }), success: t("modelProviders.add.saved", { provider }), }); - if (ok) { - this.addProviderOpen = false; - this.addProviderId = ""; - this.addProviderKey = ""; + if (result.ok && this.agentEpoch === result.agentEpoch) { + if (this.addProviderId === provider && this.addProviderKey.trim() === apiKey) { + // A failed refresh leaves a new provider without a card. Keep its + // success + warning visible in the open form instead of losing both. + this.addProviderOpen = Boolean(result.warning); + if (!result.warning) { + this.addProviderId = ""; + } + this.addProviderKey = ""; + } this.setMessage(provider, { kind: "success", text: t("modelProviders.add.saved", { provider }), + ...(result.warning ? { warning: result.warning } : {}), }); } } @@ -558,14 +572,21 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!selection?.primary) { return; } - const ok = await this.patchConfig({ + const result = await this.patchConfig({ key: "defaults", raw: buildDefaultModelsPatch(selection.primary, selection.fallbacks, selection.utilityModel), note: t("modelProviders.notes.defaultModel"), success: t("modelProviders.defaults.saved"), replacePaths: DEFAULT_MODELS_REPLACE_PATHS, }); - if (ok) { + // Without fresh provider data, clearing this committed draft would show + // the old default models beside a contradictory success message. + if ( + result.ok && + !result.warning && + this.agentEpoch === result.agentEpoch && + this.defaultsDraft === selection + ) { this.defaultsDraft = null; } } @@ -589,16 +610,10 @@ export class ModelProvidersPage extends OpenClawLightDomElement { typeof agentsDefaults?.thinkingDefault === "string" ? agentsDefaults.thinkingDefault : "off"; const fastValue = agentsDefaults?.fastModeDefault; const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : false; - const update = this.context.overlays.snapshot; // The overlay update states replace General's old configUpdating prop, // which config-page derived from this same snapshot (isUpdateBusy); the // busy gate is behavior-identical to the pre-move General controls. - const configBusy = - runtimeState.configLoading || - runtimeState.configSaving || - runtimeState.configApplying || - update.updateRunning || - update.updateReconciliationPending; + const configBusy = this.configBusy(); const cards = buildModelProviderCards({ ...data, configProviderIds: config.providerIds, diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index f4aabefa6d04..705491c637f0 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -167,6 +167,181 @@ describe("renderModelProviders", () => { expect([...groups].every((group) => group.disabled)).toBe(true); }); + it("locks provider and default-model mutations while shared config work is pending", () => { + const container = mount( + props({ + configBusy: true, + defaultModelsDirty: true, + defaultModels: { + primary: "openai/gpt-5", + fallbacks: ["anthropic/claude"], + utilityModel: null, + }, + configuredModels: [ + { id: "openai/gpt-5", provider: "openai", name: "GPT-5", available: true }, + { id: "anthropic/claude", provider: "anthropic", name: "Claude", available: true }, + ], + cards: [ + card({ + hasConfigApiKey: true, + apiKey: { source: "config" }, + logoutTargets: [{ provider: "openai", profileIds: ["openai:oauth"] }], + }), + ], + keyEditorProvider: "openai", + keyDraft: "replacement", + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + }), + ); + + const defaults = container.querySelector(".model-providers__defaults"); + const defaultControls = [ + ...(defaults?.querySelectorAll( + "select, .model-providers__fallback-row button", + ) ?? []), + button(container, "Save"), + ]; + expect(defaultControls.map((control) => control?.disabled)).toEqual([ + true, + true, + true, + true, + true, + ]); + + const provider = container.querySelector('[data-provider-id="openai"]'); + expect( + provider?.querySelector(".model-providers__inline-form input")?.disabled, + ).toBe(true); + expect(button(provider!, "Replace key")?.disabled).toBe(true); + expect(button(provider!, "Remove key")?.disabled).toBe(true); + expect(button(provider!, "Log out")?.disabled).toBe(true); + + const addForm = container.querySelector(".model-providers__add-form"); + expect( + [ + ...(addForm?.querySelectorAll( + "select, input, button", + ) ?? []), + ].map((control) => control.disabled), + ).toEqual([true, true, true]); + }); + + it("locks an already-open provider form after mutation access is revoked", () => { + const onAddProvider = vi.fn(); + const onAddProviderToggle = vi.fn(); + const container = mount( + props({ + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + canMutate: false, + mutationBlockedReason: "Operator admin access required", + onAddProvider, + onAddProviderToggle, + }), + ); + const addForm = container.querySelector(".model-providers__add-form"); + const controls = [ + ...(addForm?.querySelectorAll( + "select, input, button", + ) ?? []), + ]; + + expect(controls.map((control) => control.disabled)).toEqual([true, true, true]); + addForm?.querySelector("button")?.click(); + expect(onAddProvider).not.toHaveBeenCalled(); + + const cancel = button(addForm!.closest(".settings-section")!, "Cancel"); + expect(cancel?.disabled).toBe(false); + cancel?.click(); + expect(onAddProviderToggle).toHaveBeenCalledOnce(); + }); + + it("freezes provider and credential fields while adding a provider", () => { + const container = mount( + props({ + addProviderOpen: true, + addProviderId: "anthropic", + addProviderKey: "new-provider-key", + busy: { add: true }, + }), + ); + const addForm = container.querySelector(".model-providers__add-form"); + + expect( + [ + ...(addForm?.querySelectorAll("select, input") ?? []), + ].map((control) => control.disabled), + ).toEqual([true, true]); + }); + + it("keeps committed credential success visible beside its refresh warning", () => { + const container = mount( + props({ + messages: { + openai: { + kind: "success", + text: "Secret saved.", + warning: "Config refresh failed after the secret was committed.", + }, + }, + }), + ); + const provider = container.querySelector('[data-provider-id="openai"]'); + const messages = [...(provider?.querySelectorAll('[role="status"]') ?? [])]; + + expect(messages.map((message) => text(message))).toEqual([ + "Secret saved.", + "Config refresh failed after the secret was committed.", + ]); + expect(messages[0]?.classList.contains("success")).toBe(true); + expect(messages[1]?.classList.contains("warning")).toBe(true); + }); + + it("keeps committed default-model success visible beside its refresh warning", () => { + const container = mount( + props({ + messages: { + defaults: { + kind: "success", + text: "Default models saved.", + warning: "Config refresh failed after the model defaults were committed.", + }, + }, + }), + ); + const defaults = container.querySelector(".model-providers__defaults"); + const messages = [...(defaults?.querySelectorAll('[role="status"]') ?? [])]; + + expect(messages.map((message) => text(message))).toEqual([ + "Default models saved.", + "Config refresh failed after the model defaults were committed.", + ]); + expect(messages[0]?.classList.contains("success")).toBe(true); + expect(messages[1]?.classList.contains("warning")).toBe(true); + }); + + it("announces provider and default-model mutation failures as accessible alerts", () => { + const container = mount( + props({ + messages: { + openai: { kind: "error", text: "Provider credential could not be saved." }, + defaults: { kind: "error", text: "Default models could not be saved." }, + }, + }), + ); + + expect(text(container.querySelector('[data-provider-id="openai"] [role="alert"]'))).toBe( + "Provider credential could not be saved.", + ); + expect(text(container.querySelector('.model-providers__defaults [role="alert"]'))).toBe( + "Default models could not be saved.", + ); + }); + it("keeps model behavior available while provider data loads", () => { const container = mount(props({ loading: true, thinkingLevel: "high", fastMode: true })); const behavior = container.querySelector("#settings-model-behavior"); diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 6141598a3ddf..933ff600cdbb 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -30,7 +30,11 @@ import type { } from "./data.ts"; import { renderDefaultModels } from "./default-models-view.ts"; -export type ModelProviderRowMessage = { kind: "success" | "error"; text: string }; +export type ModelProviderRowMessage = { + kind: "success" | "error"; + text: string; + warning?: string; +}; type ModelProvidersViewProps = { connected: boolean; @@ -93,6 +97,24 @@ function fastModeOptionValue(value: "auto" | "on" | "off"): FastMode { return value === "auto" ? "auto" : value === "on"; } +function configMutationDisabled(props: ModelProvidersViewProps): boolean { + return !props.canMutate || props.configBusy; +} + +function renderMutationMessage(message: ModelProviderRowMessage | undefined) { + if (!message) { + return nothing; + } + return html` +
+ ${message.text} +
+ ${message.warning + ? html`
${message.warning}
` + : nothing} + `; +} + function renderModelBehavior(props: ModelProvidersViewProps) { const fastMode = formatFastModeValue(props.fastMode); return html` @@ -317,6 +339,7 @@ function renderKeyEditor(card: ModelProviderCard, props: ModelProvidersViewProps const authModeBlocked = card.apiKeySupported === false || Boolean(card.configAuthMode && card.configAuthMode !== "api-key"); + const mutationDisabled = configMutationDisabled(props); return html`
`; } function renderAddProvider(props: ModelProvidersViewProps) { + const busy = Boolean(props.busy.add); + const disabled = configMutationDisabled(props) || busy; const rows = html` ${props.unconfiguredProviders.length === 0 ? renderSettingsEmpty(t("modelProviders.add.none")) @@ -495,6 +518,7 @@ function renderAddProvider(props: ModelProvidersViewProps) {