From 2a1eba771342f79f63e9831fb810667d15faa812 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 12 Aug 2026 00:52:44 -0300 Subject: [PATCH 001/165] fix(ui): show local project icons in the chat header (#122406) * fix(ui): resolve project icons from deterministic paths Prepare project icon bytes during chat startup and serve only the process-stable snapshot. Keep all filesystem work asynchronous and lift the breadcrumb trail onto the topbar's optical axis. * fix(ui): keep project icon paths web-focused Drop the IDE-specific icon convention so every candidate remains an explicit web project path. * fix(ui): preserve project icon compatibility Restore existing web icon paths and refresh bounded session snapshots when they are served. Keep the IDE-specific path excluded per the final product decision. --- .../server-methods/chat-history-handler.ts | 12 ++ src/gateway/workspace-icon-http.test.ts | 101 +++++++++++++++-- src/gateway/workspace-icon-http.ts | 103 ++++++++++++++---- ui/src/e2e/chat-header-axis.e2e.test.ts | 15 ++- ui/src/styles/chat/split-view.css | 4 + 5 files changed, 203 insertions(+), 32 deletions(-) diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 16dc847821d6..9c8f8b0e6b50 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -41,6 +41,7 @@ import { resolveSessionModelRef, resolveSessionStoreKey, } from "../session-utils.js"; +import { prepareSessionWorkspaceIcon } from "../workspace-icon-http.js"; import { scheduleChatHistoryManagedMediaCleanup } from "./chat-assistant-content.js"; import { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, @@ -246,6 +247,16 @@ async function handleChatHistoryRequest({ return; } } + const workspaceIconPreparation = + method === "chat.startup" + ? prepareSessionWorkspaceIcon({ sessionKey, agentId: sessionAgentId }).catch( + (error: unknown) => { + context.logGateway.debug( + `chat.startup continuing without a workspace icon: ${formatErrorMessage(error)}`, + ); + }, + ) + : Promise.resolve(); const modelCatalogPromise = method === "chat.history" ? (() => { @@ -512,6 +523,7 @@ async function handleChatHistoryRequest({ ...(includeAgentsList && startupAgentsList ? { agentsList: startupAgentsList } : {}), ...(startupMetadata ? { metadata: startupMetadata } : {}), }; + await workspaceIconPreparation; respond(true, payload); } diff --git a/src/gateway/workspace-icon-http.test.ts b/src/gateway/workspace-icon-http.test.ts index a4b0f72f49e7..15803680ff43 100644 --- a/src/gateway/workspace-icon-http.test.ts +++ b/src/gateway/workspace-icon-http.test.ts @@ -33,6 +33,7 @@ vi.mock("./server-methods/sessions-files.js", () => ({ const { clearWorkspaceIconCacheForTest, handleWorkspaceIconHttpRequest, + prepareSessionWorkspaceIcon, resolveWorkspaceIcon, SVG_ICON_MAX_BYTES, WORKSPACE_ICON_MAX_BYTES, @@ -73,22 +74,35 @@ afterEach(async () => { describe("resolveWorkspaceIcon", () => { const conventions = [ + { relative: "favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, + { relative: "favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "favicon.png", body: PNG_BYTES, contentType: "image/png" }, { relative: "public/favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, { relative: "public/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, { relative: "public/favicon.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "public/favicon-32.png", body: PNG_BYTES, contentType: "image/png" }, { relative: "public/apple-touch-icon.png", body: PNG_BYTES, contentType: "image/png" }, { relative: "static/favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, { relative: "static/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, { relative: "static/favicon.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "ui/public/favicon-32.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "ui/public/favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, + { relative: "ui/public/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "ui/public/favicon.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "app/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "app/favicon.png", body: PNG_BYTES, contentType: "image/png" }, { relative: "app/icon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, { relative: "app/icon.png", body: PNG_BYTES, contentType: "image/png" }, - { relative: "app/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "app/icon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "src/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, + { relative: "src/favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, + { relative: "src/app/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, { relative: "src/app/icon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, { relative: "src/app/icon.png", body: PNG_BYTES, contentType: "image/png" }, - { relative: "src/app/favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, - { relative: "favicon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, - { relative: "favicon.ico", body: ICO_BYTES, contentType: "image/x-icon" }, - { relative: "favicon.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "assets/icon.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, + { relative: "assets/icon.png", body: PNG_BYTES, contentType: "image/png" }, + { relative: "assets/logo.svg", body: SVG_BYTES, contentType: "image/svg+xml" }, + { relative: "assets/logo.png", body: PNG_BYTES, contentType: "image/png" }, ] as const; it.each(conventions)("resolves $relative as $contentType", async (convention) => { @@ -99,16 +113,17 @@ describe("resolveWorkspaceIcon", () => { expect(icon?.etag).toMatch(/^"[\w-]+"$/u); }); - it("prefers the framework-specific location over a bare root favicon", async () => { + it("uses the first valid icon in the fixed precedence", async () => { const root = await makeWorkspace({ "favicon.ico": ICO_BYTES, "public/favicon.svg": SVG_BYTES, + "ui/public/favicon-32.png": PNG_BYTES, }); - expect((await resolveWorkspaceIcon(root))?.contentType).toBe("image/svg+xml"); + expect((await resolveWorkspaceIcon(root))?.contentType).toBe("image/x-icon"); }); const rejected = [ - { label: "an unconventional location", files: { "assets/favicon.ico": ICO_BYTES } }, + { label: "an unconventional location", files: { "vendor/favicon.png": PNG_BYTES } }, { label: "an empty file", files: { "favicon.ico": Buffer.alloc(0) } }, { label: "bytes that are not an image", files: { "favicon.ico": Buffer.from("#!/bin/sh\n") } }, { @@ -212,6 +227,7 @@ describe("handleWorkspaceIconHttpRequest", () => { it("serves the session workspace icon with sandboxed asset headers", async () => { const root = await makeWorkspace({ "public/favicon.ico": ICO_BYTES }); mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:one" }); const response = await fetch(iconRoute("agent:main:one")); expect(mocks.resolveLocalSessionWorkspaceRoot).toHaveBeenCalledWith({ @@ -233,6 +249,7 @@ describe("handleWorkspaceIconHttpRequest", () => { it("revalidates an unchanged icon without resending its bytes", async () => { const root = await makeWorkspace({ "favicon.png": PNG_BYTES }); mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:one" }); const first = await fetch(iconRoute("agent:main:one")); const etag = first.headers.get("etag"); @@ -249,6 +266,7 @@ describe("handleWorkspaceIconHttpRequest", () => { it("omits the body but keeps the representation headers on HEAD", async () => { const root = await makeWorkspace({ "favicon.png": PNG_BYTES }); mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:one" }); const response = await fetch(iconRoute("agent:main:one"), { method: "HEAD" }); expect(response.status).toBe(200); @@ -265,11 +283,77 @@ describe("handleWorkspaceIconHttpRequest", () => { mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue( hasWorkspace ? await makeWorkspace({}) : undefined, ); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:one" }); const response = await fetch(iconRoute("agent:main:one")); expect(response.status).toBe(404); expect(response.headers.get("cache-control")).toBe("no-store"); }); + it("keeps a request made before chat startup retryable", async () => { + const response = await fetch(iconRoute("agent:main:one")); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("retry-after")).toBe("1"); + expect(mocks.resolveLocalSessionWorkspaceRoot).not.toHaveBeenCalled(); + }); + + it("waits for preparation already started by chat startup", async () => { + const root = await makeWorkspace({ "public/favicon.ico": ICO_BYTES }); + mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + + const preparation = prepareSessionWorkspaceIcon({ sessionKey: "agent:main:pending" }); + const responsePromise = fetch(iconRoute("agent:main:pending")); + + await preparation; + const response = await responsePromise; + expect(response.status).toBe(200); + expect(Buffer.from(await response.arrayBuffer()).equals(ICO_BYTES)).toBe(true); + }); + + it("records the fallback when preparation fails", async () => { + mocks.resolveLocalSessionWorkspaceRoot.mockImplementation(() => { + throw new Error("broken workspace metadata"); + }); + await expect(prepareSessionWorkspaceIcon({ sessionKey: "agent:main:broken" })).rejects.toThrow( + "broken workspace metadata", + ); + + const response = await fetch(iconRoute("agent:main:broken")); + expect(response.status).toBe(404); + }); + + it("does no session-store or filesystem resolution in the HTTP request", async () => { + const root = await makeWorkspace({ "ui/public/favicon-32.png": PNG_BYTES }); + mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:one" }); + mocks.resolveLocalSessionWorkspaceRoot.mockClear(); + await fs.rm(path.join(root, "ui/public/favicon-32.png")); + + const first = await fetch(iconRoute("agent:main:one")); + const second = await fetch(iconRoute("agent:main:one")); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(Buffer.from(await first.arrayBuffer()).equals(PNG_BYTES)).toBe(true); + expect(Buffer.from(await second.arrayBuffer()).equals(PNG_BYTES)).toBe(true); + expect(mocks.resolveLocalSessionWorkspaceRoot).not.toHaveBeenCalled(); + }); + + it("keeps a recently served session snapshot across bounded-cache eviction", async () => { + const root = await makeWorkspace({ "favicon.png": PNG_BYTES }); + mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(root); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:kept" }); + for (let index = 0; index < 127; index += 1) { + await prepareSessionWorkspaceIcon({ sessionKey: `agent:main:filler-${index}` }); + } + + expect((await fetch(iconRoute("agent:main:kept"))).status).toBe(200); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:newest" }); + + expect((await fetch(iconRoute("agent:main:kept"))).status).toBe(200); + expect((await fetch(iconRoute("agent:main:filler-0"))).status).toBe(503); + }); + const malformed = ["/__openclaw__/workspace-icon/", "/__openclaw__/workspace-icon/a/b"]; it.each(malformed)("claims %s as a 404 instead of falling through", async (pathname) => { @@ -316,6 +400,7 @@ describe("handleWorkspaceIconHttpRequest", () => { // resolveLocalSessionWorkspaceRoot withholds the root for exec-node sessions // so the route can never answer with this Gateway's own project icon. mocks.resolveLocalSessionWorkspaceRoot.mockReturnValue(undefined); + await prepareSessionWorkspaceIcon({ sessionKey: "agent:main:remote" }); const response = await fetch(iconRoute("agent:main:remote")); expect(response.status).toBe(404); expect(response.headers.get("cache-control")).toBe("no-store"); diff --git a/src/gateway/workspace-icon-http.ts b/src/gateway/workspace-icon-http.ts index 0a858219c59e..b9724865a956 100644 --- a/src/gateway/workspace-icon-http.ts +++ b/src/gateway/workspace-icon-http.ts @@ -1,9 +1,10 @@ // Serves a workspace directory's own project icon so the Control UI can render // real project identity instead of a generic folder glyph. import { createHash } from "node:crypto"; -import fs from "node:fs"; +import { close } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; +import { promisify } from "node:util"; import { fileTypeFromBuffer } from "file-type"; import { openRootFileFollowingParents, @@ -26,28 +27,40 @@ import { import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; /** - * Conventional project icon locations, ordered by framework specificity and then - * by rendering fidelity (vector before raster). Resolution stops at the first - * hit, so this list is the whole filesystem cost of a workspace: keeping it - * bounded is what makes icon lookup a one-time-per-workspace operation. + * Conventional project icon locations in deterministic product precedence. + * Resolution stops at the first valid hit, so this fixed list is the whole + * filesystem cost of opening a workspace and never becomes a recursive scan. */ const WORKSPACE_ICON_RELATIVE_PATHS = [ + "favicon.svg", + "favicon.ico", + "favicon.png", "public/favicon.svg", "public/favicon.ico", "public/favicon.png", + "public/favicon-32.png", "public/apple-touch-icon.png", "static/favicon.svg", "static/favicon.ico", "static/favicon.png", + "ui/public/favicon-32.png", + "ui/public/favicon.svg", + "ui/public/favicon.ico", + "ui/public/favicon.png", + "app/favicon.ico", + "app/favicon.png", "app/icon.svg", "app/icon.png", - "app/favicon.ico", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", - "src/app/favicon.ico", - "favicon.svg", - "favicon.ico", - "favicon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", ] as const; /** Icons are small by construction; anything larger is not a favicon. */ @@ -55,8 +68,10 @@ export const WORKSPACE_ICON_MAX_BYTES = 512 * 1024; /** Vector icons are markup the renderer must parse, so they get a tighter cap. */ export const SVG_ICON_MAX_BYTES = 64 * 1024; const WORKSPACE_ICON_CACHE_MAX_ENTRIES = 32; +const SESSION_WORKSPACE_ICON_CACHE_MAX_ENTRIES = 128; const SVG_MIME_TYPE = "image/svg+xml"; const ICO_MIME_TYPE = "image/x-icon"; +const closeFileDescriptor = promisify(close); /** Sniffable raster types the Control UI can render inside an element. */ const ALLOWED_RASTER_ICON_MIME_TYPES = new Set([ @@ -78,9 +93,11 @@ type WorkspaceIcon = { type WorkspaceIconResolution = WorkspaceIcon | null; let workspaceIconCache = new Map>(); +let sessionWorkspaceIconCache = new Map>(); export function clearWorkspaceIconCacheForTest(): void { workspaceIconCache = new Map(); + sessionWorkspaceIconCache = new Map(); } /** @@ -138,7 +155,7 @@ async function readWorkspaceIconCandidate( } catch { return undefined; } finally { - fs.closeSync(opened.fd); + await closeFileDescriptor(opened.fd); } if (body.byteLength === 0) { return undefined; @@ -189,6 +206,41 @@ const getSessionsFilesModule = createLazyRuntimeModule( () => import("./server-methods/sessions-files.js"), ); +/** + * Prepares the immutable icon snapshot while opening a chat. The HTTP asset + * request only reads this map: no session-store or filesystem work is allowed + * on that hot path, and icon changes become visible after Gateway restart. + */ +export async function prepareSessionWorkspaceIcon(params: { + sessionKey: string; + agentId?: string; +}): Promise { + const preparation = (async (): Promise => { + const workspaceRoot = (await getSessionsFilesModule()).resolveLocalSessionWorkspaceRoot(params); + return workspaceRoot ? await resolveWorkspaceIcon(workspaceRoot) : null; + })(); + sessionWorkspaceIconCache.delete(params.sessionKey); + // A failed optional preparation still becomes a stable fallback snapshot; + // the returned promise rejects separately so chat.startup can record it. + sessionWorkspaceIconCache.set( + params.sessionKey, + preparation.catch(() => null), + ); + pruneMapToMaxSize(sessionWorkspaceIconCache, SESSION_WORKSPACE_ICON_CACHE_MAX_ENTRIES); + await preparation; +} + +function readPreparedSessionWorkspaceIcon( + sessionKey: string, +): Promise | undefined { + const prepared = sessionWorkspaceIconCache.get(sessionKey); + if (prepared) { + sessionWorkspaceIconCache.delete(sessionKey); + sessionWorkspaceIconCache.set(sessionKey, prepared); + } + return prepared; +} + /** `matched` claims the response so a malformed key 404s instead of reaching the SPA. */ type WorkspaceIconRequest = { matched: false } | { matched: true; sessionKey: string | null }; @@ -216,9 +268,8 @@ function parseWorkspaceIconRequest( } /** - * Serves the icon of the workspace a session runs in. The request names a - * session, never a path: the served file is whatever the process-cached - * resolution already picked inside that session's own workspace root. + * Serves the icon snapshot prepared when the chat opened. The request names a + * session, never a path, and performs no filesystem or session-store work. */ export async function handleWorkspaceIconHttpRequest( req: IncomingMessage, @@ -272,15 +323,23 @@ export async function handleWorkspaceIconHttpRequest( return true; } - const workspaceRoot = parsed.sessionKey - ? (await getSessionsFilesModule()).resolveLocalSessionWorkspaceRoot({ - sessionKey: parsed.sessionKey, - }) - : undefined; - const icon = workspaceRoot ? await resolveWorkspaceIcon(workspaceRoot) : null; + if (!parsed.sessionKey) { + res.setHeader("cache-control", "no-store"); + respondNotFound(res); + return true; + } + const prepared = readPreparedSessionWorkspaceIcon(parsed.sessionKey); + if (!prepared) { + // The header can paint before chat.startup finishes. Keep this state + // retryable so it cannot be cached as the workspace's resolved fallback. + res.statusCode = 503; + res.setHeader("cache-control", "no-store"); + res.setHeader("retry-after", "1"); + res.end("workspace icon snapshot is not ready"); + return true; + } + const icon = await prepared; if (!icon) { - // A workspace can gain an icon later, and this route has no revalidation - // token for an absent one; caching the miss would hide it until expiry. res.setHeader("cache-control", "no-store"); respondNotFound(res); return true; diff --git a/ui/src/e2e/chat-header-axis.e2e.test.ts b/ui/src/e2e/chat-header-axis.e2e.test.ts index 2d380ebca27d..e99c1fc1bf3b 100644 --- a/ui/src/e2e/chat-header-axis.e2e.test.ts +++ b/ui/src/e2e/chat-header-axis.e2e.test.ts @@ -72,8 +72,19 @@ suite.define(() => { }; }); - for (const center of Object.values(centers)) { - expect(Math.abs(center - centers.nav), JSON.stringify(centers)).toBeLessThanOrEqual(0.1); + expect(Math.abs(centers.search - centers.nav), JSON.stringify(centers)).toBeLessThanOrEqual( + 0.1, + ); + for (const center of [ + centers.projectIcon, + centers.projectText, + centers.separator, + centers.sessionText, + ]) { + // Text and artwork carry more visible weight below their geometric + // boxes than Lucide actions, so the identity trail needs a 1px + // optical lift to share the topbar's perceived horizontal axis. + expect(centers.nav - center, JSON.stringify(centers)).toBeCloseTo(1, 1); } expect(await header.locator(".chat-pane__crumb-sep").count()).toBe(2); const parent = header.locator(".chat-pane__parent-session"); diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index c6dd88e2826c..ea702915aa6a 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -171,6 +171,10 @@ openclaw-chat-pane { flex: 0 1 auto; min-width: 0; align-items: center; + /* Inter's visible glyph mass sits slightly below the 28px action glyphs even + when their CSS boxes share a center. Lift the whole trail as one optical + unit so project icon, labels, and separator meet the chrome centerline. */ + transform: translateY(-1px); /* Both interactive segments pull their 5px hover padding back out of flow, so this gap is the separator's actual optical air on each side. */ gap: 6px; From 630aac9b25ae6f42c760226662d4a7b3d1545f82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:02:32 -0700 Subject: [PATCH 002/165] feat(onboard): label CLI candidates with subscription vs API-key auth (#122140) Detected Codex/Claude Code CLI candidates now say whether they ride a flat-rate subscription login (ChatGPT, Claude) or a metered API key, so picking one is an informed billing decision for an always-on assistant. Codex classification parses `codex login status` output per codex-rs/cli/src/login.rs; Claude uses prompt-free credential-file and env/apiKeyHelper signals only (never the keychain). Unknown states keep the plain 'logged in'. Display-only: ranking and defaults unchanged. --- src/commands/onboard-inference.test.ts | 58 ++++++++++++++-- src/commands/onboard-inference.ts | 91 ++++++++++++++++++++------ 2 files changed, 124 insertions(+), 25 deletions(-) diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 6f9d424a79b3..39f7e8564f77 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -131,9 +131,49 @@ describe("detectInferenceBackends", () => { "anthropic-api-key", "claude-cli", ]); - expect(candidates[1]).toMatchObject({ credentials: true, detail: "logged in" }); + expect(candidates[1]).toMatchObject({ + credentials: true, + detail: "logged in · API key (usage-billed)", + }); }); + it("labels a Claude CLI environment key as usage-billed", async () => { + const candidates = await detectInferenceBackends({ + env: { ANTHROPIC_API_KEY: "sk-y" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true }), + readClaudeCliCredentials: () => null, + }, + }); + + expect(candidates.find((candidate) => candidate.kind === "claude-cli")?.detail).toBe( + "logged in · API key (usage-billed)", + ); + }); + + it.each(["oauth", "token"])( + "labels parsed Claude CLI %s credentials as a subscription", + async (type) => { + const candidates = await detectInferenceBackends({ + env: {}, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true }), + readClaudeCliCredentials: () => ({ type }), + }, + }); + + expect(candidates).toMatchObject([ + { + kind: "claude-cli", + credentials: true, + detail: "logged in · Claude subscription", + }, + ]); + }, + ); + it("keeps an Anthropic environment key ahead of unknown Claude credentials", async () => { const candidates = await detectInferenceBackends({ env: { ANTHROPIC_API_KEY: "sk-y" }, @@ -176,7 +216,7 @@ describe("detectInferenceBackends", () => { kind: "claude-cli", credentials: true, detail: - "logged in; Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime. If this build is rejected, run `claude update`, restart OpenClaw, and retry.", + "logged in · Claude subscription; Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime. If this build is rejected, run `claude update`, restart OpenClaw, and retry.", }, ]); }); @@ -320,11 +360,19 @@ describe("detectInferenceBackends", () => { ).toBeUndefined(); }); - it("recognizes Codex login status across native credential stores", async () => { + it.each([ + ["ChatGPT", "Logged in using ChatGPT", "logged in · ChatGPT subscription"], + [ + "API key", + "Logged in using an API key - sk-proj-1***23456", + "logged in · API key (usage-billed)", + ], + ["unrecognized auth", "Logged in using access token", "logged in"], + ])("classifies Codex %s login status", async (_auth, loginOutput, expectedDetail) => { const probe = async (command: string, args: string[] = ["--version"]) => ({ command, found: command === "codex", - ...(args[0] === "login" ? {} : { version: "codex 1.0" }), + version: args[0] === "login" ? loginOutput : "codex 1.0", }); const candidates = await detectInferenceBackends({ env: {}, @@ -335,7 +383,7 @@ describe("detectInferenceBackends", () => { }); expect(candidates).toMatchObject([ - { kind: "codex-cli", credentials: true, detail: "logged in" }, + { kind: "codex-cli", credentials: true, detail: expectedDetail }, ]); }); diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index d7341093e7e4..2ca233c1316d 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -83,33 +83,75 @@ function detectCliCredentialState(params: { return params.platform === "darwin" ? undefined : false; } -function describeCliDetail(credentials: boolean | undefined, loginHint: string): string { - if (credentials === true) { +type CliAuthKind = "api-key" | "chatgpt-subscription" | "claude-subscription"; +type CliLoginState = { credentials: boolean | undefined; authKind?: CliAuthKind }; + +const CLI_AUTH_KIND_LABEL: Record = { + "api-key": "API key (usage-billed)", + "chatgpt-subscription": "ChatGPT subscription", + "claude-subscription": "Claude subscription", +}; + +function describeCliDetail(state: CliLoginState, loginHint: string): string { + if (state.authKind) { + return `logged in · ${CLI_AUTH_KIND_LABEL[state.authKind]}`; + } + if (state.credentials === true) { return "logged in"; } - if (credentials === false) { + if (state.credentials === false) { return `installed, not logged in — ${loginHint}, then check again`; } return "installed"; } +function classifyClaudeCliAuth( + credential: { type: string } | null, + env: NodeJS.ProcessEnv, +): CliAuthKind | undefined { + if (env.ANTHROPIC_API_KEY?.trim() || credential?.type === "api_key_helper") { + return "api-key"; + } + if (credential?.type === "oauth" || credential?.type === "token") { + return "claude-subscription"; + } + return undefined; +} + function describeGeminiCliDetail(credentials: boolean | undefined): string { return credentials === true ? "installed; credentials found" : "installed; login status unavailable"; } +async function classifyCodexLoginStatus( + probe: typeof probeLocalCommand, + command: string, +): Promise { + const status = await probe(command, ["login", "status"], { timeoutMs: 3_000 }); + if (status.error) { + // Codex login status covers its own auth store, not custom model-provider + // credentials. Keep failures indeterminate so the live probe decides usability. + return { credentials: undefined }; + } + if (status.version === "Logged in using ChatGPT") { + return { credentials: true, authKind: "chatgpt-subscription" }; + } + if (/^Logged in using an API key - .+$/u.test(status.version ?? "")) { + return { credentials: true, authKind: "api-key" }; + } + return { credentials: true }; +} + +// Deliberately boolean-shaped: this signature is reachable from the exported +// detectInferenceBackends options type and therefore part of the plugin-sdk +// agent-harness API contract. Widening it would bump the contract hash — the +// rich classification stays module-local in classifyCodexLoginStatus. async function detectCodexLoginState( probe: typeof probeLocalCommand, command: string, ): Promise { - const status = await probe(command, ["login", "status"], { timeoutMs: 3_000 }); - if (!status.error) { - return true; - } - // Codex login status covers its own auth store, not custom model-provider - // credentials. Keep failures indeterminate so the live probe decides usability. - return undefined; + return (await classifyCodexLoginStatus(probe, command)).credentials; } function randomizeClaudeCodexTie( @@ -241,7 +283,10 @@ export async function detectInferenceBackends( if (credentials === true && claudeCredential?.type === "oauth") { subscriptionPromotionEligibleCliKinds.add("claude-cli"); } - const detail = describeCliDetail(credentials, "run `claude auth login`"); + const detail = describeCliDetail( + { credentials, authKind: classifyClaudeCliAuth(claudeCredential, env) }, + "run `claude auth login`", + ); // Only the live init record can prove capability support. Keep backports and // wrappers selectable here even when their version predates the known release. cliCandidates.push({ @@ -261,15 +306,21 @@ export async function detectInferenceBackends( } if (codexProbe.found && !codexProbe.timedOut) { const codexCredential = readCodex(); - const credentials = options.deps?.detectCodexLoginState - ? await options.deps.detectCodexLoginState(probe, codexProbe.command) + const loginState: CliLoginState = options.deps?.detectCodexLoginState + ? { credentials: await options.deps.detectCodexLoginState(probe, codexProbe.command) } : options.deps?.readCodexCliCredentials - ? detectCliCredentialState({ - probe: codexProbe, - hasStoredCredentials: codexCredential !== null, - platform, - }) - : await detectCodexLoginState(probe, codexProbe.command); + ? { + credentials: detectCliCredentialState({ + probe: codexProbe, + hasStoredCredentials: codexCredential !== null, + platform, + }), + ...(codexCredential?.type === "oauth" + ? { authKind: "chatgpt-subscription" as const } + : {}), + } + : await classifyCodexLoginStatus(probe, codexProbe.command); + const credentials = loginState.credentials; // Promote only prompt-free ChatGPT OAuth tokens. Status-only logins may be metered; // keychain-only ChatGPT users conservatively stay usable in the fallback tier. if (credentials === true && codexCredential?.type === "oauth") { @@ -279,7 +330,7 @@ export async function detectInferenceBackends( kind: "codex-cli", modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF, label: "Codex", - detail: describeCliDetail(credentials, "run `codex login`"), + detail: describeCliDetail(loginState, "run `codex login`"), ...(credentials === undefined ? {} : { credentials }), }); } From 0e6f38178a2f0c1d6a82118cd24ac9ede055c146 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:08:26 -0700 Subject: [PATCH 003/165] refactor(ui): simplify model effort profile flow (#122412) * refactor(ui): simplify model effort profile flow consolidate the Gateway profile shape, remove New Session's synthetic row, and retain boundary regression coverage. * fix(gateway): preserve thinking projection order Keep deterministic response bytes while consuming the canonical thinking profile. --- .../server-methods/models-list-result.ts | 5 +- src/gateway/session-utils-contracts.ts | 13 ++-- src/gateway/session-utils-model.ts | 30 ++++----- src/gateway/session-utils-store.ts | 7 +- ui/src/lib/chat/thinking.test.ts | 39 ----------- ui/src/lib/chat/thinking.ts | 46 ++++++------- .../chat/components/chat-model-controls.ts | 13 ++-- .../pages/new-session/model-control.test.ts | 64 ------------------- ui/src/pages/new-session/model-control.ts | 16 ++--- 9 files changed, 62 insertions(+), 171 deletions(-) diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 235ad34b73b8..526a7f31606d 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -453,10 +453,7 @@ async function buildPublicModelsListEntries(params: { return { ...buildPublicModelProjection(entry), ...(agentRuntime ? { agentRuntime } : {}), - ...(thinkingProfile && { - thinkingLevels: thinkingProfile.levels, - thinkingDefault: thinkingProfile.defaultLevel, - }), + ...thinkingProfile, ...(capabilityProvider && params.apiKeyCapabilities?.providers.has(capabilityProvider) ? { apiKeySupported: params.apiKeyCapabilities.providers.get(capabilityProvider) === true, diff --git a/src/gateway/session-utils-contracts.ts b/src/gateway/session-utils-contracts.ts index 438b863a53b9..67d12dcc9ec8 100644 --- a/src/gateway/session-utils-contracts.ts +++ b/src/gateway/session-utils-contracts.ts @@ -6,6 +6,11 @@ import type { resolveSessionModelRef } from "../agents/session-model-ref.js"; import type { SubagentRunReadIndex } from "../agents/subagents/registry/subagent-registry-read.js"; import type { SubagentRunReadRecord } from "../agents/subagents/registry/subagent-registry.types.js"; import type { ThinkLevel, listThinkingLevelOptions } from "../auto-reply/thinking.js"; + +export type GatewayModelThinkingProfile = { + thinkingLevels: ReturnType; + thinkingDefault: ThinkLevel; +}; import type { SessionAcpMeta, SessionEntry } from "../config/sessions.js"; import type { ModelCostConfig } from "../utils/usage-format.js"; @@ -18,13 +23,7 @@ export type SessionListRowContext = { subagentRuns: SubagentRunReadIndex; storeChildSessionsByKey: Map; selectedModelByOverrideRef: Map>; - thinkingMetadataByModelRef: Map< - string, - { - levels: ReturnType; - defaultLevel: ThinkLevel; - } - >; + thinkingMetadataByModelRef: Map; displayModelIdentityByKey: Map; modelCostConfigByModelRef: Map; userProfileIdentityById: Map; diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index d7b56dddf769..9224ec97959a 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -44,6 +44,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js"; import { createSessionRowModelCacheKey, + type GatewayModelThinkingProfile, type SessionListRowContext, } from "./session-utils-contracts.js"; import type { GatewaySessionsDefaults, SessionsPatchResult } from "./session-utils.types.js"; @@ -114,10 +115,7 @@ export function resolveGatewayModelThinkingProfile(params: { modelCatalog?: ModelCatalogEntry[]; rowContext?: SessionListRowContext; sessionKey?: string; -}): { - levels: ReturnType; - defaultLevel: ReturnType; -} { +}): GatewayModelThinkingProfile { const catalogEntry = params.modelCatalog ? findModelCatalogEntry(params.modelCatalog, { provider: params.provider, @@ -137,13 +135,13 @@ export function resolveGatewayModelThinkingProfile(params: { }); if (!params.rowContext) { return { - levels: listThinkingLevelOptions( + thinkingLevels: listThinkingLevelOptions( params.provider, params.model, params.modelCatalog, agentRuntime, ), - defaultLevel: resolveGatewaySessionThinkingDefault({ + thinkingDefault: resolveGatewaySessionThinkingDefault({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -162,13 +160,13 @@ export function resolveGatewayModelThinkingProfile(params: { return cached; } const metadata = { - levels: listThinkingLevelOptions( + thinkingLevels: listThinkingLevelOptions( params.provider, params.model, params.modelCatalog, agentRuntime, ), - defaultLevel: resolveGatewaySessionThinkingDefault({ + thinkingDefault: resolveGatewaySessionThinkingDefault({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -264,10 +262,11 @@ export function resolveGatewaySessionThinkingProjectionInternal( return { agentRuntime, thinkingLevel, - effectiveThinkingLevel: thinkingLevel ?? metadata.defaultLevel, - thinkingLevels: metadata.levels, - thinkingOptions: metadata.levels.map((level) => level.label), - thinkingDefault: metadata.defaultLevel, + effectiveThinkingLevel: thinkingLevel ?? metadata.thinkingDefault, + // Preserve the established serialized projection order for byte-stable responses. + thinkingLevels: metadata.thinkingLevels, + thinkingOptions: metadata.thinkingLevels.map((level) => level.label), + thinkingDefault: metadata.thinkingDefault, }; } @@ -309,9 +308,10 @@ export function getSessionDefaults( model: resolved.model ?? null, contextTokens: contextTokens ?? null, agentRuntime, - thinkingLevels: thinkingProfile.levels, - thinkingOptions: thinkingProfile.levels.map((level) => level.label), - thinkingDefault: thinkingProfile.defaultLevel, + // Preserve the established serialized projection order for byte-stable responses. + thinkingLevels: thinkingProfile.thinkingLevels, + thinkingOptions: thinkingProfile.thinkingLevels.map((level) => level.label), + thinkingDefault: thinkingProfile.thinkingDefault, }; } diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index 749ac4bf2248..f047946fda2d 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -364,9 +364,10 @@ export function listAgentsForGateway( workspace, workspaceGit, agentRuntime, - thinkingLevels: thinkingProfile.levels, - thinkingOptions: thinkingProfile.levels.map((level) => level.label), - thinkingDefault: thinkingProfile.defaultLevel, + // Preserve the established serialized projection order for byte-stable responses. + thinkingLevels: thinkingProfile.thinkingLevels, + thinkingOptions: thinkingProfile.thinkingLevels.map((level) => level.label), + thinkingDefault: thinkingProfile.thinkingDefault, }, model ? { model } : {}, ); diff --git a/ui/src/lib/chat/thinking.test.ts b/ui/src/lib/chat/thinking.test.ts index 8fac02df7478..f34f47858bff 100644 --- a/ui/src/lib/chat/thinking.test.ts +++ b/ui/src/lib/chat/thinking.test.ts @@ -20,9 +20,6 @@ describe("chat thinking helpers", () => { resolveThinkingLevelInput( "ultra", { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, thinkingLevels: [{ id: "ultra", label: "Ultra" }], }, undefined, @@ -93,40 +90,4 @@ describe("chat thinking helpers", () => { expect(state.options.map((option) => option.value)).not.toContain("ultra"); }); - - it("uses identical reasoning options for a canonical default and explicit selection", () => { - const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].map( - (id) => ({ id, label: id }), - ); - const defaults = { - modelProvider: "openai", - model: "gpt-5.6-sol", - contextTokens: null, - thinkingLevels, - thinkingOptions: thinkingLevels.map((level) => level.label), - thinkingDefault: "medium", - }; - const inherited = resolveChatThinkingSelectState({ - catalog: [{ provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol", reasoning: true }], - defaults, - sessionKey: "new-session:main", - session: { key: "new-session:main", kind: "direct", updatedAt: null }, - sessionsResult: null, - }); - const explicit = resolveChatThinkingSelectState({ - catalog: [{ provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol", reasoning: true }], - defaults, - sessionKey: "new-session:main", - session: { - key: "new-session:main", - kind: "direct", - updatedAt: null, - modelProvider: "openai", - model: "gpt-5.6-sol", - }, - sessionsResult: null, - }); - - expect(explicit).toEqual(inherited); - }); }); diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts index 12abc748150e..964b1e79f77f 100644 --- a/ui/src/lib/chat/thinking.ts +++ b/ui/src/lib/chat/thinking.ts @@ -16,20 +16,22 @@ import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; type ThinkingSessionDefaults = SessionsListResult["defaults"] | undefined; -type ChatThinkingSelection = - | { - kind: "anchored"; - source: "override" | "default"; - value: string; - displayLabel: string; - index: number; - } - | { - kind: "unanchored"; - source: "override" | "default"; - value: string; - displayLabel: string; - }; +type ChatThinkingSelection = { + source: "override" | "default"; + value: string; + displayLabel: string; +} & ({ kind: "anchored"; index: number } | { kind: "unanchored" }); + +export type ChatThinkingTarget = Pick< + GatewaySessionRow, + | "agentRuntime" + | "model" + | "modelProvider" + | "thinkingDefault" + | "thinkingLevel" + | "thinkingLevels" + | "thinkingOptions" +>; export type ChatThinkingSelectState = { selection: ChatThinkingSelection; @@ -38,7 +40,7 @@ export type ChatThinkingSelectState = { }; function resolveThinkingLevelOptionsForSession( - session: GatewaySessionRow | undefined, + session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, ): GatewayThinkingLevelOption[] { const { provider, model } = resolveThinkingTargetModel({ defaults, session }); @@ -46,7 +48,7 @@ function resolveThinkingLevelOptionsForSession( } export function formatThinkingCommandOptionsForSession( - session: GatewaySessionRow | undefined, + session: ChatThinkingTarget | undefined, defaults?: SessionsListResult["defaults"], ): string { const options = resolveThinkingLevelOptionsForSession(session, defaults) @@ -57,7 +59,7 @@ export function formatThinkingCommandOptionsForSession( export function resolveThinkingLevelInput( rawLevel: string, - session: GatewaySessionRow | undefined, + session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, ): string | undefined { const normalized = normalizeThinkLevel(rawLevel); @@ -74,7 +76,7 @@ export function resolveThinkingLevelInput( } export function isThinkingLevelOptionForSession( - session: GatewaySessionRow | undefined, + session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, level: string, ): boolean { @@ -85,7 +87,7 @@ export function isThinkingLevelOptionForSession( } export function resolveCurrentThinkingLevel( - session: GatewaySessionRow | undefined, + session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, models: ModelCatalogEntry[], ): string { @@ -143,7 +145,7 @@ function isOffOnlyThinkingLevels(levels: readonly GatewayThinkingLevelOption[]): function resolveThinkingTargetModel(params: { defaults: ThinkingSessionDefaults; - session: GatewaySessionRow | undefined; + session: ChatThinkingTarget | undefined; }): { provider: string | null; model: string | null } { return { provider: params.session?.modelProvider ?? params.defaults?.modelProvider ?? null, @@ -167,7 +169,7 @@ function resolveThinkingLevelOptions(params: { hideUnsupportedOffOnly?: boolean; model: string | null; provider: string | null; - session: GatewaySessionRow | undefined; + session: ChatThinkingTarget | undefined; }): GatewayThinkingLevelOption[] { const modelMatchesDefaults = sessionModelMatchesDefaults(params.session, params.defaults); const catalogEntry = resolveThinkingCatalogEntry(params.catalog, params.provider, params.model); @@ -209,7 +211,7 @@ function resolveThinkingLevelOptions(params: { export function resolveChatThinkingSelectState(params: { catalog: readonly ModelCatalogEntry[]; defaults?: SessionsListResult["defaults"]; - session?: GatewaySessionRow; + session?: ChatThinkingTarget; sessionKey: string; sessionsResult: SessionsListResult | null; }): ChatThinkingSelectState { diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 505c6bba2580..bda334b276a4 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -1,10 +1,6 @@ // Chat-owned model, reasoning, and fast-mode picker orchestration. import { html } from "lit"; -import type { - GatewaySessionRow, - ModelCatalogEntry, - SessionsListResult, -} from "../../../api/types.ts"; +import type { ModelCatalogEntry, SessionsListResult } from "../../../api/types.ts"; import { t } from "../../../i18n/index.ts"; import { normalizeChatModelProviderId } from "../../../lib/chat/model-ref.ts"; import { @@ -12,7 +8,10 @@ import { resolveChatModelSelectState, type ChatFastModeSelectValue, } from "../../../lib/chat/model-select-state.ts"; -import { resolveChatThinkingSelectState } from "../../../lib/chat/thinking.ts"; +import { + resolveChatThinkingSelectState, + type ChatThinkingTarget, +} from "../../../lib/chat/thinking.ts"; import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; import { renderChatEffortPicker } from "./chat-effort-picker.ts"; import { @@ -46,7 +45,7 @@ type ChatModelControlsProps = { sessionsResult: SessionsListResult | null; stream: string | null; thinkingDefaults?: SessionsListResult["defaults"]; - thinkingSession?: GatewaySessionRow; + thinkingSession?: ChatThinkingTarget; onFastModeSelect?: (value: ChatFastModeSelectValue, sessionKey: string) => unknown; onModelSelect?: (value: string, sessionKey: string) => unknown; onModelPickerTargetSelect?: (groupId: string, value: string) => unknown; diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index c75a415b649a..29c0416d3a58 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -664,70 +664,6 @@ describe("new-session model runtime", () => { }); }); - it("keeps xhigh anchored to the selected model profile across an interactive model switch", async () => { - const levels = (ids: string[]) => ids.map((id) => ({ id, label: id })); - const { context, request } = contextWith([ - { - id: "k3", - name: "Kimi K3", - provider: "kimi", - reasoning: true, - thinkingLevels: levels([ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", - ]), - thinkingDefault: "high", - }, - { - id: "gpt-5.6-sol", - name: "GPT-5.6 Sol", - provider: "openai", - reasoning: true, - thinkingLevels: levels(["off", "minimal", "low", "medium", "high", "xhigh", "max"]), - thinkingDefault: "medium", - }, - ]); - const onSelectionChange = vi.fn(); - const control = new NewSessionModelControl(() => undefined, onSelectionChange); - control.load(context, "main", true); - await vi.waitFor(() => { - expect(request).toHaveBeenCalledOnce(); - expect( - renderControl(control, context).querySelector( - '[data-chat-model-option="openai/gpt-5.6-sol"]', - ), - ).not.toBeNull(); - }); - control.selected = "kimi/k3"; - control.thinkingLevel = "xhigh"; - - renderControl(control, context) - .querySelector('[data-chat-model-option="openai/gpt-5.6-sol"]') - ?.click(); - - expect(control.selected).toBe("openai/gpt-5.6-sol"); - expect(control.thinkingLevel).toBe("xhigh"); - expect(onSelectionChange).toHaveBeenLastCalledWith({ - model: "openai/gpt-5.6-sol", - thinkingLevel: "xhigh", - }); - const container = renderControl(control, context); - const slider = container.querySelector('[data-chat-thinking-slider="true"]'); - expect(slider?.dataset.chatThinkingValues).toBe("off,minimal,low,medium,high,xhigh,max"); - expect(slider?.value).toBe("5"); - expect(slider?.max).toBe("6"); - expect(slider?.getAttribute("aria-valuetext")).toBe("Extra high"); - expect( - Number.parseFloat(slider?.style.getPropertyValue("--reasoning-fill") ?? "0"), - ).toBeCloseTo(83.33, 1); - }); - it("clears xhigh when an interactive model switch targets a profile ending at high", async () => { const levels = (ids: string[]) => ids.map((id) => ({ id, label: id })); const { context, request } = contextWith([ diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index b97b2e86dc69..97bf520d180f 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -6,7 +6,7 @@ import type { SessionCatalog, SessionsCatalogListResult, } from "../../../../packages/gateway-protocol/src/index.ts"; -import type { GatewayAgentRow, GatewaySessionRow, ModelCatalogEntry } from "../../api/types.ts"; +import type { GatewayAgentRow, ModelCatalogEntry } from "../../api/types.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { t } from "../../i18n/index.ts"; import { @@ -584,14 +584,10 @@ export class NewSessionModelControl { this.catalog, ); const selectedTarget = resolveDraftModelTarget(this.selected, undefined, this.catalog); - const draftRow: GatewaySessionRow = { - key: sessionKey, - kind: "direct", - updatedAt: null, - ...(selectedTarget - ? { model: selectedTarget.model, modelProvider: selectedTarget.provider ?? undefined } - : {}), - ...(this.thinkingLevel ? { thinkingLevel: this.thinkingLevel } : {}), + const thinkingTarget = { + model: selectedTarget?.model, + modelProvider: selectedTarget?.provider ?? undefined, + thinkingLevel: this.thinkingLevel || undefined, }; const thinkingDefaults = { ...sourceResult?.defaults, @@ -639,7 +635,7 @@ export class NewSessionModelControl { showFastMode: false, stream: null, thinkingDefaults, - thinkingSession: draftRow, + thinkingSession: thinkingTarget, onModelSelect: (value) => { this.selectionGeneration += 1; this.restoringPreference = false; From ec4ae78b752e27423497b7c69ead26bf35954698 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 09:42:16 +0530 Subject: [PATCH 004/165] fix(agents): generate session titles with runtime-owned auth (#122163) Use the selected harness for isolated title generation, including native Codex auth. Retry failed dashboard titles from the first user message without overriding manual names. Co-authored-by: Ayaan Zaidi --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/plugins/sdk-agent-harness.md | 29 +- extensions/codex/harness.test.ts | 89 +++ extensions/codex/harness.ts | 75 +- .../codex/src/app-server/bounded-turn.test.ts | 54 ++ .../codex/src/app-server/bounded-turn.ts | 26 +- .../event-projector-assistant-message.ts | 20 +- .../app-server/isolated-completion.test.ts | 174 +++++ .../src/app-server/isolated-completion.ts | 97 +++ .../src/app-server/settled-turn-finalizer.ts | 1 + .../codex/src/web-search-provider.runtime.ts | 1 + extensions/copilot/harness.test.ts | 109 +-- extensions/copilot/harness.ts | 6 +- extensions/copilot/src/isolated-completion.ts | 57 +- .../src/monitor/thread-title.generate.test.ts | 273 ++------ .../discord/src/monitor/thread-title.ts | 72 +- src/agents/harness/builtin-openclaw.test.ts | 44 +- src/agents/harness/builtin-openclaw.ts | 9 +- src/agents/harness/types.ts | 33 +- src/agents/isolated-completion.test.ts | 392 ++++++++++- src/agents/isolated-completion.ts | 285 +++++++- .../conversation-label-generator.test.ts | 662 ++++-------------- .../reply/conversation-label-generator.ts | 334 +++------ src/gateway/dashboard-session-title.test.ts | 92 ++- src/gateway/dashboard-session-title.ts | 59 +- .../server-methods/chat-send-background.ts | 1 + .../server-methods/session-discussion.test.ts | 22 +- .../server-methods/session-discussion.ts | 33 +- src/plugin-sdk/agent-harness-runtime.test.ts | 9 + 46 files changed, 1837 insertions(+), 1257 deletions(-) create mode 100644 extensions/codex/src/app-server/isolated-completion.test.ts create mode 100644 extensions/codex/src/app-server/isolated-completion.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2a530c0f36a0..57dac44fd419 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"5e739e6ec9fd1a63b1fad71781d063c47b0e2169d8e39cc8cbf21352ed1333f4","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"606504bb06b321f5a4fef507f322c297a9295dccdc2e5996ac64603049113408","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 6a77527735c6..2a8b60ee0264 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"7a620697c8689b8ddd08f9d9ec31e54240455158e43277946178caa8b81c3872","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"40d137a31b2ac9f1da776aafcc77b54422b1610c91fe2d6594068e1f7285e91b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 00d2a0a258c3..156c909f3056 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"c462572277db06da0e31193b91fef1ff87682602665148d89fbbf848929f11a9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"6257c43a60153049dac1af0d828435081f10f6bcd2554e2a67d1c8adb6df973c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index f402b1376699..876806d33a11 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"c202b9e8bbbcc1d35d29e9ce92e0d9fd5a08a5a9c2c4c6a4eb6f621a52839da9","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"88113b5ae8119ca780a3d93b0e033c87d0c387b21a29b72e72b375f5c77f08b3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 4d2e61f084b9..12d12f0322ff 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"22a0413c4e79e1c1cd51e122681bf7ad3e7c867668e61dd2f510ea9e14968891","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"a85b4bbcd416a76bde8d58af34fab58bdb416a1b2c8476b46a3ffd8540754e04","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 1ded94d41b1d..051fa3974425 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"810a5da4a06925554ab89f9462a17f1fb9fa3196277227eaa383aa82b3b81e58","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"12e07b9ba7b5b35c1f4e0f4510a073adac00671d292a72700025c86376db22b0","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 0ebc205b6c16..41cf20d4e926 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"604289da3812346c5a080a9f867a32f4fcc0e06a9837f61395c961d8363d8b9d","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"f36ace33d1e16649ed888032751d5db67e826cdc59f06732a5c13b5185f971ed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 581e18aa177d..775825f24dd0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"1ecacce44a31327293a1ab1b2e24e3f085e526764f90fbb367687c42e029cce4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"7451e2a5ffc615f6e8fedebbf4327caf3a64a30973828ca776ca8e298052eebf","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 7c1501dcd9a8..4be0b091d72d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"b9936060e6ca111906bdca8409097589f8548304b947a7762dc068992f0d2d95","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"726e1cc6c7c0463333587b25908a0b4274c9ca2e1c7d696ab7af5cb392aeca07","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index ae7c44a68622..d84bd6799495 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"c7fbb54e9ad926e520df85c396531eadbab78e042ffe315dd3311a3508eb6e55","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"da9273b6213137fdfc3c2f0f22de681c7bd664dfb4fd0fd0f7636b70cad9835b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index e13f7ffbc91c..d024143ffc6d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"4ea730b41856a414960f55940197485c5b9a80782f7584425eedd8fca585a177","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"2328445ee010703050ecce3ea4b823881172b1da8ffd061cb149a7324ac3b967","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index af3a0f2510ff..a7e49f33706f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"6d3f6a64b8ad7459763f4c62a5f731b679ddc64eba4b3463be122659546b2b58","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"cd50a67407a27b36d6f3e22af00940d1031bfe907fc15be5a72187e68f04d822","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 9e081752c72e..79ea9f3b1493 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"e7a9ba2f6a48e5c3c2f7cc4a3d6be9d4776deb0c55b4a22084968ad19ad64fbc","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"ba7ea5cfd334e44f5c5c2571a2161961a44f51e64e45074917fab3e69ac7652f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 406cd885b820..8dbf79d536d2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"a0bbe80276278db6a7f9981d5fb0b2e990438995e76db85d40bf695924e2ad19","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"6f6b852b66e41c6f2c15b617bc00148efbafc2d737049b19b45fe3b25eebb9fe","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index d2cfe93ae0eb..577914c7b783 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"cec5c1bb21caf43610e93817539237330ba78d3ebbe7e21a4218914129a564c2","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"a41c05428a9a5430b2d81ed310aa5efcbeddcef7573adfd8af5ec415bfbbba97","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index c7eeb7a97fe7..27b48726206c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"682fa22e552f9663f901c9928b49546cf73a9e91824923c794660672f8de1288","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"74f042e649fe9ad18cee1eb327622b526636b470e7c230c2e280e11e990c7764","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index a52f4f826d4b..e7cf05ffec50 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"1759143daf318471c30e816f8651b52ee01c1edbade4f0a9f87b4260b11a21a6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"0686a3b02b9bae23e46af30fdddf46487db53ec6ffe0833ce9cf298aded54362","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 1d2b55e775cf..3e29106b8dd2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1eff4a94da8119d071af4884b96f77fe2b3a73650b9be79bfb2703af510e0a8e","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"80e15361b1e42280548074fc349fd32a45b55dd622c33bc37a0e2db963852790","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 61a78b2e18a6..4ee0e941b4e6 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -170,12 +170,21 @@ export default definePluginEntry({ ### Isolated completion -The optional `runIsolatedCompletion(params)` capability serves product paths +The optional `runIsolatedCompletionV2(params)` capability serves product paths that require one fresh prompt-only inference call with a literal empty -model-callable tool surface. Core passes the exact prepared `model`, `auth`, -provider, model id, system prompt, user prompt, timeout, abort signal, and stream -parameters. The harness must not re-resolve credentials, switch routes, reuse a -native thread, attach tools, invoke agent lifecycle hooks, or deliver output. +model-callable tool surface. Core passes provider and model ids, prompts, +deadline controls, and one prepared `authorization`: + +- `owner: "host"` contains the exact transport `model` and resolved `auth`. +- `owner: "harness"` contains the prepared runtime auth plan and a credential + snapshot restricted to the single profile selected for that call. Core owns + automatic fallback order and invokes the harness separately for each candidate. + +Host-authorized calls must use the supplied model and credential without +substitution. Harness-authorized calls may resolve only the supplied prepared +route and scoped profiles, or the harness's native account when the plan leaves +auth to the harness. The harness must not switch routes, reuse a native thread, +attach tools, invoke agent lifecycle hooks, or deliver output. Return `{ assistant: AssistantMessage }`. Core accepts only terminal text/thinking content with a `stop` or `length` stop reason; tool calls, failed stops, and empty @@ -187,9 +196,15 @@ Plugin callers select this behavior through the harness callback is the provider-side enforcement SPI, not a second caller API. +The legacy `runIsolatedCompletion(params)` host-auth-only capability is +deprecated and remains available for external plugins through 2026-10-12. +Implement V2 for harness-owned or native authentication; OpenClaw never invents +a host credential when only the legacy capability is present. + Native agent servers often have ambient built-in tools even when OpenClaw sends -an empty tool list. In that case, use a separate provider transport that can -serialize a true zero-tool request, or leave the capability unsupported. +an empty tool list. Disable and attest those native capabilities for the fresh +turn, use a separate transport that can serialize a true zero-tool request, or +leave the capability unsupported. ### Delegated execution diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 35e6481eb531..05036ffe2a13 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -6,10 +6,14 @@ import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it, vi } from "vitest"; const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn()); +const runCodexIsolatedCompletion = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/simple-completion-runtime", () => ({ completeWithPreparedSimpleCompletionModel, })); +vi.mock("./src/app-server/isolated-completion.js", () => ({ + runCodexIsolatedCompletion, +})); import { createCodexAppServerAgentHarness } from "./harness.js"; import { @@ -66,6 +70,91 @@ describe("Codex agent harness supports()", () => { ); }); + it("delegates V2 isolated completion to the native bounded adapter", async () => { + const legacyCallCount = completeWithPreparedSimpleCompletionModel.mock.calls.length; + const result = { + assistant: { + role: "assistant", + content: [{ type: "text", text: "done" }], + stopReason: "stop", + }, + }; + runCodexIsolatedCompletion.mockResolvedValueOnce(result); + const params = { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } as unknown as Parameters>[0]; + + await expect(harness.runIsolatedCompletionV2?.(params)).resolves.toBe(result); + expect(runCodexIsolatedCompletion).toHaveBeenCalledWith(params, { pluginConfig: undefined }); + expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(legacyCallCount); + }); + + it("keeps V2 host authorization on the prepared direct transport", async () => { + const nativeCallCount = runCodexIsolatedCompletion.mock.calls.length; + const assistant = { + role: "assistant", + content: [{ type: "text", text: "done" }], + stopReason: "stop", + }; + completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce(assistant); + const websocketHarness = createCodexAppServerAgentHarness({ + bindingStore: testCodexAppServerBindingStore, + pluginConfig: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4501" }, + }, + }); + const hostModel = { + provider: "openai", + id: "gpt-test", + api: "openai-responses", + }; + const hostAuth = { apiKey: "secret", source: "profile:test", mode: "api-key" }; + const params = { + authorization: { + owner: "host", + model: hostModel, + auth: hostAuth, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } as unknown as Parameters>[0]; + + await expect(websocketHarness.runIsolatedCompletionV2?.(params)).resolves.toEqual({ + assistant, + }); + expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledWith( + expect.objectContaining({ + model: hostModel, + auth: hostAuth, + context: expect.objectContaining({ tools: [] }), + }), + ); + expect(runCodexIsolatedCompletion).toHaveBeenCalledTimes(nativeCallCount); + }); + it("supports the canonical codex virtual provider", () => { expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({ supported: true, diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 72d2f61ca60e..3d43f5ea05bf 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -33,6 +33,36 @@ type CodexAppServerAgentHarness = AgentHarnessV2 & { ): Promise; }; +type CodexHostPreparedIsolatedCompletionParams = Parameters< + NonNullable +>[0]; + +async function runCodexHostPreparedIsolatedCompletion( + params: CodexHostPreparedIsolatedCompletionParams, +) { + const timeoutSignal = AbortSignal.timeout(params.timeoutMs); + const signal = params.abortSignal + ? AbortSignal.any([params.abortSignal, timeoutSignal]) + : timeoutSignal; + const assistant = await completeWithPreparedSimpleCompletionModel({ + model: params.model, + auth: params.auth, + cfg: params.config, + context: { + systemPrompt: params.systemPrompt, + messages: [{ role: "user", content: params.prompt, timestamp: Date.now() }], + tools: [], + }, + options: { + maxTokens: params.streamParams?.maxTokens, + temperature: params.streamParams?.temperature, + reasoning: params.thinkLevel, + signal, + }, + }); + return { assistant }; +} + async function disposeSharedCodexAppServerClients(): Promise { const dispose = ( globalThis as typeof globalThis & { @@ -186,31 +216,28 @@ export function createCodexAppServerAgentHarness(options: { nativeHookRelay: { enabled: true }, }); }, - runIsolatedCompletion: async (params) => { - // Codex app-server always exposes update_plan. Pure inference therefore - // uses the already-prepared OpenAI/ChatGPT transport and credential - // directly, without entering a Codex thread or re-resolving the route. - const timeoutSignal = AbortSignal.timeout(params.timeoutMs); - const signal = params.abortSignal - ? AbortSignal.any([params.abortSignal, timeoutSignal]) - : timeoutSignal; - const assistant = await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, - cfg: params.config, - context: { - systemPrompt: params.systemPrompt, - messages: [{ role: "user", content: params.prompt, timestamp: Date.now() }], - tools: [], - }, - options: { - maxTokens: params.streamParams?.maxTokens, - temperature: params.streamParams?.temperature, - reasoning: params.thinkLevel, - signal, - }, + runIsolatedCompletionV2: async (params) => { + if (params.authorization.owner === "host") { + const { authorization, ...commonParams } = params; + return runCodexHostPreparedIsolatedCompletion({ + ...commonParams, + model: authorization.model, + auth: authorization.auth, + ...(authorization.sourceAuthFingerprint + ? { sourceAuthFingerprint: authorization.sourceAuthFingerprint } + : {}), + }); + } + const { runCodexIsolatedCompletion } = + await import("./src/app-server/isolated-completion.js"); + return runCodexIsolatedCompletion(params, { + pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig, }); - return { assistant }; + }, + runIsolatedCompletion: async (params) => { + // Keep the deprecated V1 contract on its exact host-prepared transport. + // V2 owns native Codex auth and zero-tool attestation above. + return runCodexHostPreparedIsolatedCompletion(params); }, finalizeSettledTurn: async (params) => { const { runCodexSettledTurnFinalization } = diff --git a/extensions/codex/src/app-server/bounded-turn.test.ts b/extensions/codex/src/app-server/bounded-turn.test.ts index bafb0e6b4ee0..fe136b62e6a2 100644 --- a/extensions/codex/src/app-server/bounded-turn.test.ts +++ b/extensions/codex/src/app-server/bounded-turn.test.ts @@ -410,6 +410,56 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { ).rejects.toThrow("turn ended with status interrupted"); }); + it("forwards one prepared authorization selection to the isolated client", async () => { + const fake = createClientFactory(); + const preparedAuth = { kind: "api-key" as const, apiKey: "test-key" }; + + await runBoundedCodexAppServerTurn({ + model: { mode: "required", id: "gpt-5.4" }, + preparedAuth, + authRequirement: "api-key", + timeoutMs: 5_000, + options: { + clientFactory: fake.factory, + pluginConfig: { appServer: { homeScope: "user" } }, + }, + taskLabel: "isolated completion", + developerInstructions: "Answer only.", + input: [{ type: "text", text: "Name this conversation.", text_elements: [] }], + requiredModalities: ["text"], + isolation: "private-stdio", + requireNoExternalCapabilities: true, + }); + + expect(fake.factory).toHaveBeenCalledWith( + expect.objectContaining({ + preparedAuth, + authRequirement: "api-key", + startOptions: expect.objectContaining({ homeScope: "agent" }), + }), + ); + expect(vi.mocked(fake.factory).mock.calls[0]?.[0]).not.toHaveProperty("authProfileId"); + }); + + it("preserves the configured native model provider when no override is supplied", async () => { + const fake = createClientFactory(); + + await runBoundedCodexAppServerTurn({ + model: { mode: "required", id: "gpt-5.4" }, + timeoutMs: 5_000, + options: { clientFactory: fake.factory }, + taskLabel: "isolated completion", + developerInstructions: "Answer only.", + input: [{ type: "text", text: "Name this conversation.", text_elements: [] }], + requiredModalities: ["text"], + isolation: "configured-transport", + requireNoExternalCapabilities: true, + }); + + const startParams = fake.request.mock.calls.find(([method]) => method === "thread/start")?.[1]; + expect(startParams).not.toHaveProperty("modelProvider"); + }); + it("attests ring-zero and injects frozen history before starting the final turn", async () => { const fake = createClientFactory(); const historyItems: JsonValue[] = [ @@ -465,9 +515,13 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { "features.hooks": false, "features.multi_agent": false, "features.multi_agent_v2": false, + "features.code_mode": false, + "features.code_mode_only": false, "skills.include_instructions": false, include_environment_context: false, mcp_servers: { inherited: { enabled: false } }, + "tools.experimental_request_user_input.enabled": false, + "tools.update_plan.enabled": false, }, }); const turnParams = fake.request.mock.calls.find(([method]) => method === "turn/start")?.[1]; diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index 069bc7084c25..75006a39229f 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -7,6 +7,7 @@ import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS, + closeCodexStartupClientBestEffort, interruptCodexTurnAndWaitBestEffort, } from "./attempt-client-cleanup.js"; import { @@ -14,6 +15,7 @@ import { isTerminalTurnStatus, readCodexNotificationItem, } from "./attempt-notifications.js"; +import type { CodexAppServerAuthRequirement, CodexAppServerPreparedAuth } from "./auth-bridge.js"; import type { CodexAppServerClient } from "./client.js"; import { resolveCodexAppServerRuntimeOptions } from "./config.js"; import { normalizeCodexResponseTokenUsage } from "./event-projector-usage.js"; @@ -95,7 +97,10 @@ class CodexBoundedTurnTimeoutError extends Error { type CodexBoundedTurnParams = { config?: OpenClawConfig; model: CodexBoundedTurnModelSelection; + modelProvider?: string; profile?: string; + preparedAuth?: CodexAppServerPreparedAuth; + authRequirement?: CodexAppServerAuthRequirement; timeoutMs: number; signal?: AbortSignal; agentDir?: string; @@ -163,14 +168,24 @@ async function runBoundedCodexAppServerTurnInWorkspace( // Hosted search needs a private Codex home and cwd so inherited native tools // cannot escape the bounded turn. Media calls retain configured transport // compatibility while still using an isolated ephemeral thread. - const startOptions = workspace.codexHome + const isolatedStartOptions = workspace.codexHome ? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome) : appServer.start; + // A prepared credential is scoped to the fresh private home even when the + // operator's configured app-server normally points at their user home. + const startOptions = + workspace.codexHome && params.preparedAuth + ? { ...isolatedStartOptions, homeScope: "agent" as const } + : isolatedStartOptions; const ownsClient = !params.options.clientFactory; + const authSelection = params.preparedAuth + ? { preparedAuth: params.preparedAuth } + : { authProfileId: params.profile }; const client = params.options.clientFactory ? await params.options.clientFactory({ startOptions, - authProfileId: params.profile, + ...authSelection, + authRequirement: params.authRequirement, agentDir, config: params.config, timeoutMs, @@ -180,7 +195,8 @@ async function runBoundedCodexAppServerTurnInWorkspace( createIsolatedCodexAppServerClient({ startOptions, timeoutMs, - authProfileId: params.profile, + ...authSelection, + authRequirement: params.authRequirement, agentDir, authProfileStore: params.authProfileStore, config: params.config, @@ -244,7 +260,7 @@ async function runBoundedCodexAppServerTurnInWorkspace( "thread/start", { model, - modelProvider: "openai", + ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), cwd: workspace.cwd, approvalPolicy: "on-request", sandbox: "read-only", @@ -339,7 +355,7 @@ async function runBoundedCodexAppServerTurnInWorkspace( params.signal?.removeEventListener("abort", abortFromCaller); await interruptPromise; if (ownsClient) { - client.close(); + await closeCodexStartupClientBestEffort(client); } } if (retrySelection) { diff --git a/extensions/codex/src/app-server/event-projector-assistant-message.ts b/extensions/codex/src/app-server/event-projector-assistant-message.ts index b14fca630dbe..3dc5e4cf87e5 100644 --- a/extensions/codex/src/app-server/event-projector-assistant-message.ts +++ b/extensions/codex/src/app-server/event-projector-assistant-message.ts @@ -11,6 +11,11 @@ import { type CodexAssistantMessageParams = CodexLocalRuntimeAttributionParams & Pick; +type CodexAssistantAttribution = { + provider: string; + modelId: string; + api?: AssistantMessage["api"]; +}; type CodexAssistantUsage = Usage & { // Codex is a managed runtime; keep reasoning telemetry private to managed consumers. @@ -44,6 +49,19 @@ export function createAssistantMessage( options: AssistantMessageOptions, ): AssistantMessage { const attribution = resolveCodexLocalRuntimeAttribution(params); + return createAttributedCodexAssistantMessage( + { ...attribution, modelId: params.modelId }, + text, + options, + ); +} + +/** Creates a Codex assistant row when a bounded call already owns attribution. */ +export function createAttributedCodexAssistantMessage( + attribution: CodexAssistantAttribution, + text: string, + options: AssistantMessageOptions, +): AssistantMessage { const usage: CodexAssistantUsage = options.tokenUsage ? { input: options.tokenUsage.input ?? 0, @@ -70,7 +88,7 @@ export function createAssistantMessage( content: [{ type: "text", text }], api: attribution.api ?? "openai-chatgpt-responses", provider: attribution.provider, - model: params.modelId, + model: attribution.modelId, usage, stopReason: options.aborted ? "aborted" : options.promptError ? "error" : "stop", errorMessage: options.promptError ? formatErrorMessage(options.promptError) : undefined, diff --git a/extensions/codex/src/app-server/isolated-completion.test.ts b/extensions/codex/src/app-server/isolated-completion.test.ts new file mode 100644 index 000000000000..23532ce09953 --- /dev/null +++ b/extensions/codex/src/app-server/isolated-completion.test.ts @@ -0,0 +1,174 @@ +import type { AgentHarnessV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAuthHandoff: vi.fn(), + runBoundedTurn: vi.fn(), +})); + +vi.mock("./auth-bridge.js", () => ({ + resolveCodexAppServerPreparedAuthHandoff: mocks.resolveAuthHandoff, +})); +vi.mock("./bounded-turn.js", () => ({ + runBoundedCodexAppServerTurn: mocks.runBoundedTurn, +})); + +import { runCodexIsolatedCompletion } from "./isolated-completion.js"; + +type IsolatedParams = Parameters>[0]; + +const authProfileStore = { + version: 1, + profiles: { + "openai:test": { + type: "oauth", + provider: "openai", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }, + }, +}; + +function createParams(): IsolatedParams { + return { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:test", + modelRoute: { + provider: "openai", + modelId: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + authProfileStore, + }, + config: {}, + provider: "openai", + modelId: "gpt-5.4", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + systemPrompt: "Name the conversation.", + prompt: "Help me plan a garden.", + timeoutMs: 5_000, + } as unknown as IsolatedParams; +} + +describe("runCodexIsolatedCompletion", () => { + beforeEach(() => { + mocks.resolveAuthHandoff.mockReset(); + mocks.runBoundedTurn.mockReset(); + mocks.resolveAuthHandoff.mockResolvedValue({ + authProfileId: "openai:test", + nativeAuthProfile: true, + }); + mocks.runBoundedTurn.mockResolvedValue({ + text: "Garden Planning", + model: "gpt-5.4", + usage: { input: 7, output: 3, cacheRead: 2, total: 10 }, + items: [ + { + id: "prompt", + type: "userMessage", + content: [{ type: "text", text: "Help me plan a garden." }], + }, + { id: "reasoning", type: "reasoning" }, + { id: "answer", type: "agentMessage", text: "Garden Planning" }, + ], + }); + }); + + it("uses native authorization on a ring-zero configured-transport turn", async () => { + const params = createParams(); + + await expect(runCodexIsolatedCompletion(params, {})).resolves.toEqual({ + assistant: expect.objectContaining({ + role: "assistant", + api: "openai-chatgpt-responses", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "Garden Planning" }], + usage: expect.objectContaining({ + input: 7, + output: 3, + cacheRead: 2, + totalTokens: 10, + }), + }), + }); + expect(mocks.resolveAuthHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + authRequirement: "subscription", + authProfileId: "openai:test", + authProfileStore, + agentDir: "/tmp/agent", + }), + ); + expect(mocks.runBoundedTurn).toHaveBeenCalledWith( + expect.objectContaining({ + model: { mode: "required", id: "gpt-5.4" }, + profile: "openai:test", + authRequirement: "subscription", + isolation: "configured-transport", + requireNoExternalCapabilities: true, + developerInstructions: "Name the conversation.", + input: [{ type: "text", text: "Help me plan a garden.", text_elements: [] }], + }), + ); + expect(mocks.runBoundedTurn.mock.calls[0]?.[0]).not.toHaveProperty("modelProvider"); + }); + + it("forwards prepared profile auth without also selecting a profile", async () => { + const preparedAuth = { + kind: "profile", + profileId: "openai:test", + store: authProfileStore, + snapshot: { + loginParams: { type: "chatgptAuthTokens", accessToken: "test-access" }, + secretFreeCacheKey: "test-account", + }, + }; + mocks.resolveAuthHandoff.mockResolvedValue({ + authProfileId: "openai:test", + nativeAuthProfile: true, + preparedAuth, + }); + + await runCodexIsolatedCompletion(createParams(), {}); + + const boundedParams = mocks.runBoundedTurn.mock.calls[0]?.[0]; + expect(boundedParams).toMatchObject({ preparedAuth }); + expect(boundedParams).not.toHaveProperty("profile"); + }); + + it("rejects any native or tool item outside the passive response surface", async () => { + mocks.runBoundedTurn.mockResolvedValue({ + text: "Garden Planning", + model: "gpt-5.4", + items: [{ id: "tool", type: "commandExecution" }], + }); + + await expect(runCodexIsolatedCompletion(createParams(), {})).rejects.toThrow( + "Codex isolated completion returned unexpected native item: commandExecution", + ); + }); + + it("rejects host authorization at the native-only boundary", async () => { + const params = createParams(); + params.authorization = { + owner: "host", + model: { provider: "openai", id: "gpt-5.4", api: "openai-responses" }, + auth: { mode: "api-key", source: "test" }, + } as IsolatedParams["authorization"]; + + await expect(runCodexIsolatedCompletion(params, {})).rejects.toThrow("harness-owned"); + expect(mocks.runBoundedTurn).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/codex/src/app-server/isolated-completion.ts b/extensions/codex/src/app-server/isolated-completion.ts new file mode 100644 index 000000000000..b25eea38887b --- /dev/null +++ b/extensions/codex/src/app-server/isolated-completion.ts @@ -0,0 +1,97 @@ +import type { AgentHarnessV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { resolveCodexAppServerPreparedAuthHandoff } from "./auth-bridge.js"; +import { runBoundedCodexAppServerTurn, type CodexBoundedTurnOptions } from "./bounded-turn.js"; +import { readCodexPluginConfig, resolveCodexAppServerHomeScope } from "./config.js"; +import { createAttributedCodexAssistantMessage } from "./event-projector-assistant-message.js"; +import { isJsonObject, type CodexThreadItem } from "./protocol.js"; + +const ISOLATED_PASSIVE_ITEM_TYPES = new Set(["agentMessage", "reasoning"]); + +type CodexIsolatedCompletionParams = Parameters< + NonNullable +>[0]; +type AgentHarnessIsolatedCompletionResult = Awaited< + ReturnType> +>; + +function assertIsolatedCompletionItems(items: CodexThreadItem[], prompt: string): void { + let promptEchoSeen = false; + for (const item of items) { + if (ISOLATED_PASSIVE_ITEM_TYPES.has(item.type)) { + continue; + } + if (item.type === "userMessage" && !promptEchoSeen) { + const content = Array.isArray(item.content) ? item.content : []; + const input = content[0]; + if ( + content.length === 1 && + isJsonObject(input) && + input.type === "text" && + input.text === prompt + ) { + promptEchoSeen = true; + continue; + } + } + throw new Error(`Codex isolated completion returned unexpected native item: ${item.type}`); + } +} + +/** Runs prompt-only Codex inference on an ephemeral, ring-zero native thread. */ +export async function runCodexIsolatedCompletion( + params: CodexIsolatedCompletionParams, + options: CodexBoundedTurnOptions, +): Promise { + const authorization = params.authorization; + if (authorization.owner !== "harness") { + throw new Error("Codex native isolated completion requires harness-owned authorization."); + } + const pluginConfig = readCodexPluginConfig(options.pluginConfig); + const authRequirement = authorization.plan.modelRoute?.authRequirement; + const authHandoff = await resolveCodexAppServerPreparedAuthHandoff({ + authRequirement, + authProfileId: authorization.plan.forwardedAuthProfileId, + authProfileStore: authorization.authProfileStore, + agentDir: params.agentDir, + homeScope: resolveCodexAppServerHomeScope({ appServer: pluginConfig.appServer }), + config: params.config, + subscriptionProfileRequiredError: + "Prepared Codex subscription route requires a scoped native OAuth or token profile.", + subscriptionProfileUnusableError: `Prepared Codex auth profile "${authorization.plan.forwardedAuthProfileId}" is unusable.`, + }); + const authSelection = authHandoff.preparedAuth + ? { preparedAuth: authHandoff.preparedAuth } + : { profile: authHandoff.authProfileId }; + const result = await runBoundedCodexAppServerTurn({ + config: params.config, + model: { + mode: "required", + id: params.modelId, + }, + ...authSelection, + authRequirement, + timeoutMs: params.timeoutMs, + signal: params.abortSignal, + agentDir: params.agentDir, + authProfileStore: authorization.authProfileStore, + options, + taskLabel: "isolated completion", + developerInstructions: params.systemPrompt, + input: [{ type: "text", text: params.prompt, text_elements: [] }], + requiredModalities: ["text"], + isolation: "configured-transport", + requireNoExternalCapabilities: true, + }); + assertIsolatedCompletionItems(result.items, params.prompt); + return { + assistant: createAttributedCodexAssistantMessage( + { + api: "openai-chatgpt-responses", + provider: params.provider, + modelId: result.model, + }, + result.text, + { tokenUsage: result.usage, aborted: false, promptError: null }, + ), + }; +} diff --git a/extensions/codex/src/app-server/settled-turn-finalizer.ts b/extensions/codex/src/app-server/settled-turn-finalizer.ts index 07ec4aeda76c..b0b3ff64324d 100644 --- a/extensions/codex/src/app-server/settled-turn-finalizer.ts +++ b/extensions/codex/src/app-server/settled-turn-finalizer.ts @@ -39,6 +39,7 @@ export async function runCodexSettledTurnFinalization( const bounded = await runBoundedCodexAppServerTurn({ config: attempt.config, model: { mode: "required", id: attempt.modelId }, + modelProvider: "openai", profile: attempt.authProfileId, timeoutMs: attempt.runTimeoutOverrideMs ?? attempt.timeoutMs, signal: attempt.abortSignal, diff --git a/extensions/codex/src/web-search-provider.runtime.ts b/extensions/codex/src/web-search-provider.runtime.ts index 7051c4f7d966..6c2a1d5ff82c 100644 --- a/extensions/codex/src/web-search-provider.runtime.ts +++ b/extensions/codex/src/web-search-provider.runtime.ts @@ -26,6 +26,7 @@ export async function executeCodexWebSearchProviderTool( const result = await runBoundedCodexAppServerTurn({ config: ctx.config, model: { mode: "live-default" }, + modelProvider: "openai", timeoutMs: resolveSearchTimeoutSeconds(ctx.searchConfig as SearchConfigRecord) * 1_000, signal: executionContext?.signal, agentDir: ctx.agentDir, diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index de2015478790..7867d7b1724e 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -21,7 +21,7 @@ import { createCopilotTestHostCapabilities } from "./src/host-capability.test-su import type { CopilotClientPool, PoolKey } from "./src/runtime.js"; type AgentHarnessIsolatedCompletionParams = Parameters< - NonNullable + NonNullable >[0]; type CanonicalAttemptResult = Extract; @@ -121,25 +121,28 @@ const TEST_SESSION_CONFIG = { const ISOLATED_COMPLETION_PARAMS = { provider: "github-copilot", modelId: "gpt-4.1", - model: { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.githubcopilot.com", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 8_192, + authorization: { + owner: "host", + model: { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + auth: { + apiKey: "prepared-github-token", + profileId: "github:work", + source: "profile", + mode: "oauth", + }, + sourceAuthFingerprint: "prepared-owner-fingerprint", }, - auth: { - apiKey: "prepared-github-token", - profileId: "github:work", - source: "profile", - mode: "oauth", - }, - sourceAuthFingerprint: "prepared-owner-fingerprint", config: {}, agentId: "test", agentDir: "/tmp/agent", @@ -546,7 +549,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, streamParams: { maxTokens: 800, temperature: 0.2 }, }), @@ -621,6 +624,26 @@ describe("createCopilotAgentHarness", () => { expect(pool.release).toHaveBeenCalledWith(expect.objectContaining({ client })); }); + it("rejects harness-owned authorization before acquiring a client", async () => { + const pool = makePoolMock(); + const harness = createCopilotAgentHarness({ pool }); + + await expect( + harness.runIsolatedCompletionV2?.({ + ...ISOLATED_COMPLETION_PARAMS, + authorization: { + owner: "harness", + plan: { + providerForAuth: "github-copilot", + authProfileProviderForAuth: "github-copilot", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + }), + ).rejects.toThrow("requires host-prepared authorization"); + expect(pool.acquire).not.toHaveBeenCalled(); + }); + it("returns tool-shaped output for core to reject with its stable code", async () => { const session = { abort: vi.fn().mockResolvedValue(undefined), @@ -645,7 +668,7 @@ describe("createCopilotAgentHarness", () => { pool.acquire.mockResolvedValue({ client, key: TEST_POOL_KEY }); const harness = createCopilotAgentHarness({ pool }); - await expect(harness.runIsolatedCompletion?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "toolCall", id: "call-1", name: "shell", arguments: {} }], stopReason: "toolUse", @@ -662,7 +685,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, thinkLevel }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, thinkLevel }), ).rejects.toThrow(`does not support thinking level ${thinkLevel}`); expect(pool.acquire).not.toHaveBeenCalled(); }, @@ -686,7 +709,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, abortSignal: controller.signal, }), @@ -723,7 +746,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, abortSignal: controller.signal, }), @@ -744,7 +767,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), ).rejects.toThrow("timed out after 5ms"); deferred.resolve(lateHandle); await flushAsyncWork(); @@ -767,7 +790,7 @@ describe("createCopilotAgentHarness", () => { const harness = createCopilotAgentHarness({ pool }); await expect( - harness.runIsolatedCompletion?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), + harness.runIsolatedCompletionV2?.({ ...ISOLATED_COMPLETION_PARAMS, timeoutMs: 5 }), ).rejects.toThrow("timed out after 5ms"); deferred.resolve(lateSession); await flushAsyncWork(); @@ -797,7 +820,7 @@ describe("createCopilotAgentHarness", () => { pool.acquire.mockResolvedValue({ client, key: TEST_POOL_KEY }); const harness = createCopilotAgentHarness({ pool }); - await expect(harness.runIsolatedCompletion?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(ISOLATED_COMPLETION_PARAMS)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "text", text: "Done." }] }), }); expect(disconnect).toHaveBeenCalledOnce(); @@ -825,24 +848,28 @@ describe("createCopilotAgentHarness", () => { ...ISOLATED_COMPLETION_PARAMS, provider: "custom-openai", modelId: "prepared-model", - model: { - ...ISOLATED_COMPLETION_PARAMS.model, - id: "prepared-model", - name: "Prepared model", - provider: "custom-openai", - baseUrl: "https://inference.example/v1", - headers: { "x-tenant": "tenant-a" }, - }, - auth: { - apiKey: "prepared-byok-key", - profileId: "custom:work", - source: "profile", - mode: "api-key" as const, + authorization: { + owner: "host", + model: { + ...ISOLATED_COMPLETION_PARAMS.authorization.model, + id: "prepared-model", + name: "Prepared model", + provider: "custom-openai", + baseUrl: "https://inference.example/v1", + headers: { "x-tenant": "tenant-a" }, + }, + auth: { + apiKey: "prepared-byok-key", + profileId: "custom:work", + source: "profile", + mode: "api-key" as const, + }, + sourceAuthFingerprint: "prepared-owner-fingerprint", }, streamParams: { maxTokens: 321 }, } satisfies AgentHarnessIsolatedCompletionParams; - await expect(harness.runIsolatedCompletion?.(params)).resolves.toEqual({ + await expect(harness.runIsolatedCompletionV2?.(params)).resolves.toEqual({ assistant: expect.objectContaining({ content: [{ type: "text", text: "Done." }], model: "prepared-model", diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 3aa628911fa0..1cef2466c7ef 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -34,7 +34,7 @@ import type { PoolKey, } from "./src/runtime.js"; -type AgentHarnessIsolatedCompletion = NonNullable; +type AgentHarnessIsolatedCompletion = NonNullable; type AgentHarnessIsolatedCompletionParams = Parameters[0]; type AgentHarnessIsolatedCompletionResult = Awaited>; type CopilotSettledTurnFinalizationAttemptParams = Parameters< @@ -896,7 +896,7 @@ export function createCopilotAgentHarness( } } - async function runIsolatedCompletion( + async function runIsolatedCompletionV2( params: AgentHarnessIsolatedCompletionParams, ): Promise { const completionPromise = (async () => { @@ -974,7 +974,7 @@ export function createCopilotAgentHarness( runAttempt: (params) => runHarnessAttempt(params, "attempt"), - runIsolatedCompletion, + runIsolatedCompletionV2, finalizeSettledTurn: async ({ attempt }) => { const result = await runHarnessAttempt(attempt, "settled-tool-finalization"); diff --git a/extensions/copilot/src/isolated-completion.ts b/extensions/copilot/src/isolated-completion.ts index 2f73b14b3ae8..90533a1585f2 100644 --- a/extensions/copilot/src/isolated-completion.ts +++ b/extensions/copilot/src/isolated-completion.ts @@ -9,7 +9,7 @@ import type { CopilotClientPool, PooledClient } from "./runtime.js"; import { createCopilotIsolatedSessionRestrictions } from "./session-restrictions.js"; import { buildCopilotAssistantUsage } from "./usage-bridge.js"; -type AgentHarnessIsolatedCompletion = NonNullable; +type AgentHarnessIsolatedCompletion = NonNullable; type AgentHarnessIsolatedCompletionParams = Parameters[0]; type AgentHarnessIsolatedCompletionResult = Awaited>; @@ -36,14 +36,6 @@ function startBestEffortCleanup(cleanup: () => Promise): void { } } -function requirePreparedCredential(params: AgentHarnessIsolatedCompletionParams): string { - const apiKey = params.auth.apiKey?.trim(); - if (!apiKey) { - throw new Error("[copilot] isolated completion requires the prepared credential"); - } - return apiKey; -} - function resolveReasoningEffort( thinkLevel: AgentHarnessIsolatedCompletionParams["thinkLevel"], ): SessionConfig["reasoningEffort"] { @@ -175,25 +167,33 @@ export async function runCopilotIsolatedCompletion( deadlineMs: Date.now() + params.timeoutMs, timeoutMs: params.timeoutMs, }; - const apiKey = requirePreparedCredential(params); + if (params.authorization.owner !== "host") { + throw new Error("[copilot] isolated completion requires host-prepared authorization"); + } + const authorization = params.authorization; + const { auth, model } = authorization; + const apiKey = auth.apiKey?.trim(); + if (!apiKey) { + throw new Error("[copilot] isolated completion requires the prepared credential"); + } const resolvedProvider = resolveCopilotProvider({ model: { - api: params.model.api, - id: params.model.id, - provider: params.model.provider, - baseUrl: params.model.baseUrl, - headers: params.model.headers, - authHeader: params.model.authHeader, - contextTokens: params.model.contextTokens, - contextWindow: params.model.contextWindow, - maxTokens: params.streamParams?.maxTokens ?? params.model.maxTokens, + api: model.api, + id: model.id, + provider: model.provider, + baseUrl: model.baseUrl, + headers: model.headers, + authHeader: model.authHeader, + contextTokens: model.contextTokens, + contextWindow: model.contextWindow, + maxTokens: params.streamParams?.maxTokens ?? model.maxTokens, azureApiVersion: - typeof params.model.params?.azureApiVersion === "string" - ? params.model.params.azureApiVersion + typeof model.params?.azureApiVersion === "string" + ? model.params.azureApiVersion : undefined, }, resolvedApiKey: apiKey, - authProfileId: params.auth.profileId, + authProfileId: auth.profileId, }); // Sampling controls are best-effort completion hints. Native Copilot does // not expose equivalent SDK fields, while BYOK applies maxTokens above. @@ -209,8 +209,9 @@ export async function runCopilotIsolatedCompletion( const sessionProvider = byokProxy?.provider ?? resolvedProvider; const githubAuth = sessionProvider.mode === "github-copilot"; const copilotHome = resolve(params.agentDir, "copilot"); - const authProfileId = params.auth.profileId?.trim() || "prepared"; - const authProfileVersion = params.sourceAuthFingerprint?.trim() || tokenFingerprint(apiKey); + const authProfileId = auth.profileId?.trim() || "prepared"; + const authProfileVersion = + authorization.sourceAuthFingerprint?.trim() || tokenFingerprint(apiKey); let handle: PooledClient | undefined; let session: IsolatedSession | undefined; try { @@ -238,7 +239,7 @@ export async function runCopilotIsolatedCompletion( handle = acquiredHandle; const sessionConfig: SessionConfig = { ...createCopilotIsolatedSessionRestrictions(), - model: params.model.id, + model: model.id, ...(githubAuth ? { gitHubToken: apiKey } : {}), ...(sessionProvider.provider ? { provider: sessionProvider.provider } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), @@ -287,9 +288,9 @@ export async function runCopilotIsolatedCompletion( assistant: { role: "assistant", content, - api: params.model.api, - provider: params.model.provider, - model: event.data.model ?? params.model.id, + api: model.api, + provider: model.provider, + model: event.data.model ?? model.id, stopReason: event.data.toolRequests?.length ? "toolUse" : "stop", timestamp: Date.now(), usage: buildCopilotAssistantUsage({ fallbackOutputTokens: event.data.outputTokens }), diff --git a/extensions/discord/src/monitor/thread-title.generate.test.ts b/extensions/discord/src/monitor/thread-title.generate.test.ts index 728ede3a982b..2d8c53208813 100644 --- a/extensions/discord/src/monitor/thread-title.generate.test.ts +++ b/extensions/discord/src/monitor/thread-title.generate.test.ts @@ -1,31 +1,13 @@ // Discord tests cover thread title.generate plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - completeWithPreparedSimpleCompletionModel, - extractAssistantText, - prepareSimpleCompletionModelForAgent, -} from "openclaw/plugin-sdk/simple-completion-runtime"; +import { generateConversationLabel } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; -vi.mock("openclaw/plugin-sdk/simple-completion-runtime", { spy: true }); - -const completeWithPreparedSimpleCompletionModelMock = - vi.fn(); -const prepareSimpleCompletionModelForAgentMock = - vi.fn(); -const extractAssistantTextMock = vi.fn(); +vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", { spy: true }); +const generateConversationLabelMock = vi.fn(); let generateThreadTitle: typeof import("./thread-title.js").generateThreadTitle; -function firstCompletionArgs(): Parameters[0] { - const firstCall = completeWithPreparedSimpleCompletionModelMock.mock.calls.at(0); - if (!firstCall) { - throw new Error("expected completion call"); - } - return firstCall[0]; -} - function hasLoneSurrogate(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); @@ -35,9 +17,7 @@ function hasLoneSurrogate(value: string): boolean { return true; } index += 1; - continue; - } - if (code >= 0xdc00 && code <= 0xdfff) { + } else if (code >= 0xdc00 && code <= 0xdfff) { return true; } } @@ -50,58 +30,23 @@ beforeAll(async () => { beforeEach(() => { vi.restoreAllMocks(); - completeWithPreparedSimpleCompletionModelMock.mockReset(); - prepareSimpleCompletionModelForAgentMock.mockReset(); - extractAssistantTextMock.mockReset(); - - prepareSimpleCompletionModelForAgentMock.mockResolvedValue({ - selection: { - provider: "anthropic", - modelId: "claude-sonnet-4-6", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "anthropic", - id: "claude-sonnet-4-6", - maxTokens: 64_000, - }, - auth: { - apiKey: "sk-test", - source: "env:TEST_API_KEY", - mode: "api-key", - }, - } as Awaited>); - completeWithPreparedSimpleCompletionModelMock.mockResolvedValue( - {} as Awaited>, - ); - extractAssistantTextMock.mockReturnValue("Generated title"); - vi.mocked(prepareSimpleCompletionModelForAgent).mockImplementation((...args) => - prepareSimpleCompletionModelForAgentMock(...args), - ); - vi.mocked(completeWithPreparedSimpleCompletionModel).mockImplementation((...args) => - completeWithPreparedSimpleCompletionModelMock(...args), - ); - vi.mocked(extractAssistantText).mockImplementation((...args) => - extractAssistantTextMock(...args), + generateConversationLabelMock.mockReset(); + generateConversationLabelMock.mockResolvedValue("Generated title"); + vi.mocked(generateConversationLabel).mockImplementation((...args) => + generateConversationLabelMock(...args), ); }); describe("generateThreadTitle", () => { it.each([ [' "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], - ['\n\n "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], ["```markdown\nWeekly Release Summary\n```", "Weekly Release Summary"], ["**Scaling ArcherScore Development Roadmap**", "Scaling ArcherScore Development Roadmap"], ['"__Weekly Release Summary__"', "Weekly Release Summary"], ["*Plan* for *project*", "*Plan* for *project*"], - ["**Bold** vs **Strong**", "**Bold** vs **Strong**"], - ["_intro_ and _outro_", "_intro_ and _outro_"], - ["**Release *plan***", "Release *plan*"], ["***Release plan***", "Release plan"], - ["__Release _plan___", "Release _plan_"], ])("normalizes generated title %j", async (generated, expected) => { - extractAssistantTextMock.mockReturnValueOnce(generated); - + generateConversationLabelMock.mockResolvedValueOnce(generated); await expect( generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, @@ -111,139 +56,27 @@ describe("generateThreadTitle", () => { ).resolves.toBe(expected); }); - it("calls shared one-shot model prep with aws-sdk allowance", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - selection: { - provider: "openrouter", - modelId: "anthropic/claude-sonnet-4-5", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "openrouter", - id: "anthropic/claude-sonnet-4-5", - maxTokens: 64_000, - }, - auth: { - apiKey: "sk-openrouter", - source: "profile:work", - mode: "api-key", - }, - } as Awaited>); - const cfg = { - agents: { - defaults: { - model: "openrouter/anthropic/claude-sonnet-4-5@work", - }, - }, - } as OpenClawConfig; - + it("routes through the shared isolated label generator", async () => { await generateThreadTitle({ - cfg, - agentId: "main", - messageText: "Need a generated title.", - }); - - expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({ - cfg, - agentId: "main", - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("passes model override refs into shared model prep", async () => { - const cfg = EMPTY_DISCORD_TEST_CONFIG; - await generateThreadTitle({ - cfg, - agentId: "main", - modelRef: "openai/gpt-4.1-mini@local", - messageText: "Need a generated title.", - }); - - expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({ - cfg, - agentId: "main", - modelRef: "openai/gpt-4.1-mini@local", - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("returns null when shared model prep cannot resolve selection", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - error: "No model configured for agent main.", - } as Awaited>); - - const result = await generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", - messageText: "Need a thread title.", + modelRef: "openai/gpt-4.1-mini@local", + messageText: "Summarize deployment blockers and owner follow-ups.", + channelName: "release-status", + channelDescription: "Deploy updates and incident notes", }); - expect(result).toBeNull(); - expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled(); - }); - - it("returns null when shared completion prep fails", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValue({ - error: 'No API key resolved for provider "anthropic" (auth mode: api-key).', - selection: { - provider: "anthropic", - modelId: "claude-sonnet-4-6", - agentDir: "/tmp/openclaw-agent", - }, - } as Awaited>); - - const result = await generateThreadTitle({ + expect(generateConversationLabelMock).toHaveBeenCalledWith({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", - messageText: "Need a thread title.", - }); - - expect(result).toBeNull(); - expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled(); - }); - - it("builds contextual prompt and forwards completion options", async () => { - const now = 1_700_000_000_000; - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - let result: string | null; - try { - result = await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Summarize deployment blockers and owner follow-ups.", - channelName: "release-status", - channelDescription: "Deploy updates and incident notes", - }); - } finally { - dateNowSpy.mockRestore(); - } - - expect(result).toBe("Generated title"); - expect(completeWithPreparedSimpleCompletionModelMock).toHaveBeenCalledTimes(1); - const completionArgs = firstCompletionArgs(); - expect(completionArgs.context).toEqual({ - systemPrompt: + userMessage: + "Channel: release-status\n\nChannel description: Deploy updates and incident notes\n\nMessage:\nSummarize deployment blockers and owner follow-ups.", + prompt: "Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity.", - messages: [ - { - role: "user", - content: - "Channel: release-status\n\nChannel description: Deploy updates and incident notes\n\nMessage:\nSummarize deployment blockers and owner follow-ups.", - timestamp: now, - }, - ], + modelRef: "openai/gpt-4.1-mini@local", + timeoutMs: 60_000, + maxLength: 600, }); - expect(completionArgs.options).toEqual({ - maxTokens: 4_096, - signal: completionArgs.options?.signal, - }); - expect(completionArgs.options?.signal).toBeInstanceOf(AbortSignal); - expect(completionArgs.options).not.toHaveProperty("temperature"); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60_000); }); it("keeps truncated prompt fields on UTF-16 boundaries", async () => { @@ -255,54 +88,32 @@ describe("generateThreadTitle", () => { channelDescription: `${"d".repeat(319)}😀tail`, }); - const message = firstCompletionArgs().context.messages.at(0); - const content = typeof message?.content === "string" ? message.content : ""; - + const content = generateConversationLabelMock.mock.calls[0]?.[0]?.userMessage ?? ""; expect(hasLoneSurrogate(content)).toBe(false); expect(content).toContain(`${"m".repeat(599)}...`); expect(content).toContain(`${"n".repeat(119)}...`); expect(content).toContain(`${"d".repeat(319)}...`); }); - it("clamps completion budget to the selected model output cap", async () => { - prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ - selection: { - provider: "anthropic", - modelId: "claude-haiku-4-5", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "anthropic", - id: "claude-haiku-4-5", - maxTokens: 1_024, - }, - auth: { - apiKey: "sk-test", - source: "env:TEST_API_KEY", - mode: "api-key", - }, - } as Awaited>); - - await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Need a generated title.", - }); - - expect(firstCompletionArgs().options?.maxTokens).toBe(1_024); - }); - - it("returns null when completion throws", async () => { - completeWithPreparedSimpleCompletionModelMock.mockRejectedValueOnce( - new Error("network timeout"), - ); - - const result = await generateThreadTitle({ - cfg: EMPTY_DISCORD_TEST_CONFIG, - agentId: "main", - messageText: "Generate title.", - }); - - expect(result).toBeNull(); + it("returns null for empty input, empty output, or generation failure", async () => { + await expect( + generateThreadTitle({ cfg: EMPTY_DISCORD_TEST_CONFIG, agentId: "main", messageText: " " }), + ).resolves.toBeNull(); + generateConversationLabelMock.mockResolvedValueOnce(null); + await expect( + generateThreadTitle({ + cfg: EMPTY_DISCORD_TEST_CONFIG, + agentId: "main", + messageText: "Generate title.", + }), + ).resolves.toBeNull(); + generateConversationLabelMock.mockRejectedValueOnce(new Error("network timeout")); + await expect( + generateThreadTitle({ + cfg: EMPTY_DISCORD_TEST_CONFIG, + agentId: "main", + messageText: "Generate title.", + }), + ).resolves.toBeNull(); }); }); diff --git a/extensions/discord/src/monitor/thread-title.ts b/extensions/discord/src/monitor/thread-title.ts index 579993f5e7b8..a3b7146b4544 100644 --- a/extensions/discord/src/monitor/thread-title.ts +++ b/extensions/discord/src/monitor/thread-title.ts @@ -1,24 +1,13 @@ // Discord plugin module implements thread title behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { generateConversationLabel } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - completeWithPreparedSimpleCompletionModel, - extractAssistantText, - prepareSimpleCompletionModelForAgent, -} from "openclaw/plugin-sdk/simple-completion-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { withAbortTimeout } from "./timeouts.js"; const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 60_000; const MAX_THREAD_TITLE_SOURCE_CHARS = 600; const MAX_THREAD_TITLE_CHANNEL_NAME_CHARS = 120; const MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS = 320; -// Budget generous enough to cover reasoning-model thinking tokens plus the -// short text output. Lower values (e.g. 24) starve reasoning models of output -// capacity: the entire budget is consumed by the thinking block before any -// text is emitted, so extractAssistantText returns empty and the rename is -// silently skipped. -const DISCORD_THREAD_TITLE_MAX_TOKENS = 4_096; const DISCORD_THREAD_TITLE_SYSTEM_PROMPT = "Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity."; @@ -36,21 +25,6 @@ export async function generateThreadTitle(params: { return null; } - const prepared = await prepareSimpleCompletionModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - ...(params.modelRef ? { modelRef: params.modelRef } : {}), - useUtilityModel: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - if ("error" in prepared) { - const modelLabel = prepared.selection - ? `${prepared.selection.provider}/${prepared.selection.modelId}` - : "unknown"; - logVerbose(`thread-title: ${prepared.error} (agent=${params.agentId}, model=${modelLabel})`); - return null; - } - try { const userMessage = buildThreadTitleCompletionUserMessage({ sourceText, @@ -58,52 +32,22 @@ export async function generateThreadTitle(params: { channelDescription: params.channelDescription, }); const timeoutMs = resolveThreadTitleTimeoutMs(params.timeoutMs); - const response = await completeThreadTitle({ - model: prepared.model, - auth: prepared.auth, + const generated = await generateConversationLabel({ + cfg: params.cfg, + agentId: params.agentId, userMessage, + prompt: DISCORD_THREAD_TITLE_SYSTEM_PROMPT, + ...(params.modelRef ? { modelRef: params.modelRef } : {}), timeoutMs, + maxLength: MAX_THREAD_TITLE_SOURCE_CHARS, }); - const generated = normalizeGeneratedThreadTitle(extractAssistantText(response)); - return generated || null; + return generated ? normalizeGeneratedThreadTitle(generated) : null; } catch (err) { logVerbose(`thread-title: title generation failed for agent ${params.agentId}: ${String(err)}`); return null; } } -async function completeThreadTitle(params: { - model: Parameters[0]["model"]; - auth: Parameters[0]["auth"]; - userMessage: string; - timeoutMs: number; -}) { - const maxTokens = Math.min(DISCORD_THREAD_TITLE_MAX_TOKENS, Math.floor(params.model.maxTokens)); - return await withAbortTimeout({ - timeoutMs: params.timeoutMs, - createTimeoutError: () => new Error(`thread-title timed out after ${params.timeoutMs}ms`), - run: async (signal) => - await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, - context: { - systemPrompt: DISCORD_THREAD_TITLE_SYSTEM_PROMPT, - messages: [ - { - role: "user", - content: params.userMessage, - timestamp: Date.now(), - }, - ], - }, - options: { - maxTokens, - signal, - }, - }), - }); -} - function buildThreadTitleCompletionUserMessage(params: { sourceText: string; channelName?: string; diff --git a/src/agents/harness/builtin-openclaw.test.ts b/src/agents/harness/builtin-openclaw.test.ts index 9e7214c6389a..a8b80c3cbb2a 100644 --- a/src/agents/harness/builtin-openclaw.test.ts +++ b/src/agents/harness/builtin-openclaw.test.ts @@ -98,8 +98,11 @@ describe("createOpenClawAgentHarness", () => { it("runs isolated completion through the prepared zero-tool transport", async () => { const params = { - model: { provider: "openai", id: "gpt-test", api: "openai-responses" }, - auth: { apiKey: "secret", source: "profile:test", mode: "api-key" }, + authorization: { + owner: "host", + model: { provider: "openai", id: "gpt-test", api: "openai-responses" }, + auth: { apiKey: "secret", source: "profile:test", mode: "api-key" }, + }, config: {}, systemPrompt: "system", prompt: "user", @@ -110,16 +113,16 @@ describe("createOpenClawAgentHarness", () => { agentDir: "/tmp/agent", workspaceDir: "/tmp/workspace", } as unknown as Parameters< - NonNullable["runIsolatedCompletion"]> + NonNullable["runIsolatedCompletionV2"]> >[0]; - await expect(createOpenClawAgentHarness().runIsolatedCompletion?.(params)).resolves.toEqual({ + await expect(createOpenClawAgentHarness().runIsolatedCompletionV2?.(params)).resolves.toEqual({ assistant: expect.objectContaining({ stopReason: "stop" }), }); expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledWith( expect.objectContaining({ - model: params.model, - auth: params.auth, + model: expect.objectContaining({ provider: "openai", id: "gpt-test" }), + auth: expect.objectContaining({ apiKey: "secret", mode: "api-key" }), context: { systemPrompt: "system", messages: [expect.objectContaining({ role: "user", content: "user" })], @@ -129,4 +132,33 @@ describe("createOpenClawAgentHarness", () => { ); expect(runEmbeddedAttempt).not.toHaveBeenCalled(); }); + + it("rejects harness-owned isolated authorization", async () => { + const params = { + authorization: { + owner: "harness", + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }, + authProfileStore: { version: 1, profiles: {} }, + }, + config: {}, + systemPrompt: "system", + prompt: "user", + timeoutMs: 1_000, + provider: "openai", + modelId: "gpt-test", + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + } satisfies Parameters< + NonNullable["runIsolatedCompletionV2"]> + >[0]; + + await expect(createOpenClawAgentHarness().runIsolatedCompletionV2?.(params)).rejects.toThrow( + "requires host-prepared authorization", + ); + expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/harness/builtin-openclaw.ts b/src/agents/harness/builtin-openclaw.ts index e0fc18589b8b..1256307e6739 100644 --- a/src/agents/harness/builtin-openclaw.ts +++ b/src/agents/harness/builtin-openclaw.ts @@ -85,14 +85,17 @@ export function createOpenClawAgentHarness(): AgentHarnessV2 { contextEngineHostCapabilities: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, supports: () => ({ supported: true, priority: 0 }), runAttempt: (params) => runEmbeddedAttempt(params as EmbeddedRunAttemptParams), - runIsolatedCompletion: async (params) => { + runIsolatedCompletionV2: async (params) => { + if (params.authorization.owner !== "host") { + throw new Error("The built-in OpenClaw harness requires host-prepared authorization."); + } const timeoutSignal = AbortSignal.timeout(params.timeoutMs); const signal = params.abortSignal ? AbortSignal.any([params.abortSignal, timeoutSignal]) : timeoutSignal; const assistant = await completeWithPreparedSimpleCompletionModel({ - model: params.model, - auth: params.auth, + model: params.authorization.model, + auth: params.authorization.auth, cfg: params.config, context: { systemPrompt: params.systemPrompt, diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 832a26a4e115..4f7816796176 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -134,6 +134,7 @@ export type AgentHarnessSettledTurnFinalizationResult = { assistantMessageIndex?: number; diagnosticTrace?: import("../../infra/diagnostic-trace-context.js").DiagnosticTraceContext; }; +/** @deprecated Use AgentHarnessIsolatedCompletionParamsV2. Remove after 2026-10-12. */ type AgentHarnessIsolatedCompletionParams = { /** Logical provider selected by the caller before harness dispatch. */ provider: string; @@ -159,7 +160,29 @@ type AgentHarnessIsolatedCompletionParams = { temperature?: number; }; }; -type AgentHarnessIsolatedCompletionResult = { +export type AgentHarnessIsolatedCompletionAuthorization = + | { + /** OpenClaw resolved the exact transport model and credential before handoff. */ + owner: "host"; + model: import("../../llm/types.js").Model; + auth: import("../model-auth-runtime-shared.js").ResolvedProviderAuth; + /** Non-reversible proof of the prepared credential owner when available. */ + sourceAuthFingerprint?: string; + } + | { + /** The selected harness owns credential resolution for this prepared route. */ + owner: "harness"; + plan: import("../runtime-plan/types.js").AgentRuntimeAuthPlan; + /** Credential snapshot restricted to the single profile selected for this call. */ + authProfileStore: import("../auth-profiles/types.js").AuthProfileStore; + }; +export type AgentHarnessIsolatedCompletionParamsV2 = Omit< + AgentHarnessIsolatedCompletionParams, + "model" | "auth" | "sourceAuthFingerprint" +> & { + authorization: AgentHarnessIsolatedCompletionAuthorization; +}; +export type AgentHarnessIsolatedCompletionResult = { /** The single assistant completion. Core rejects tool-shaped or failed results. */ assistant: import("../../llm/types.js").AssistantMessage; }; @@ -335,12 +358,16 @@ type AgentHarnessRunCapability< finalizeSettledTurn?( params: AgentHarnessSettledTurnFinalizationParams, ): Promise; + /** @deprecated Implement runIsolatedCompletionV2. Remove after 2026-10-12. */ + runIsolatedCompletion?( + params: AgentHarnessIsolatedCompletionParams, + ): Promise; /** * Runs one fresh prompt-only completion with a literal zero-tool model surface. * The harness must fail closed when it cannot enforce that native boundary. */ - runIsolatedCompletion?( - params: AgentHarnessIsolatedCompletionParams, + runIsolatedCompletionV2?( + params: AgentHarnessIsolatedCompletionParamsV2, ): Promise; }; diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index c2f60eec9a91..c2cffa3ae035 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -8,8 +8,11 @@ const mocks = vi.hoisted(() => ({ acquireAgentRunPreparedModelRuntime: vi.fn(), ensureSelectedAgentHarnessPlugin: vi.fn(async () => {}), getRegisteredAgentHarness: vi.fn(), + ensureAuthProfileStore: vi.fn(), isCliRuntimeAliasForProvider: vi.fn(() => false), prepareSimpleCompletionModel: vi.fn(), + prepareAgentRuntimeAuth: vi.fn(), + resolveModelWithRegistry: vi.fn(), resolveCliRuntimeCanonicalProvider: vi.fn(() => undefined), resolveCliBackendConfig: vi.fn< () => { config: { command: string; modelAliases?: Record } } | undefined @@ -32,6 +35,9 @@ vi.mock("./cli-backends.js", () => ({ vi.mock("./embedded-agent-runner/cli-backend-dispatch-eligibility.js", () => ({ resolveEmbeddedCliBackendDispatchEligibility: mocks.resolveEmbeddedCliBackendDispatchEligibility, })); +vi.mock("./embedded-agent-runner/model.js", () => ({ + resolveModelWithRegistry: mocks.resolveModelWithRegistry, +})); vi.mock("./harness/registry.js", () => ({ getRegisteredAgentHarness: mocks.getRegisteredAgentHarness, })); @@ -42,12 +48,33 @@ vi.mock("./model-runtime-aliases.js", () => ({ isCliRuntimeAliasForProvider: mocks.isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider: mocks.resolveCliRuntimeExecutionProvider, })); +vi.mock("./model-auth.js", () => ({ ensureAuthProfileStore: mocks.ensureAuthProfileStore })); vi.mock("./prepared-model-runtime.js", () => ({ acquireAgentRunPreparedModelRuntime: mocks.acquireAgentRunPreparedModelRuntime, })); vi.mock("./simple-completion-runtime.js", () => ({ prepareSimpleCompletionModel: mocks.prepareSimpleCompletionModel, })); +vi.mock("./runtime-plan/prepare-auth.js", async () => { + const actual = await vi.importActual( + "./runtime-plan/prepare-auth.js", + ); + return { ...actual, prepareAgentRuntimeAuth: mocks.prepareAgentRuntimeAuth }; +}); +vi.mock("./runtime-plan/resolve-auth.js", () => ({ + scopeAuthProfileStoreToPreparedPlan: ( + store: { version: number; profiles: Record }, + plan: { forwardedAuthProfileCandidateIds?: string[] }, + ) => ({ + ...store, + profiles: Object.fromEntries( + (plan.forwardedAuthProfileCandidateIds ?? []).flatMap((profileId) => { + const profile = store.profiles[profileId]; + return profile ? [[profileId, profile]] : []; + }), + ), + }), +})); vi.mock("./thinking-runtime.js", () => ({ resolveEffectiveAgentRuntime: mocks.resolveEffectiveAgentRuntime, })); @@ -100,7 +127,10 @@ function request() { beforeEach(() => { vi.clearAllMocks(); mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ - snapshot: { pluginRegistry: createEmptyPluginRegistry() }, + snapshot: { + pluginRegistry: createEmptyPluginRegistry(), + createStores: () => ({ modelRegistry: {} }), + }, release: vi.fn(), }); mocks.isCliRuntimeAliasForProvider.mockReturnValue(false); @@ -111,9 +141,369 @@ beforeEach(() => { auth: { apiKey: "secret", source: "profile:openai:test", mode: "oauth" }, sourceAuthFingerprint: "fingerprint", }); + mocks.resolveModelWithRegistry.mockReturnValue({ + provider: "openai", + id: "gpt-test", + api: "openai-chatgpt-responses", + }); + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + const plan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "subscription" }, + }; + mocks.prepareAgentRuntimeAuth.mockReturnValue({ + plan, + attempts: [{ kind: "implicit", plan }], + }); }); describe("runIsolatedCompletion", () => { + it("hands harness-owned authorization to the V2 owner without resolving a host key", async () => { + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "native result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "native result", + owner: { kind: "harness", id: "codex" }, + }); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: expect.objectContaining({ owner: "harness" }), + }), + ); + }); + + it("clamps V2 output tokens to the resolved physical model limit", async () => { + mocks.resolveModelWithRegistry.mockReturnValueOnce({ + provider: "openai", + id: "gpt-test", + api: "openai-chatgpt-responses", + maxTokens: 1_024, + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "native result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await runIsolatedCompletion({ + ...request(), + streamParams: { maxTokens: 4_096, temperature: 0.2 }, + }); + + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ streamParams: { maxTokens: 1_024, temperature: 0.2 } }), + ); + }); + + it("keeps automatic harness fallback core-owned and scopes one profile per call", async () => { + const firstPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const backupPlan = { + ...firstPlan, + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + "openai:backup": { type: "token", provider: "openai", token: "backup" }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: firstPlan, + attempts: [ + { kind: "profile", plan: firstPlan, profileId: "openai:first" }, + { kind: "profile", plan: backupPlan, profileId: "openai:backup" }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("first profile unavailable")) + .mockResolvedValueOnce({ + assistant: assistant([{ type: "text", text: "backup result" }]), + }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "backup result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledTimes(2); + expect( + runIsolatedCompletionV2.mock.calls.map(([params]) => ({ + profileId: + params.authorization.owner === "harness" + ? params.authorization.plan.forwardedAuthProfileId + : undefined, + candidateIds: + params.authorization.owner === "harness" + ? params.authorization.plan.forwardedAuthProfileCandidateIds + : undefined, + profiles: + params.authorization.owner === "harness" + ? Object.keys(params.authorization.authProfileStore.profiles) + : [], + })), + ).toEqual([ + { + profileId: "openai:first", + candidateIds: ["openai:first"], + profiles: ["openai:first"], + }, + { + profileId: "openai:backup", + candidateIds: ["openai:backup"], + profiles: ["openai:backup"], + }, + ]); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + }); + + it("does not unlock direct auth when a prepared profile becomes cooldown-blocked", async () => { + const profilePlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const directPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + }, + usageStats: { + "openai:first": { cooldownUntil: Date.now() + 60_000 }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: profilePlan, + attempts: [ + { kind: "profile", plan: profilePlan, profileId: "openai:first" }, + { + kind: "direct", + plan: directPlan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("profile unavailable")) + .mockResolvedValueOnce({ assistant: assistant([{ type: "text", text: "direct result" }]) }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).rejects.toThrow("temporarily unavailable"); + expect(runIsolatedCompletionV2).not.toHaveBeenCalled(); + expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); + }); + + it("skips a cooled profile without hiding a prepared healthy backup", async () => { + const firstPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const backupPlan = { + ...firstPlan, + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + "openai:backup": { type: "token", provider: "openai", token: "backup" }, + }, + usageStats: { + "openai:first": { cooldownUntil: Date.now() + 60_000 }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: firstPlan, + attempts: [ + { kind: "profile", plan: firstPlan, profileId: "openai:first" }, + { kind: "profile", plan: backupPlan, profileId: "openai:backup" }, + ], + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "backup result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "backup result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledOnce(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: expect.objectContaining({ + owner: "harness", + plan: expect.objectContaining({ forwardedAuthProfileId: "openai:backup" }), + }), + }), + ); + }); + + it("allows direct auth after a prepared profile was actually dispatched", async () => { + const profilePlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first"], + modelRoute: { authRequirement: "subscription" as const }, + }; + const directPlan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:first": { type: "token", provider: "openai", token: "first" }, + }, + }); + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan: profilePlan, + attempts: [ + { kind: "profile", plan: profilePlan, profileId: "openai:first" }, + { + kind: "direct", + plan: directPlan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ], + }); + const runIsolatedCompletionV2 = vi + .fn() + .mockRejectedValueOnce(new Error("profile unavailable")) + .mockResolvedValueOnce({ assistant: assistant([{ type: "text", text: "direct result" }]) }); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await expect(runIsolatedCompletion(request())).resolves.toMatchObject({ + text: "direct result", + }); + expect(runIsolatedCompletionV2).toHaveBeenCalledTimes(2); + expect(mocks.prepareSimpleCompletionModel).toHaveBeenCalledOnce(); + }); + + it("uses host authorization for V2 API-key routes", async () => { + const plan = { + providerForAuth: "openai", + modelId: "gpt-test", + harnessAuthProvider: "openai", + modelRoute: { authRequirement: "api-key" as const }, + }; + mocks.prepareAgentRuntimeAuth.mockReturnValueOnce({ + plan, + attempts: [{ kind: "implicit", plan }], + }); + const runIsolatedCompletionV2 = vi.fn(async () => ({ + assistant: assistant([{ type: "text", text: "key result" }]), + })); + mocks.getRegisteredAgentHarness.mockReturnValue({ + harness: { + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + runIsolatedCompletionV2, + } satisfies AgentHarness, + }); + + await runIsolatedCompletion(request()); + + expect(mocks.prepareSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletionV2).toHaveBeenCalledWith( + expect.objectContaining({ authorization: expect.objectContaining({ owner: "host" }) }), + ); + }); + it("passes one prepared route to the selected harness and returns text", async () => { const runIsolatedCompletionHarness = vi.fn(async () => ({ assistant: assistant([{ type: "text", text: '{"ok":true}' }]), diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 28f5bdece7fd..340f515d9fd5 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -18,9 +18,16 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; import { normalizeCliModel } from "./cli-runner/helpers.js"; import { resolveEmbeddedCliBackendDispatchEligibility } from "./embedded-agent-runner/cli-backend-dispatch-eligibility.js"; +import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; import { getRegisteredAgentHarness } from "./harness/registry.js"; import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js"; -import type { AgentHarness } from "./harness/types.js"; +import type { + AgentHarness, + AgentHarnessIsolatedCompletionAuthorization, + AgentHarnessIsolatedCompletionParamsV2, + AgentHarnessIsolatedCompletionResult, +} from "./harness/types.js"; +import { ensureAuthProfileStore } from "./model-auth.js"; import { isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider, @@ -30,6 +37,13 @@ import { unwrapModelHeaderSentinelsForProviderEgress, unwrapSecretSentinelsForProviderEgress, } from "./provider-secret-egress.js"; +import { + canRunPreparedAgentRuntimeAuthAttempt, + prepareAgentRuntimeAuth, + preparedAgentRuntimeProfileAttemptHasCandidate, + type PreparedAgentRuntimeAuthAttempt, +} from "./runtime-plan/prepare-auth.js"; +import { scopeAuthProfileStoreToPreparedPlan } from "./runtime-plan/resolve-auth.js"; import { prepareSimpleCompletionModel } from "./simple-completion-runtime.js"; import { resolveEffectiveAgentRuntime } from "./thinking-runtime.js"; import type { UsageLike } from "./usage.js"; @@ -41,6 +55,7 @@ type RunIsolatedCompletionParams = { /** Explicit credential owner. CLI and harness paths must not replace it with another profile. */ authProfileId?: string; agentId?: string; + agentDir?: string; workspaceDir?: string; /** Concrete owner already resolved by the caller, when available. */ agentHarnessRuntimeOverride?: string; @@ -84,6 +99,29 @@ type AgentHarnessIsolatedCompletionParams = Parameters< NonNullable >[0]; +function clampIsolatedStreamParams( + streamParams: RunIsolatedCompletionParams["streamParams"], + modelMaxTokens: number | undefined, +): RunIsolatedCompletionParams["streamParams"] { + if (streamParams?.maxTokens === undefined || modelMaxTokens === undefined) { + return streamParams; + } + return { ...streamParams, maxTokens: Math.min(streamParams.maxTokens, modelMaxTokens) }; +} + +function selectIsolatedHarnessAuthPlan(attempt: PreparedAgentRuntimeAuthAttempt) { + if (attempt.kind !== "profile") { + return attempt.plan; + } + return { + ...attempt.plan, + forwardedAuthProfileId: attempt.profileId, + // Core owns candidate order. A harness receives one selected credential + // snapshot per call so it cannot inspect or reorder fallback profiles. + forwardedAuthProfileCandidateIds: [attempt.profileId], + }; +} + function requireIsolatedAssistantText(assistant: AssistantMessage): string { if (assistant.stopReason !== "stop" && assistant.stopReason !== "length") { throw new IsolatedCompletionError( @@ -325,13 +363,71 @@ function prepareIsolatedHarnessParams( }; } +function prepareIsolatedHarnessParamsV2( + harness: AgentHarness, + params: AgentHarnessIsolatedCompletionParamsV2, +): AgentHarnessIsolatedCompletionParamsV2 { + if (harness.id === "openclaw" || params.authorization.owner === "harness") { + return params; + } + const boundary = "plugin harness isolated completion handoff"; + const apiKey = params.authorization.auth.apiKey + ? unwrapSecretSentinelsForProviderEgress(params.authorization.auth.apiKey, boundary) + : params.authorization.auth.apiKey; + const model = unwrapModelHeaderSentinelsForProviderEgress(params.authorization.model, boundary); + if (apiKey === params.authorization.auth.apiKey && model === params.authorization.model) { + return params; + } + return { + ...params, + authorization: { + ...params.authorization, + model, + auth: { ...params.authorization.auth, apiKey }, + }, + }; +} + +async function prepareHostAuthorization(params: { + config: OpenClawConfig; + agentId: string; + agentDir: string; + provider: string; + modelId: string; + authProfileId?: string; +}): Promise> { + const prepared = await prepareSimpleCompletionModel({ + cfg: params.config, + agentId: params.agentId, + provider: params.provider, + modelId: params.modelId, + agentDir: params.agentDir, + profileId: params.authProfileId, + allowMissingApiKeyModes: ["aws-sdk"], + allowBundledStaticCatalogFallback: true, + skipAgentDiscovery: true, + bindAuthOwner: true, + }); + if ("error" in prepared) { + throw new Error(`Isolated completion preparation failed: ${prepared.error}`); + } + return { + owner: "host", + model: prepared.model, + auth: prepared.auth, + ...(prepared.sourceAuthFingerprint + ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } + : {}), + }; +} + /** Run one fresh completion without any model-callable tool surface or fallback. */ export async function runIsolatedCompletion( request: RunIsolatedCompletionParams, ): Promise { const config = request.config ?? {}; const agentId = request.agentId ?? resolveDefaultAgentId(config); - const agentDir = resolveAgentDir(config, agentId); + const agentDir = request.agentDir ?? resolveAgentDir(config, agentId); const workspaceDir = request.workspaceDir ?? resolveAgentWorkspaceDir(config, agentId); const provider = resolveCliRuntimeCanonicalProvider({ @@ -398,35 +494,15 @@ export async function runIsolatedCompletion( } const harness = await resolveHarness(runtime); - if (!harness.runIsolatedCompletion) { + if (!harness.runIsolatedCompletionV2 && !harness.runIsolatedCompletion) { throw new IsolatedCompletionError( "unsupported", `Agent harness ${harness.id} does not support isolated completion.`, ); } - const prepared = await prepareSimpleCompletionModel({ - cfg: config, - agentId, + const commonParams = { provider, modelId: request.model, - agentDir, - profileId: request.authProfileId, - allowMissingApiKeyModes: ["aws-sdk"], - allowBundledStaticCatalogFallback: true, - skipAgentDiscovery: true, - bindAuthOwner: true, - }); - if ("error" in prepared) { - throw new Error(`Isolated completion preparation failed: ${prepared.error}`); - } - const harnessParams: AgentHarnessIsolatedCompletionParams = { - provider, - modelId: request.model, - model: prepared.model, - auth: prepared.auth, - ...(prepared.sourceAuthFingerprint - ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } - : {}), config, agentId, agentDir, @@ -436,11 +512,164 @@ export async function runIsolatedCompletion( timeoutMs: request.timeoutMs, abortSignal: request.abortSignal, thinkLevel: request.thinkLevel, - streamParams: request.streamParams, }; - const result = await harness.runIsolatedCompletion( - prepareIsolatedHarnessParams(harness, harnessParams), - ); + let result: AgentHarnessIsolatedCompletionResult | undefined; + if (harness.runIsolatedCompletionV2) { + let modelMaxTokens: number | undefined; + let authProfileStore: ReturnType | undefined; + let authAttempts: readonly PreparedAgentRuntimeAuthAttempt[] | undefined; + if (harness.authBootstrap === "harness") { + const { modelRegistry } = lease.snapshot.createStores(); + const runtimeModel = resolveModelWithRegistry({ + provider, + modelId: request.model, + modelRegistry, + cfg: config, + }); + if (!runtimeModel) { + throw new IsolatedCompletionError( + "runtime-unavailable", + `Unknown isolated completion model ${provider}/${request.model}.`, + ); + } + modelMaxTokens = runtimeModel.maxTokens; + authProfileStore = ensureAuthProfileStore(agentDir, { + readOnly: true, + allowKeychainPrompt: false, + config, + }); + authAttempts = prepareAgentRuntimeAuth({ + provider: runtimeModel.provider, + modelId: runtimeModel.id, + modelApi: runtimeModel.api, + modelBaseUrl: runtimeModel.baseUrl, + config, + env: process.env, + agentDir, + workspaceDir, + authProfileStore, + sessionAuthProfileId: request.authProfileId, + sessionAuthProfileSource: request.authProfileId ? "user" : undefined, + harnessId: harness.id, + harnessRuntime: harness.id, + harnessAuthBootstrap: harness.authBootstrap, + }).attempts; + } + let firstError: unknown; + let priorProfileAttempted = false; + for (const preparedAttempt of authAttempts?.length ? authAttempts : [undefined]) { + const attempt: PreparedAgentRuntimeAuthAttempt | undefined = + preparedAttempt?.kind === "profile" + ? { ...preparedAttempt, plan: selectIsolatedHarnessAuthPlan(preparedAttempt) } + : preparedAttempt; + if ( + attempt && + !canRunPreparedAgentRuntimeAuthAttempt({ attempt, priorProfileAttempted }) + ) { + firstError ??= new Error("Prepared direct auth requires a prior profile attempt."); + continue; + } + if ( + attempt?.kind === "profile" && + authProfileStore && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: authProfileStore, + modelId: request.model, + }) + ) { + firstError ??= new Error( + "Prepared runtime auth candidates are temporarily unavailable.", + ); + continue; + } + try { + let authorization: AgentHarnessIsolatedCompletionAuthorization; + if ( + attempt?.plan.harnessAuthProvider && + attempt.plan.modelRoute?.authRequirement !== "api-key" && + authProfileStore + ) { + const plan = attempt.plan; + authorization = { + owner: "harness", + plan, + authProfileStore: scopeAuthProfileStoreToPreparedPlan(authProfileStore, plan), + }; + } else { + authorization = await prepareHostAuthorization({ + config, + agentId, + agentDir, + provider, + modelId: request.model, + authProfileId: + attempt?.kind === "profile" ? attempt.profileId : request.authProfileId, + }); + modelMaxTokens = authorization.model.maxTokens; + } + if ( + attempt?.kind === "profile" && + authProfileStore && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: authProfileStore, + modelId: request.model, + }) + ) { + throw new Error("Prepared runtime auth candidates are temporarily unavailable."); + } + const pending = harness.runIsolatedCompletionV2( + prepareIsolatedHarnessParamsV2(harness, { + ...commonParams, + authorization, + streamParams: clampIsolatedStreamParams(request.streamParams, modelMaxTokens), + }), + ); + priorProfileAttempted ||= attempt?.kind === "profile"; + result = await pending; + break; + } catch (error) { + if (request.abortSignal?.aborted) { + throw error; + } + firstError ??= error; + } + } + if (!result) { + if (firstError instanceof Error) { + throw firstError; + } + throw new Error("No prepared auth attempt succeeded.", { cause: firstError }); + } + } else { + const authorization = await prepareHostAuthorization({ + config, + agentId, + agentDir, + provider, + modelId: request.model, + authProfileId: request.authProfileId, + }); + const harnessParams: AgentHarnessIsolatedCompletionParams = { + ...commonParams, + streamParams: clampIsolatedStreamParams( + request.streamParams, + authorization.model.maxTokens, + ), + model: authorization.model, + auth: authorization.auth, + ...(authorization.sourceAuthFingerprint + ? { sourceAuthFingerprint: authorization.sourceAuthFingerprint } + : {}), + }; + result = await harness.runIsolatedCompletion!( + prepareIsolatedHarnessParams(harness, harnessParams), + ); + } + if (!result) { + throw new IsolatedCompletionError("runtime-unavailable", "Isolated completion failed."); + } return { text: requireIsolatedAssistantText(result.assistant), provider: result.assistant.provider, diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index f23628091396..24f585e77c34 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -1,330 +1,140 @@ /** Tests generated conversation labels for reply sessions. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn()); -const logVerbose = vi.hoisted(() => vi.fn()); -const prepareSimpleCompletionModelForAgent = vi.hoisted(() => vi.fn()); +const runIsolatedCompletion = vi.hoisted(() => vi.fn()); const resolveSimpleCompletionSelectionForAgent = vi.hoisted(() => vi.fn()); +vi.mock("../../agents/isolated-completion.js", () => ({ runIsolatedCompletion })); vi.mock("../../agents/simple-completion-runtime.js", () => ({ - completeWithPreparedSimpleCompletionModel, - prepareSimpleCompletionModelForAgent, resolveSimpleCompletionSelectionForAgent, })); -vi.mock("../../globals.js", () => ({ logVerbose })); - import { generateConversationLabel, generateConversationLabelWithFallback, } from "./conversation-label-generator.js"; -function firstCompletionArgs() { - const call = completeWithPreparedSimpleCompletionModel.mock.calls.at(0); - if (!call) { - throw new Error("expected simple completion call"); - } - return call[0]; +function resolveSelection({ modelRef, useUtilityModel, agentDir }: Record) { + const ref = + typeof modelRef === "string" + ? modelRef + : useUtilityModel + ? "openai/gpt-mini@work" + : "openai/gpt-main@work"; + const [rawModel, profileId] = ref.split("@"); + const model = rawModel ?? ""; + const slash = model.indexOf("/"); + return { + provider: model.slice(0, slash), + modelId: model.slice(slash + 1), + profileId, + agentDir: typeof agentDir === "string" ? agentDir : "/tmp/openclaw-agent", + }; } describe("generateConversationLabel", () => { beforeEach(() => { - completeWithPreparedSimpleCompletionModel.mockReset(); - logVerbose.mockReset(); - prepareSimpleCompletionModelForAgent.mockReset(); - - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-test", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-test", maxTokens: 8192 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "Topic label" }], - }); + runIsolatedCompletion.mockReset(); + resolveSimpleCompletionSelectionForAgent.mockReset(); + resolveSimpleCompletionSelectionForAgent.mockImplementation(resolveSelection); + runIsolatedCompletion.mockResolvedValue({ text: "Topic label" }); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("prepares the configured utility model in the routed agent directory", async () => { - const cfg = { agents: { defaults: { utilityModel: "openai/gpt-test" } } }; - - await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "prompt", - cfg, - agentId: "billing", - agentDir: "/tmp/agents/billing/agent", - }); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({ - cfg, - agentId: "billing", - agentDir: "/tmp/agents/billing/agent", - useUtilityModel: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("passes the label prompt and a reasoning-safe bounded completion budget", async () => { - vi.useFakeTimers(); - vi.setSystemTime(1_710_000_000_000); - const cfg = {}; - - await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg, - }); - - expect(firstCompletionArgs()).toMatchObject({ - model: { provider: "openai", id: "gpt-test" }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - cfg, - context: { - systemPrompt: "Generate a label", - messages: [ - { - role: "user", - content: "Need help with invoices", - timestamp: 1_710_000_000_000, - }, - ], - }, - options: { - maxTokens: 4_096, - temperature: 0.3, - }, - }); - expect(firstCompletionArgs().options.signal).toBeInstanceOf(AbortSignal); - }); - - it("caps the completion budget at the model output limit", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-test", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-test", maxTokens: 1_024 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - - await generateConversationLabel({ - userMessage: "test topic creation", - prompt: "Generate a label", - cfg: {}, - }); - - expect(firstCompletionArgs().options.maxTokens).toBe(1_024); - }); - - it("omits temperature for Codex Responses simple completions", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "gpt-5.5", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: "openai", - id: "gpt-5.5", - api: "openai-chatgpt-responses", - maxTokens: 8192, - }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - - await generateConversationLabel({ - userMessage: "test topic creation", - prompt: "Generate a label", - cfg: {}, - }); - - expect(firstCompletionArgs().options).not.toHaveProperty("temperature"); - }); - - it("returns null when utility model preparation fails", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - error: 'No API key resolved for provider "openai".', - }); + it("routes the utility model through isolated completion with the selected auth owner", async () => { + const cfg = { agents: { defaults: { utilityModel: "openai/gpt-mini" } } }; await expect( generateConversationLabel({ userMessage: "Need help with invoices", prompt: "Generate a label", - cfg: {}, - }), - ).resolves.toBeNull(); - - expect(logVerbose).toHaveBeenCalledWith( - 'conversation-label-generator: No API key resolved for provider "openai".', - ); - expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); - }); - - it("falls back to the primary model when utility model preparation fails", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ - error: 'No API key resolved for provider "openai".', - selection: { - provider: "openai", - modelId: "gpt-5.6-luna", - agentDir: "/tmp/openclaw-agent", - }, - }) - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-sol", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "api-key" }, - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, + cfg, + agentId: "billing", + agentDir: "/tmp/agents/billing/agent", }), ).resolves.toBe("Topic label"); - expect(prepareSimpleCompletionModelForAgent).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ useUtilityModel: false }), - ); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledWith({ + config: cfg, + provider: "openai", + model: "gpt-mini", + authProfileId: "work", + agentId: "billing", + agentDir: "/tmp/agents/billing/agent", + systemPrompt: "Generate a label", + prompt: "Need help with invoices", + timeoutMs: 15_000, + streamParams: { maxTokens: 4_096 }, + }); }); - it("falls back to the primary model when the utility completion fails", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-luna", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-luna", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "oauth" }, - }) - .mockResolvedValueOnce({ - selection: { - provider: "openai", - modelId: "gpt-5.6-sol", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 }, - auth: { apiKey: "test-api-key", mode: "oauth" }, - }); - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce({ - content: [], - stopReason: "error", - errorMessage: "utility unavailable", - }) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Primary title" }] }); + it("uses one explicit model and timeout when supplied", async () => { + await generateConversationLabel({ + userMessage: "Message", + prompt: "Prompt", + cfg: {}, + modelRef: "anthropic/claude-haiku@team", + timeoutMs: 900, + }); + + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + model: "claude-haiku", + authProfileId: "team", + timeoutMs: 900, + }), + ); + }); + + it("falls back to the primary after a utility failure", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Primary title" }); await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }), + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), ).resolves.toBe("Primary title"); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2); + expect(runIsolatedCompletion).toHaveBeenCalledTimes(2); + expect(runIsolatedCompletion.mock.calls[1]?.[0]?.model).toBe("gpt-main"); }); - it("does not call the same primary model twice when utility routing resolves to it", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [], - stopReason: "error", - errorMessage: "primary unavailable", - }); + it("throws a sanitized error after every configured attempt fails", async () => { + runIsolatedCompletion.mockRejectedValue(new Error("secret-bearing provider failure")); await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }), + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), + ).rejects.toThrow("conversation label generation failed (utility, primary fallback)"); + }); + + it("deduplicates utility and primary when they resolve to the same owner", async () => { + resolveSimpleCompletionSelectionForAgent.mockReturnValue({ + provider: "openai", + modelId: "same-model", + profileId: "work", + agentDir: "/tmp/openclaw-agent", + }); + runIsolatedCompletion.mockResolvedValue({ text: "" }); + + await expect( + generateConversationLabel({ userMessage: "Message", prompt: "Prompt", cfg: {} }), ).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); }); - it("logs completion errors instead of treating them as empty labels", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [], - stopReason: "error", - errorMessage: "Codex error: Instructions are required", - }); - - const label = await generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - }); - - expect(label).toBeNull(); - expect(logVerbose).toHaveBeenCalledWith( - "conversation-label-generator: completion failed: Codex error: Instructions are required", - ); - }); - - it("bounds the generated label length", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "A very long generated topic label" }], - }); + it("bounds labels without splitting surrogate pairs", async () => { + runIsolatedCompletion.mockResolvedValue({ text: `${"a".repeat(11)}😀tail` }); await expect( generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - maxLength: 12, - }), - ).resolves.toBe("A very long "); - }); - - it("drops a split emoji instead of returning a lone surrogate", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: `${"a".repeat(11)}😀tail` }], - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", + userMessage: "Message", + prompt: "Prompt", cfg: {}, maxLength: 12, }), ).resolves.toBe("a".repeat(11)); }); - - it("returns null when the length cap cannot retain the first emoji", async () => { - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "😀 label" }], - }); - - await expect( - generateConversationLabel({ - userMessage: "Need help with invoices", - prompt: "Generate a label", - cfg: {}, - maxLength: 1, - }), - ).resolves.toBeNull(); - }); }); describe("generateConversationLabelWithFallback", () => { @@ -339,292 +149,78 @@ describe("generateConversationLabelWithFallback", () => { }; beforeEach(() => { - completeWithPreparedSimpleCompletionModel.mockReset(); - logVerbose.mockReset(); - prepareSimpleCompletionModelForAgent.mockReset(); + runIsolatedCompletion.mockReset(); resolveSimpleCompletionSelectionForAgent.mockReset(); - resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => { - const [model, profileId] = modelRef.split("@"); - const slash = model.indexOf("/"); - return { - provider: model.slice(0, slash), - modelId: model.slice(slash + 1), - profileId, - agentDir: "/tmp/openclaw-agent", - }; - }); - prepareSimpleCompletionModelForAgent.mockImplementation(async ({ modelRef }) => { - const [model] = modelRef.split("@"); - const slash = model.indexOf("/"); - return { - selection: { - provider: model.slice(0, slash), - modelId: model.slice(slash + 1), - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { - provider: model.slice(0, slash), - id: model.slice(slash + 1), - maxTokens: 8192, - }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }; - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ - content: [{ type: "text", text: "Utility title" }], - }); + resolveSimpleCompletionSelectionForAgent.mockImplementation(resolveSelection); + runIsolatedCompletion.mockResolvedValue({ text: "Utility title" }); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("uses the utility candidate once with the selected auth owner", async () => { + it("uses the utility candidate once", async () => { await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({ - cfg: {}, - agentId: "billing", - agentDir: undefined, - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], + expect(runIsolatedCompletion).toHaveBeenCalledOnce(); + expect(runIsolatedCompletion.mock.calls[0]?.[0]).toMatchObject({ + provider: "openai", + model: "gpt-mini", + authProfileId: "work", }); }); it("locks an inherited profile onto a same-provider utility ref", async () => { - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "openai/gpt-mini", - }), - ).resolves.toBe("Utility title"); + await generateConversationLabelWithFallback({ ...params, utilityModelRef: "openai/gpt-mini" }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({ - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", + expect(resolveSimpleCompletionSelectionForAgent).toHaveBeenCalledWith( + expect.objectContaining({ modelRef: "openai/gpt-mini@work" }), ); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.authProfileId).toBe("work"); }); - it("does not force the regular profile onto a cross-provider utility model", async () => { - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toEqual({ - cfg: {}, - agentId: "billing", - agentDir: undefined, - modelRef: "anthropic/claude-haiku-4-5", - bindAuthOwner: true, - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - }); - - it("does not inherit profiles across logical providers sharing one runtime", async () => { - resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => ({ - provider: modelRef.startsWith("anthropic/") ? "anthropic" : "openai", - runtimeProvider: "openai", - modelId: modelRef.split("/").slice(1).join("/"), - agentDir: "/tmp/openclaw-agent", - })); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe( - "anthropic/claude-haiku-4-5", - ); - }); - - it("falls back when utility preparation fails", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValueOnce({ error: "missing auth" }); - completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce({ - content: [{ type: "text", text: "Regular title" }], + it("does not inherit a profile across providers", async () => { + await generateConversationLabelWithFallback({ + ...params, + utilityModelRef: "anthropic/claude-haiku", }); - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(prepareSimpleCompletionModelForAgent.mock.calls[1]?.[0]?.modelRef).toBe( - "openai/gpt-main@work", - ); + expect(runIsolatedCompletion.mock.calls[0]?.[0]).toMatchObject({ + provider: "anthropic", + model: "claude-haiku", + }); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.authProfileId).toBeUndefined(); }); - it.each([ - { - name: "error stop reason", - first: { content: [], stopReason: "error", errorMessage: "utility failed" }, - }, - { name: "empty output", first: { content: [] } }, - ])("falls back after utility $name", async ({ first }) => { - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce(first) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2); - }); - - it("falls back when utility output fails operation-specific normalization", async () => { - completeWithPreparedSimpleCompletionModel - .mockResolvedValueOnce({ content: [{ type: "text", text: "Title:" }] }) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); + it("records an exhausted failure after fallback normalization rejects the result", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Title:" }); await expect( generateConversationLabelWithFallback({ ...params, normalizeLabel: (label) => (label === "Title:" ? null : label), }), - ).resolves.toBe("Regular title"); + ).rejects.toThrow("conversation label generation failed (utility)"); + expect(runIsolatedCompletion).toHaveBeenCalledTimes(2); }); - it("falls back after a utility completion exception", async () => { - completeWithPreparedSimpleCompletionModel - .mockRejectedValueOnce(new Error("transport failed")) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title"); - }); - - it("falls back after the utility attempt times out", async () => { - vi.useFakeTimers(); - completeWithPreparedSimpleCompletionModel - .mockImplementationOnce( - ({ options }) => - new Promise((_resolve, reject) => { - options.signal.addEventListener("abort", () => reject(new Error("aborted"))); - }), - ) - .mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] }); - - const generated = generateConversationLabelWithFallback(params); - await vi.advanceTimersByTimeAsync(15_000); - - await expect(generated).resolves.toBe("Regular title"); - }); - - it("returns null when both explicit candidates fail", async () => { - prepareSimpleCompletionModelForAgent - .mockResolvedValueOnce({ error: "utility auth failed" }) - .mockResolvedValueOnce({ error: "regular auth failed" }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); - }); - - it("skips a regular candidate that resolves to the same model and profile", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue({ - provider: "openai", - modelId: "same-model", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); - }); - - it("deduplicates candidates after asynchronous preparation resolves them identically", async () => { - prepareSimpleCompletionModelForAgent.mockResolvedValue({ - selection: { - provider: "openai", - modelId: "resolved-same-model", - profileId: "work", - agentDir: "/tmp/openclaw-agent", - }, - model: { provider: "openai", id: "resolved-same-model", maxTokens: 8192 }, - auth: { apiKey: "resolved-key", mode: "api-key" }, - }); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2); - expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce(); - }); - - it("inherits the regular profile for unresolved same-provider utility refs", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); + it("keeps an explicit runtime owner across utility and primary attempts", async () => { + runIsolatedCompletion + .mockRejectedValueOnce(new Error("utility unavailable")) + .mockResolvedValueOnce({ text: "Primary title" }); await expect( generateConversationLabelWithFallback({ ...params, - utilityModelRef: "openai/gpt-mini", + agentHarnessRuntimeOverride: "codex", }), - ).resolves.toBe("Utility title"); + ).resolves.toBe("Primary title"); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({ - modelRef: "openai/gpt-mini@work", - bindAuthOwner: true, - }); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); + expect( + runIsolatedCompletion.mock.calls.map(([request]) => request.agentHarnessRuntimeOverride), + ).toEqual(["codex", "codex"]); }); - it("does not inherit the regular profile for unresolved cross-provider utility refs", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: "anthropic/claude-haiku-4-5", - }), - ).resolves.toBe("Utility title"); - - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty( - "preferredProfile", - ); - }); - - it("deduplicates identical raw refs when selection resolution is unavailable", async () => { - resolveSimpleCompletionSelectionForAgent.mockReturnValue(null); - completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] }); - - await expect( - generateConversationLabelWithFallback({ - ...params, - utilityModelRef: params.regularModelRef, - }), - ).resolves.toBeNull(); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - }); - - it("uses the regular candidate directly when no utility model is available", async () => { + it("uses the regular candidate directly when no utility model exists", async () => { const { utilityModelRef: _utilityModelRef, ...regularOnlyParams } = params; - - await expect(generateConversationLabelWithFallback(regularOnlyParams)).resolves.toBe( - "Utility title", - ); - - expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce(); - expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe( - "openai/gpt-main@work", - ); + await generateConversationLabelWithFallback(regularOnlyParams); + expect(runIsolatedCompletion.mock.calls[0]?.[0]?.model).toBe("gpt-main"); }); }); diff --git a/src/auto-reply/reply/conversation-label-generator.ts b/src/auto-reply/reply/conversation-label-generator.ts index 59d428539b0c..a4e2eb4718f7 100644 --- a/src/auto-reply/reply/conversation-label-generator.ts +++ b/src/auto-reply/reply/conversation-label-generator.ts @@ -1,31 +1,21 @@ // Generates short labels for sessions from conversation context. import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { runIsolatedCompletion } from "../../agents/isolated-completion.js"; import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; -import { - completeWithPreparedSimpleCompletionModel, - prepareSimpleCompletionModelForAgent, - resolveSimpleCompletionSelectionForAgent, -} from "../../agents/simple-completion-runtime.js"; +import { resolveSimpleCompletionSelectionForAgent } from "../../agents/simple-completion-runtime.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { logVerbose } from "../../globals.js"; -import type { TextContent } from "../../llm/types.js"; const DEFAULT_MAX_LABEL_LENGTH = 128; // Reasoning models spend output tokens before emitting the short visible label. -// A tiny cap can leave no text, so keep the bounded title budget large enough -// for reasoning while respecting models with a lower output limit. const CONVERSATION_LABEL_MAX_TOKENS = 4_096; const TIMEOUT_MS = 15_000; -type PreparedLabelModel = Awaited>; -type ReadyLabelModel = Extract; type LabelModelPhase = "utility" | "primary fallback"; type ConversationLabelAttempt = { modelRef?: string; useUtilityModel?: boolean; preferredProfile?: string; - bindAuthOwner?: boolean; }; /** Inputs for generating a short conversation label from the configured utility model. */ @@ -35,6 +25,9 @@ export type ConversationLabelParams = { cfg: OpenClawConfig; agentId?: string; agentDir?: string; + agentHarnessRuntimeOverride?: string; + modelRef?: string; + timeoutMs?: number; maxLength?: number; }; @@ -45,84 +38,16 @@ type ConversationLabelFallbackParams = ConversationLabelParams & { normalizeLabel?: (label: string) => string | null; }; -function isTextContentBlock(block: { type: string }): block is TextContent { - return block.type === "text"; -} - -function isCodexSimpleCompletionModel(model: { api?: string; provider?: string }): boolean { - return model.api === "openai-chatgpt-responses"; -} - -function extractSimpleCompletionError(result: { - stopReason?: string; - errorMessage?: string; -}): string | null { - if (result.stopReason !== "error") { - return null; - } - return result.errorMessage?.trim() || "unknown error"; -} - function resolveMaxLabelLength(value: number | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_MAX_LABEL_LENGTH; } -function logLabelFailure(phase: LabelModelPhase, message: string): void { - const prefix = phase === "utility" ? "" : `${phase} `; - logVerbose(`conversation-label-generator: ${prefix}${message}`); -} - -async function prepareLabelModel(params: { - cfg: OpenClawConfig; - agentId: string; - agentDir?: string; - attempt: ConversationLabelAttempt; - phase: LabelModelPhase; -}): Promise { - try { - const prepared = await prepareSimpleCompletionModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - agentDir: params.agentDir, - ...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}), - ...(params.attempt.useUtilityModel !== undefined - ? { useUtilityModel: params.attempt.useUtilityModel } - : {}), - ...(params.attempt.preferredProfile - ? { preferredProfile: params.attempt.preferredProfile } - : {}), - ...(params.attempt.bindAuthOwner !== undefined - ? { bindAuthOwner: params.attempt.bindAuthOwner } - : {}), - useAsyncModelResolution: true, - allowMissingApiKeyModes: ["aws-sdk"], - }); - if ("error" in prepared) { - logLabelFailure(params.phase, prepared.error); - } - return prepared; - } catch (err) { - logLabelFailure(params.phase, `model preparation failed: ${String(err)}`); - return null; - } -} - -function selectedLabelModelsMatch( - first: PreparedLabelModel | null, - second: PreparedLabelModel | null, -): boolean { - const firstSelection = first && "selection" in first ? first.selection : undefined; - const secondSelection = second && "selection" in second ? second.selection : undefined; - return Boolean( - firstSelection && - secondSelection && - firstSelection.provider === secondSelection.provider && - firstSelection.runtimeProvider === secondSelection.runtimeProvider && - firstSelection.modelId === secondSelection.modelId && - firstSelection.profileId === secondSelection.profileId, - ); +function resolveTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : TIMEOUT_MS; } function resolveAttemptSelection(params: { @@ -170,111 +95,96 @@ function resolveAttemptKey(params: { } async function completeLabel(params: { - prepared: ReadyLabelModel; cfg: OpenClawConfig; + agentId: string; + agentDir?: string; + agentHarnessRuntimeOverride?: string; + attempt: ConversationLabelAttempt; userMessage: string; prompt: string; + timeoutMs: number; maxLength: number; - phase: LabelModelPhase; }): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - try { - const maxTokens = Math.min( - CONVERSATION_LABEL_MAX_TOKENS, - Math.floor(params.prepared.model.maxTokens), - ); - // Label generation should never block normal reply handling for long. - const result = await completeWithPreparedSimpleCompletionModel({ - model: params.prepared.model, - auth: params.prepared.auth, - cfg: params.cfg, - context: { - systemPrompt: params.prompt, - messages: [ - { - role: "user", - content: params.userMessage, - timestamp: Date.now(), - }, - ], - }, - options: { - maxTokens, - ...(isCodexSimpleCompletionModel(params.prepared.model) ? {} : { temperature: 0.3 }), - signal: controller.signal, - }, - }); - const errorMessage = extractSimpleCompletionError(result); - if (errorMessage) { - logLabelFailure(params.phase, `completion failed: ${errorMessage}`); - return null; - } - - const text = result.content - .filter(isTextContentBlock) - .map((block) => block.text) - .join("") - .trim(); - return text ? truncateUtf16Safe(text, params.maxLength) || null : null; - } catch (err) { - logLabelFailure(params.phase, `completion failed: ${String(err)}`); - return null; - } finally { - clearTimeout(timeout); + const selection = resolveAttemptSelection(params); + if (!selection) { + throw new Error("conversation label model selection unavailable"); } + const completion = await runIsolatedCompletion({ + config: params.cfg, + provider: selection.runtimeProvider ?? selection.provider, + model: selection.modelId, + authProfileId: selection.profileId ?? params.attempt.preferredProfile, + agentId: params.agentId, + agentDir: params.agentDir ?? selection.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + systemPrompt: params.prompt, + prompt: params.userMessage, + timeoutMs: params.timeoutMs, + streamParams: { maxTokens: CONVERSATION_LABEL_MAX_TOKENS }, + }); + return truncateUtf16Safe(completion.text.trim(), params.maxLength) || null; } -/** Generates a bounded human-readable label for a session, or null on failure. */ +async function runLabelAttempts(params: { + cfg: OpenClawConfig; + agentId: string; + agentDir?: string; + agentHarnessRuntimeOverride?: string; + attempts: readonly ConversationLabelAttempt[]; + userMessage: string; + prompt: string; + timeoutMs: number; + maxLength: number; + normalizeLabel?: (label: string) => string | null; +}): Promise { + const seen = new Set(); + const failures: LabelModelPhase[] = []; + for (const [index, attempt] of params.attempts.entries()) { + const key = resolveAttemptKey({ ...params, attempt }); + if (seen.has(key)) { + continue; + } + seen.add(key); + try { + const label = await completeLabel({ ...params, attempt }); + const normalized = label && params.normalizeLabel ? params.normalizeLabel(label) : label; + if (normalized) { + return normalized; + } + } catch { + failures.push(index === params.attempts.length - 1 ? "primary fallback" : "utility"); + } + } + if (failures.length > 0) { + // Keep provider errors and credentials out of logs while still recording the + // owned operation that failed after every configured route was exhausted. + throw new Error(`conversation label generation failed (${failures.join(", ")})`); + } + return null; +} + +/** Generates a bounded human-readable label for a session, or null for empty output. */ export async function generateConversationLabel( params: ConversationLabelParams, ): Promise { - const { userMessage, prompt, cfg, agentId, agentDir } = params; - const maxLength = resolveMaxLabelLength(params.maxLength); - const resolvedAgentId = agentId ?? resolveDefaultAgentId(cfg); - const utilityPrepared = await prepareLabelModel({ - cfg, - agentId: resolvedAgentId, - agentDir, - attempt: { useUtilityModel: true }, - phase: "utility", - }); - const utilityCompletionAttempted = Boolean(utilityPrepared && !("error" in utilityPrepared)); - if (utilityPrepared && !("error" in utilityPrepared)) { - const label = await completeLabel({ - prepared: utilityPrepared, - cfg, - userMessage, - prompt, - maxLength, - phase: "utility", - }); - if (label) { - return label; - } - } - - const primaryPrepared = await prepareLabelModel({ - cfg, - agentId: resolvedAgentId, - agentDir, - attempt: { useUtilityModel: false }, - phase: "primary fallback", - }); - if ( - !primaryPrepared || - "error" in primaryPrepared || - (utilityCompletionAttempted && selectedLabelModelsMatch(utilityPrepared, primaryPrepared)) - ) { - return null; - } - return await completeLabel({ - prepared: primaryPrepared, - cfg, - userMessage, - prompt, - maxLength, - phase: "primary fallback", + const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg); + const attempts: ConversationLabelAttempt[] = params.modelRef + ? [{ modelRef: params.modelRef }] + : [{ useUtilityModel: true }, { useUtilityModel: false }]; + return await runLabelAttempts({ + cfg: params.cfg, + agentId, + agentDir: params.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + attempts, + userMessage: params.userMessage, + prompt: params.prompt, + timeoutMs: resolveTimeoutMs(params.timeoutMs), + maxLength: resolveMaxLabelLength(params.maxLength), }); } @@ -286,12 +196,11 @@ export async function generateConversationLabelWithFallback( const regularAttempt: ConversationLabelAttempt = { modelRef: params.regularModelRef, ...(params.preferredProfile ? { preferredProfile: params.preferredProfile } : {}), - bindAuthOwner: true, }; const utilityRef = params.utilityModelRef?.trim(); let utilityAttempt: ConversationLabelAttempt | undefined; if (utilityRef) { - const candidate: ConversationLabelAttempt = { modelRef: utilityRef, bindAuthOwner: true }; + const candidate: ConversationLabelAttempt = { modelRef: utilityRef }; const utilitySelection = resolveAttemptSelection({ cfg: params.cfg, agentId, @@ -315,56 +224,21 @@ export async function generateConversationLabelWithFallback( utilityAuthProvider && utilityAuthProvider === regularAuthProvider; utilityAttempt = inheritsRegularProfile - ? { modelRef: `${utilityRef}@${params.preferredProfile}`, bindAuthOwner: true } + ? { modelRef: `${utilityRef}@${params.preferredProfile}` } : candidate; } - const attempts: ConversationLabelAttempt[] = [ - ...(utilityAttempt ? [utilityAttempt] : []), - regularAttempt, - ]; - const seen = new Set(); - const maxLength = resolveMaxLabelLength(params.maxLength); - let previousCompletedModel: PreparedLabelModel | null = null; - for (const attempt of attempts) { - const key = resolveAttemptKey({ - cfg: params.cfg, - agentId, - agentDir: params.agentDir, - attempt, - }); - if (seen.has(key)) { - continue; - } - seen.add(key); - const phase = attempt === regularAttempt ? "primary fallback" : "utility"; - const prepared = await prepareLabelModel({ - cfg: params.cfg, - agentId, - agentDir: params.agentDir, - attempt, - phase, - }); - if (!prepared || "error" in prepared) { - continue; - } - if (previousCompletedModel && selectedLabelModelsMatch(previousCompletedModel, prepared)) { - continue; - } - previousCompletedModel = prepared; - const label = await completeLabel({ - prepared, - cfg: params.cfg, - userMessage: params.userMessage, - prompt: params.prompt, - maxLength, - phase, - }); - if (label) { - const normalized = params.normalizeLabel ? params.normalizeLabel(label) : label; - if (normalized) { - return normalized; - } - } - } - return null; + return await runLabelAttempts({ + cfg: params.cfg, + agentId, + agentDir: params.agentDir, + ...(params.agentHarnessRuntimeOverride + ? { agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride } + : {}), + attempts: [...(utilityAttempt ? [utilityAttempt] : []), regularAttempt], + userMessage: params.userMessage, + prompt: params.prompt, + timeoutMs: resolveTimeoutMs(params.timeoutMs), + maxLength: resolveMaxLabelLength(params.maxLength), + normalizeLabel: params.normalizeLabel, + }); } diff --git a/src/gateway/dashboard-session-title.test.ts b/src/gateway/dashboard-session-title.test.ts index 025be9af0def..270e2accfd4a 100644 --- a/src/gateway/dashboard-session-title.test.ts +++ b/src/gateway/dashboard-session-title.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const generateConversationLabelWithFallback = vi.hoisted(() => vi.fn()); const resolveUtilityModelRefForAgent = vi.hoisted(() => vi.fn()); +const readSessionTitleFieldsFromTranscript = vi.hoisted(() => vi.fn()); const updateSessionEntry = vi.hoisted(() => vi.fn()); vi.mock("../agents/utility-model.js", () => ({ resolveUtilityModelRefForAgent })); @@ -10,6 +11,7 @@ vi.mock("../auto-reply/reply/conversation-label-generator.js", () => ({ generateConversationLabelWithFallback, })); vi.mock("../config/sessions/session-accessor.js", () => ({ updateSessionEntry })); +vi.mock("./session-transcript-title-reader.js", () => ({ readSessionTitleFieldsFromTranscript })); import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -51,6 +53,11 @@ describe("maybeGenerateDashboardSessionTitle", () => { generateConversationLabelWithFallback.mockReset(); resolveUtilityModelRefForAgent.mockReset(); updateSessionEntry.mockReset(); + readSessionTitleFieldsFromTranscript.mockReset(); + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: null, + lastMessagePreview: null, + }); generateConversationLabelWithFallback.mockResolvedValue("Release Planning"); resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna"); mockSessionUpdate(baseEntry); @@ -116,6 +123,38 @@ describe("maybeGenerateDashboardSessionTitle", () => { ); }); + it("preserves a locked session harness as the title runtime owner", async () => { + const entry = { + ...baseEntry, + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }; + mockSessionUpdate(entry); + + await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true); + + expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessRuntimeOverride: "codex" }), + ); + }); + + it("preserves a compatible session runtime override for title generation", async () => { + const entry = { + ...baseEntry, + providerOverride: "anthropic", + modelOverride: "claude-fable-5", + agentRuntimeOverride: "claude-cli", + }; + mockSessionUpdate(entry); + + await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true); + + expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessRuntimeOverride: "claude-cli" }), + ); + }); + it("preserves the configured primary auth profile for explicit utility models", async () => { const profiledCfg = { agents: { @@ -201,7 +240,6 @@ describe("maybeGenerateDashboardSessionTitle", () => { ["group subject", { entry: { ...baseEntry, subject: "Release team" } }], ["channel name", { entry: { ...baseEntry, groupChannel: "releases" } }], ["space name", { entry: { ...baseEntry, space: "Engineering" } }], - ["existing session history", { entry: { ...baseEntry, systemSent: true } }], ])("skips %s", async (_name, override) => { await expect( maybeGenerateDashboardSessionTitle({ ...titleParams(), ...override }), @@ -211,6 +249,58 @@ describe("maybeGenerateDashboardSessionTitle", () => { expect(updateSessionEntry).not.toHaveBeenCalled(); }); + it("retries a historical session from the transcript's first user message", async () => { + const entry = { ...baseEntry, systemSent: true }; + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: "[Mon 2026-08-10 12:00 UTC] Original release plan", + lastMessagePreview: "Latest follow-up", + }); + mockSessionUpdate(entry); + + await expect( + maybeGenerateDashboardSessionTitle({ + ...titleParams(entry), + currentUserMessage: "Latest follow-up", + userMessage: "Latest follow-up", + }), + ).resolves.toBe(true); + + expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( + "Original release plan", + ); + }); + + it("preserves attachment-aware input when the first turn is already in the transcript", async () => { + readSessionTitleFieldsFromTranscript.mockReturnValue({ + firstUserMessage: "[Mon 2026-08-10 12:00 UTC] Review this rollout", + lastMessagePreview: "Review this rollout", + }); + + await expect( + maybeGenerateDashboardSessionTitle({ + ...titleParams(), + currentUserMessage: "Review this rollout", + userMessage: "Review this rollout\nDeployment context", + }), + ).resolves.toBe(true); + + expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( + "Review this rollout\nDeployment context", + ); + }); + + it("evicts a failed request so later activity can retry", async () => { + generateConversationLabelWithFallback + .mockRejectedValueOnce(new Error("route unavailable")) + .mockResolvedValueOnce("Release Planning"); + + await expect(maybeGenerateDashboardSessionTitle(titleParams())).rejects.toThrow( + "route unavailable", + ); + await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true); + expect(generateConversationLabelWithFallback).toHaveBeenCalledTimes(2); + }); + it("does not overwrite a name added while the model request is running", async () => { mockSessionUpdate({ ...baseEntry, label: "Manual title" }); diff --git a/src/gateway/dashboard-session-title.ts b/src/gateway/dashboard-session-title.ts index d2e187c5df30..4311d2fe0a48 100644 --- a/src/gateway/dashboard-session-title.ts +++ b/src/gateway/dashboard-session-title.ts @@ -3,8 +3,10 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; +import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js"; import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js"; import { generateConversationLabelWithFallback } from "../auto-reply/reply/conversation-label-generator.js"; +import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import { updateSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -12,10 +14,18 @@ import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isValidAttachmentBase64, type ChatAttachment } from "./chat-attachments.js"; +import { readSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; type DashboardSessionTitleModelEntry = Pick< SessionEntry, - "authProfileOverride" | "model" | "modelOverride" | "modelProvider" | "providerOverride" + | "agentHarnessId" + | "agentRuntimeOverride" + | "authProfileOverride" + | "model" + | "modelOverride" + | "modelProvider" + | "modelSelectionLocked" + | "providerOverride" >; const DASHBOARD_SESSION_TITLE_MAX_CHARS = 60; @@ -23,11 +33,11 @@ const DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS = 1_000; const DASHBOARD_SESSION_TITLE_PROMPT = "Generate a concise session title (3-6 words, max 60 characters) from the user's first message. Use the same language as the message. No emoji. Return only the title."; -// One title request per first turn. Concurrent sends cannot race duplicate model +// One title request per session generation. Concurrent triggers cannot race duplicate model // calls or metadata writes; late callers receive the in-flight promise so they // may await the persisted title before proceeding. Stored promises always -// settle: the label generator aborts internally (TIMEOUT_MS), so a hung model -// call cannot pin an entry here and block future attempts. +// settle: isolated completion enforces a timeout, so a hung model call cannot +// pin an entry here and block future attempts. const sessionTitleRequests = new Map>(); function decodeTextAttachmentPrefix(attachment: ChatAttachment, maxChars: number): string | null { @@ -161,6 +171,11 @@ export async function generateDashboardSessionTitle(params: { return null; } const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId); + const agentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ + provider: regularModel.provider, + entry: params.entry, + cfg: params.cfg, + }); const preferredProfile = resolveDashboardTitleAuthProfile({ cfg: params.cfg, agentId: params.agentId, @@ -181,6 +196,7 @@ export async function generateDashboardSessionTitle(params: { prompt: DASHBOARD_SESSION_TITLE_PROMPT, cfg: params.cfg, agentId: params.agentId, + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), ...(utilityModelRef ? { utilityModelRef } : {}), regularModelRef, ...(preferredProfile ? { preferredProfile } : {}), @@ -197,6 +213,7 @@ export async function maybeGenerateDashboardSessionTitle(params: { sessionId: string; sessionKey: string; storePath: string; + currentUserMessage?: string; userMessage: string; }): Promise { const sourceText = params.userMessage.trim(); @@ -221,14 +238,10 @@ export async function maybeGenerateSessionTitle(params: { sessionId: string; sessionKey: string; storePath: string; + currentUserMessage?: string; userMessage: string; }): Promise { - const sourceText = params.userMessage.trim(); - if ( - hasExplicitSessionName(params.entry) || - params.entry?.systemSent === true || - params.entry?.sessionId !== params.sessionId - ) { + if (hasExplicitSessionName(params.entry) || params.entry?.sessionId !== params.sessionId) { return { kind: "skipped" }; } @@ -237,6 +250,32 @@ export async function maybeGenerateSessionTitle(params: { if (existing) { return { kind: "in-flight", settled: existing }; } + + // A retry may be triggered by a later send or by discussion open. Always + // title the session from its original user message when the transcript owns it. + const transcriptSource = readSessionTitleFieldsFromTranscript({ + agentId: params.agentId, + sessionEntry: params.entry, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }).firstUserMessage; + const transcriptText = transcriptSource + ? stripInlineDirectiveTagsForDisplay(stripInboundMetadata(transcriptSource)).text.trim() + : ""; + const currentText = params.currentUserMessage + ? stripInlineDirectiveTagsForDisplay(params.currentUserMessage).text.trim() + : ""; + // A first-turn transcript may win the persistence race before title work starts. + // When it is the current turn, retain the supplied attachment-enriched source. + const sourceText = + !transcriptText || (currentText && currentText === transcriptText) + ? params.userMessage.trim() + : transcriptText; + if (!sourceText) { + return { kind: "skipped" }; + } + const request = getOrCreatePromise( sessionTitleRequests, requestKey, diff --git a/src/gateway/server-methods/chat-send-background.ts b/src/gateway/server-methods/chat-send-background.ts index 13071fcecfed..305c4ba20c31 100644 --- a/src/gateway/server-methods/chat-send-background.ts +++ b/src/gateway/server-methods/chat-send-background.ts @@ -71,6 +71,7 @@ export function scheduleChatDashboardSessionTitle(params: { sessionId: titleSessionId, sessionKey: params.sessionKey, storePath: params.storePath, + currentUserMessage: params.request.rawMessage, userMessage: titleSource, }); if (updated) { diff --git a/src/gateway/server-methods/session-discussion.test.ts b/src/gateway/server-methods/session-discussion.test.ts index 693d32724486..cd670dc1eea5 100644 --- a/src/gateway/server-methods/session-discussion.test.ts +++ b/src/gateway/server-methods/session-discussion.test.ts @@ -174,7 +174,7 @@ describe("session discussion gateway methods", () => { sessionId: "session-1", sessionKey, storePath, - userMessage: "Plan the release", + userMessage: "", }), ); expect(persistedEntry?.displayName).toBe("Release Planning"); @@ -187,6 +187,26 @@ describe("session discussion gateway methods", () => { ); }); + it("attempts a title when system prompt state already exists", async () => { + const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1, systemSent: true }; + mockSession(entry); + mocks.readSessionTitleFields.mockReturnValue({ + firstUserMessage: "Plan the release", + lastMessagePreview: null, + }); + mocks.updateSessionEntry.mockImplementation(async (_scope, update) => { + const patch = await update({ ...entry }); + return patch ? { ...entry, ...patch } : entry; + }); + const registered = provider(); + mocks.getProvider.mockReturnValue(registered.value); + + await invoke("session.discussion.open", { sessionKey }); + + expect(mocks.maybeGenerateSessionTitle).toHaveBeenCalledOnce(); + expect(mocks.generateConversationLabelWithFallback).toHaveBeenCalledOnce(); + }); + it("titles via the canonical session key when opened through an alias key", async () => { const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1 }; mocks.loadSessionTarget.mockReturnValue({ diff --git a/src/gateway/server-methods/session-discussion.ts b/src/gateway/server-methods/session-discussion.ts index 8460db7de82a..15936ddc6237 100644 --- a/src/gateway/server-methods/session-discussion.ts +++ b/src/gateway/server-methods/session-discussion.ts @@ -7,10 +7,9 @@ import { validateSessionDiscussionOpenParams, validateSessionDiscussionOpenResult, } from "../../../packages/gateway-protocol/src/index.js"; -import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; import { hasExplicitSessionName, maybeGenerateSessionTitle } from "../dashboard-session-title.js"; -import { readSessionTitleFieldsFromTranscript } from "../session-transcript-title-reader.js"; +import { formatForLog } from "../ws-log.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { loadAccessorSessionEntryForGatewayTarget } from "./sessions-shared.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -30,20 +29,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { }); const { entry } = resolved; const sessionId = entry?.sessionId; - if (!entry || !sessionId || entry.systemSent === true || hasExplicitSessionName(entry)) { - return; - } - const fields = readSessionTitleFieldsFromTranscript({ - agentId: resolved.target.agentId, - sessionEntry: entry, - sessionId, - sessionKey: resolved.canonicalKey, - storePath: resolved.storePath, - }); - const userMessage = fields.firstUserMessage - ? stripInboundMetadata(fields.firstUserMessage).trim() - : ""; - if (!userMessage) { + if (!entry || !sessionId || hasExplicitSessionName(entry)) { return; } @@ -59,7 +45,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { // the open request addresses the session through an alias key. sessionKey: resolved.canonicalKey, storePath: resolved.storePath, - userMessage, + userMessage: "", }).then(async (attempt) => { if (attempt.kind === "in-flight") { await attempt.settled.catch(() => {}); @@ -67,6 +53,12 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { } return attempt.kind === "persisted"; }); + const observedTitleRequest = titleRequest.catch((error: unknown) => { + params.context.logGateway.warn( + `dashboard session title generation failed: ${formatForLog(error)}`, + ); + return false; + }); let timeout: NodeJS.Timeout | undefined; let persisted = false; // Discussion open waits at most 10 seconds for best-effort titling. @@ -74,7 +66,7 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { // picks up any title that completes after the timeout. try { persisted = await Promise.race([ - titleRequest.catch(() => false), + observedTitleRequest, new Promise((resolve) => { timeout = setTimeout(() => resolve(false), DISCUSSION_TITLE_TIMEOUT_MS); timeout.unref?.(); @@ -94,8 +86,11 @@ async function maybeGenerateTitleBeforeDiscussionOpen(params: { reason: "chat.title", }); } - } catch { + } catch (error) { // Titling is best-effort; provider open remains the authoritative operation. + params.context.logGateway.warn( + `dashboard session title generation failed: ${formatForLog(error)}`, + ); } } diff --git a/src/plugin-sdk/agent-harness-runtime.test.ts b/src/plugin-sdk/agent-harness-runtime.test.ts index aa44e224aa9e..a6840e2aa05f 100644 --- a/src/plugin-sdk/agent-harness-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-runtime.test.ts @@ -210,6 +210,15 @@ describe("agent harness runtime SDK facade", () => { NonNullable["runtimePolicy"] >().toEqualTypeOf(); }); + + it("exports the V2 isolated-completion authorization contract through the harness", () => { + type IsolatedCompletionV2 = NonNullable; + + expectTypeOf[0]["authorization"]["owner"]>().toEqualTypeOf< + "host" | "harness" + >(); + expectTypeOf>["assistant"]>().not.toBeNever(); + }); }); describe("agent harness user input helpers", () => { From fc45a0f1e682e02758b427392bddc825219ae8ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:15:15 -0700 Subject: [PATCH 005/165] fix(update): show tracked status for detached deployments (#122415) * fix(update): track main from detached deployments * test(update): use canonical temp directory helper * test(update): keep detached tracking regression compact --- src/infra/update-channels.ts | 8 ++++++ src/infra/update-check.test.ts | 7 ++++- src/infra/update-check.ts | 20 +++++++++----- src/infra/update-runner-git-preflight.ts | 10 ++++--- src/infra/update-startup.test.ts | 33 +++++++++++++++++++++++- src/infra/update-startup.ts | 10 ++++--- 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index a40205a30624..83c4c344e33b 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -25,6 +25,14 @@ export const UPDATE_EFFECTIVE_CHANNEL_ENV = "OPENCLAW_UPDATE_EFFECTIVE_CHANNEL"; /** Git branch that represents the development update stream. */ export const DEV_BRANCH = "main"; +/** Resolves current tracking, or the configured Dev branch for detached HEAD. */ +export function resolveDevUpstreamRef(branch?: string | null, detached = false): string | null { + if (branch !== "HEAD") { + return "@{upstream}"; + } + return detached ? `${DEV_BRANCH}@{upstream}` : null; +} + /** Normalizes config or CLI channel input to a supported update channel. */ export function normalizeUpdateChannel(value?: string | null): UpdateChannel | null { const normalized = normalizeOptionalLowercaseString(value); diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 411664e94fd7..9d7e75df0d57 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -627,7 +627,7 @@ describe("formatGitInstallLabel", () => { }); describe("checkUpdateStatus", () => { - it("uses a matching receipt upstream only for the detached installed revision", async () => { + it("resolves detached dev tracking before matching update receipts", async () => { await withTestDir({ prefix: "openclaw-update-check-receipt-fallback-" }, async (base) => { const sourceRoot = path.join(base, "source"); const localRoot = path.join(base, "local"); @@ -650,9 +650,14 @@ describe("checkUpdateStatus", () => { includeRegistry: false, fetchGit: params.fetch ?? false, timeoutMs: 5000, + useDetachedDevUpstream: true, ...(params.fallback ? { gitUpstreamFallback: params.fallback } : {}), }); + expect((await readStatus()).git?.upstream).toBe("origin/main"); + await runGit(localRoot, "branch", "--unset-upstream", "main"); + expect((await readStatus()).git?.upstream).toBeNull(); + const current = await readStatus({ fetch: true, fallback }); expect(current.git).toMatchObject({ branch: "HEAD", diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index f9ebf69af8f1..4cc978ee55b1 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -10,7 +10,7 @@ import { } from "./detect-package-manager.js"; import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js"; import { compareValidSemver, normalizeLegacyDotBetaVersion } from "./semver.js"; -import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; +import { channelToNpmTag, resolveDevUpstreamRef, type UpdateChannel } from "./update-channels.js"; import { fetchNpmPackageTargetStatus, type NpmMetadataCommandRunner, @@ -229,6 +229,7 @@ async function checkGitUpdateStatus(params: { root: string; timeoutMs?: number; fetch?: boolean; + useDetachedDevUpstream?: boolean; upstreamFallback?: { currentSha: string; upstreamRef: string }; }): Promise { const timeoutMs = params.timeoutMs ?? 6000; @@ -248,7 +249,7 @@ async function checkGitUpdateStatus(params: { fetchOk: null, }; - const [branchRes, shaRes, commitAtRes, tagRes, upstreamRes, dirtyRes] = await Promise.all([ + const [branchRes, shaRes, commitAtRes, tagRes, dirtyRes] = await Promise.all([ runCommandWithTimeout(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], { timeoutMs, }).catch(() => null), @@ -261,9 +262,6 @@ async function checkGitUpdateStatus(params: { runCommandWithTimeout(["git", "-C", root, "describe", "--tags", "--exact-match"], { timeoutMs, }).catch(() => null), - runCommandWithTimeout(["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"], { - timeoutMs, - }).catch(() => null), runCommandWithTimeout( ["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"], { @@ -275,6 +273,13 @@ async function checkGitUpdateStatus(params: { return { ...base, error: branchRes?.stderr?.trim() || "git unavailable" }; } const branch = branchRes.stdout.trim() || null; + const trackingRevision = resolveDevUpstreamRef(branch, params.useDetachedDevUpstream); + const upstreamRes = trackingRevision + ? await runCommandWithTimeout( + ["git", "-C", root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", trackingRevision], + { timeoutMs }, + ).catch(() => null) + : null; const sha = shaRes && shaRes.code === 0 ? shaRes.stdout.trim() : null; const commitAtSeconds = @@ -311,8 +316,7 @@ async function checkGitUpdateStatus(params: { // Freeze the post-fetch upstream for both graph queries. Active tracking wins; // a matching successful update receipt keeps intentional detached installs comparable. - const upstreamRevision = - upstreamSource === "tracking" ? "@{upstream}^{commit}" : `${upstream}^{commit}`; + const upstreamRevision = `${upstreamSource === "tracking" ? trackingRevision : upstream}^{commit}`; const upstreamCommitRes = canCompareUpstream && upstream && sha ? await runCommandWithTimeout( @@ -608,6 +612,7 @@ export async function checkUpdateStatus(params: { root: string | null; timeoutMs?: number; fetchGit?: boolean; + useDetachedDevUpstream?: boolean; gitUpstreamFallback?: { currentSha: string; upstreamRef: string }; includeRegistry?: boolean; registryChannel?: UpdateChannel; @@ -658,6 +663,7 @@ export async function checkUpdateStatus(params: { root, timeoutMs, fetch: Boolean(params.fetchGit), + useDetachedDevUpstream: params.useDetachedDevUpstream, upstreamFallback: params.gitUpstreamFallback, }) : Promise.resolve(undefined), diff --git a/src/infra/update-runner-git-preflight.ts b/src/infra/update-runner-git-preflight.ts index 5c6bd0d2421a..02723a3cd9b1 100644 --- a/src/infra/update-runner-git-preflight.ts +++ b/src/infra/update-runner-git-preflight.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { trimLogTail } from "./restart-sentinel.js"; -import { DEV_BRANCH } from "./update-channels.js"; +import { DEV_BRANCH, resolveDevUpstreamRef } from "./update-channels.js"; import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js"; import { managerInstallArgs, @@ -210,9 +210,11 @@ async function resolveUpstreamCandidates(params: { ); } } - const upstreamRefs = params.needsCheckoutMain - ? [`${DEV_BRANCH}@{upstream}`, ...remoteBranchRefs] - : ["@{upstream}"]; + const trackingRevision = resolveDevUpstreamRef( + params.needsCheckoutMain ? "HEAD" : DEV_BRANCH, + true, + ); + const upstreamRefs = [...(trackingRevision ? [trackingRevision] : []), ...remoteBranchRefs]; let upstreamSha: string | null = null; let selectedDevUpstream: string | null = null; let sawResolvableUpstreamRef = false; diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 28faa198cfc7..00e54c5e473f 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -1097,6 +1097,7 @@ describe("update-startup", () => { timeoutMs: 2500, fetchGit: true, includeRegistry: false, + useDetachedDevUpstream: true, }); expect(resolveNpmChannelTag).not.toHaveBeenCalled(); expect(getUpdateAvailable()).toEqual({ @@ -1214,6 +1215,32 @@ describe("update-startup", () => { }); }); + it("continues managed dev campaigns from a detached tracked deployment", async () => { + mockDevGitStatus({ branch: "HEAD", upstreamSource: "tracking" }); + const runAutoUpdate = createAutoUpdateSuccessMock(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel: "dev", auto: { enabled: true } } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + activeWorkInspectors: idleActiveWorkInspectors(), + runAutoUpdate, + }); + + expect(getUpdateSchedule()?.campaign?.state).toBe("countdown"); + await vi.advanceTimersByTimeAsync(60_000); + expect(runAutoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + devTarget: { + mode: "tracked", + upstreamRef: "origin/main", + upstreamSha: "upstream-sha", + }, + }), + ); + }); + it.each([ { name: "successful install", status: "ok", reason: undefined }, { @@ -1256,6 +1283,7 @@ describe("update-startup", () => { timeoutMs: 2500, fetchGit: true, includeRegistry: false, + useDetachedDevUpstream: true, gitUpstreamFallback: { currentSha: "current-sha", upstreamRef: "origin/main" }, }); expect(getUpdateSchedule()?.campaign?.state).toBe("countdown"); @@ -1275,7 +1303,10 @@ describe("update-startup", () => { { name: "ahead", git: { ahead: 1, behind: 0 } }, { name: "diverged", git: { ahead: 1, behind: 2 } }, { name: "non-main", git: { branch: "feature" } }, - { name: "detached", git: { branch: "HEAD" } }, + { + name: "detached without tracking", + git: { branch: "HEAD", upstream: null, upstreamSha: null, ahead: null, behind: null }, + }, ])("does not announce an automatic dev campaign for a $name checkout", async ({ git }) => { mockDevGitStatus(git); const runAutoUpdate = createAutoUpdateSuccessMock(); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 6cf38fe5f0df..c49b0724c250 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -567,7 +567,7 @@ function clearAutoState(nextState: UpdateCheckState): void { delete nextState.autoFirstSeenAt; } -async function resolveStartupInstallStatus(fetchGit: boolean) { +async function resolveStartupInstallStatus(checkDevGit: boolean) { const [root, installReceipt] = await Promise.all([ resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, @@ -583,8 +583,9 @@ async function resolveStartupInstallStatus(fetchGit: boolean) { const status = await checkUpdateStatus({ root, timeoutMs: 2500, - fetchGit, + fetchGit: checkDevGit, includeRegistry: false, + ...(checkDevGit ? { useDetachedDevUpstream: true } : {}), ...(gitUpstreamFallback ? { gitUpstreamFallback } : {}), }); return { root, status, installReceipt }; @@ -1121,10 +1122,11 @@ export async function runGatewayUpdateCheck(params: { reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON, }); } - const hasTrackedMain = git.branch === DEV_BRANCH && git.upstreamSource === "tracking"; + const hasTrackedDevUpstream = + (git.branch === DEV_BRANCH || git.branch === "HEAD") && git.upstreamSource === "tracking"; const hasReceiptBackedDetachedHead = git.branch === "HEAD" && git.upstreamSource === "receipt"; const canRunTrackedDevCampaign = - (hasTrackedMain || hasReceiptBackedDetachedHead) && git.ahead === 0; + (hasTrackedDevUpstream || hasReceiptBackedDetachedHead) && git.ahead === 0; if (shouldRunAutoUpdate && canRunTrackedDevCampaign) { const lastAttemptAt = state.autoLastAttemptAt ? Date.parse(state.autoLastAttemptAt) : null; const recentAttempt = From 6b0be0215fb8f54bf55b0a36b85678b16d7b096b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:15:32 -0700 Subject: [PATCH 006/165] refactor(ui): split new-session page into draft owners (#122413) * refactor(ui): split new-session page into draft owners * refactor(ui): delete duplicated draft plumbing * chore(lint): ratchet max-lines baseline after new-session split --- config/max-lines-baseline.txt | 1 - ui/src/pages/new-session/cloud-target.ts | 2 +- .../pages/new-session/draft-gateway-state.ts | 537 ++++ .../new-session/draft-place-browser.test.ts | 111 + .../pages/new-session/draft-place-browser.ts | 596 ++++ ui/src/pages/new-session/draft-place-state.ts | 744 +++++ .../new-session/draft-submission-flow.test.ts | 205 ++ .../new-session/draft-submission-flow.ts | 690 +++++ .../new-session/new-session-page.test.ts | 265 +- ui/src/pages/new-session/new-session-page.ts | 2582 ++--------------- ui/src/pages/new-session/place-picker.test.ts | 3 +- ui/src/pages/new-session/place-picker.ts | 39 +- 12 files changed, 3245 insertions(+), 2530 deletions(-) create mode 100644 ui/src/pages/new-session/draft-gateway-state.ts create mode 100644 ui/src/pages/new-session/draft-place-browser.test.ts create mode 100644 ui/src/pages/new-session/draft-place-browser.ts create mode 100644 ui/src/pages/new-session/draft-place-state.ts create mode 100644 ui/src/pages/new-session/draft-submission-flow.test.ts create mode 100644 ui/src/pages/new-session/draft-submission-flow.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e4bf2bb2b02c..6e128a973367 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -921,7 +921,6 @@ ui/src/pages/chat/tool-stream.ts ui/src/pages/config/config-page.ts ui/src/pages/config/view.browser.test.ts ui/src/pages/cron/view.ts -ui/src/pages/new-session/new-session-page.ts ui/src/pages/plugins/plugins-page.ts ui/src/pages/plugins/view.ts ui/src/pages/sessions/sessions-page.ts diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index a4acfb852903..f00f32261541 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -50,7 +50,7 @@ export function renderSessionMenuItem(params: SessionMenuItemOptions, submitting } export function renderCloudProfileMenuItems(params: { - profiles: DraftCloudProfile[]; + profiles: readonly DraftCloudProfile[]; selectedId: string; submitting: boolean; icon?: unknown; diff --git a/ui/src/pages/new-session/draft-gateway-state.ts b/ui/src/pages/new-session/draft-gateway-state.ts new file mode 100644 index 000000000000..e4fcd67afbfc --- /dev/null +++ b/ui/src/pages/new-session/draft-gateway-state.ts @@ -0,0 +1,537 @@ +import { initialState, Task, TaskStatus } from "@lit/task"; +import type { ReactiveControllerHost } from "lit"; +import type { + UsersPrefsGetResult, + UsersPrefsSetResult, +} from "../../../../packages/gateway-protocol/src/index.js"; +import type { ApplicationContext } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; +import * as catalog from "./catalog-target.ts"; +import { + CLOUD_PROFILE_RETRY_DELAYS_MS, + discoverCloudProfiles, + selectProfiles, +} from "./cloud-profile-discovery.ts"; +import { + resolveScope, + resolveSubmissionOutcomeReason, + type SubmissionOutcomeReason, +} from "./cloud-recovery-state.ts"; +import type { DraftCloudProfile } from "./discovery.ts"; +import { discoverGatewayName } from "./gateway-name-discovery.ts"; +import type { NewSessionRouteData } from "./location.ts"; +import { + decodeIdentityPreferences, + encodeIdentityPreferences, + loadBrowserPreferences, + loadNewSessionPreference, + patchNewSessionPreference, + PREFS_MIGRATION_KEY, + replaceBrowserPreference, + type NewSessionPreference, +} from "./preferences.ts"; + +const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const; + +type DraftGatewaySnapshot = Readonly<{ + context: ApplicationContext | undefined; + data: NewSessionRouteData | undefined; + isConnected: boolean; + isAdmin: boolean; + canStartAsDraft: boolean; + visibility: "normal" | "draft" | "incognito"; + cloudProfileId: string; + pendingCloud: Readonly<{ + sessionKey: string; + gatewayUrl: string; + recoveryScope: string; + }>; + agentsHydrated: boolean; +}>; + +type DraftGatewayCallbacks = { + requestUpdate: () => void; + updateComplete: () => Promise; + onInvalidate: (resetHostSelection: boolean, outcome: SubmissionOutcomeReason) => void; + onVisibilityRetired: () => void; + onCloudProfileCleared: () => void; + onCloudState: (error: string | null) => void; + onPendingCloudReset: () => void; + onRecoveryReady: (gatewayUrl: string, recoveryScope: string) => void; + onAdoptAgentDefaults: () => void; +}; + +export class DraftGatewayState { + private gatewayNameValue = ""; + private cloudProfilesValue: DraftCloudProfile[] = []; + private cloudProfilesReadyValue = false; + private catalogRetryingValue = false; + private gatewaySource: ApplicationContext["gateway"] | null = null; + private gatewayClientValue: ApplicationContext["gateway"]["snapshot"]["client"] = null; + private gatewayUrlValue = ""; + private gatewayRecoveryScopeValue = ""; + private gatewayRecoveryScopeReady = false; + private gatewayConnectedValue = false; + private gatewayConnectionEpochValue = 0; + private catalogRetryScope = ""; + private catalogRetryAttempt = 0; + private catalogRetryTimer: ReturnType | undefined; + private cloudProfileRetryAttempt = 0; + private cloudProfileRetryTimer: ReturnType | undefined; + private preferenceScope = ""; + private preferenceModeValue: "local" | "loading" | "remote" = "local"; + private identityPreferences: Record = {}; + private preferenceLoad: Promise = Promise.resolve(); + private preferenceWrite: Promise = Promise.resolve(); + + private readonly gatewayNameTask: Task; + private readonly cloudProfileTask: Task; + + constructor( + host: ReactiveControllerHost, + private readonly read: () => DraftGatewaySnapshot, + private readonly callbacks: DraftGatewayCallbacks, + ) { + this.gatewayNameTask = new Task(host, { + args: () => + [ + this.read().isConnected && this.gatewayConnectedValue ? this.gatewayClientValue : null, + isGatewayMethodAdvertised(this.read().context?.gateway.snapshot ?? {}, "system.info") === + true, + this.gatewayConnectionEpochValue, + ] as const, + task: ([client, advertised, _connectionEpoch], { signal }) => + discoverGatewayName(client, advertised, signal), + onComplete: (name) => { + this.gatewayNameValue = name; + this.callbacks.requestUpdate(); + }, + }); + this.cloudProfileTask = new Task(host, { + args: () => + [ + this.read().isConnected && this.gatewayConnectedValue ? this.gatewayClientValue : null, + this.gatewayConnectionEpochValue, + this.read().isAdmin, + this.gatewayRecoveryScopeValue, + ] as const, + task: ([client, _connectionEpoch, admin]) => + client ? discoverCloudProfiles(client, admin) : initialState, + onComplete: (profiles) => { + this.resetCloudProfileRetry(); + this.applyCloudProfiles(profiles); + this.cloudProfilesReadyValue = true; + this.callbacks.requestUpdate(); + }, + onError: () => { + this.cloudProfilesValue = []; + this.cloudProfilesReadyValue = false; + this.scheduleCloudProfileRetry(); + this.callbacks.requestUpdate(); + }, + }); + } + + get gatewayName(): string { + return this.gatewayNameValue; + } + + get cloudProfiles(): readonly DraftCloudProfile[] { + return this.cloudProfilesValue; + } + + get cloudProfilesReady(): boolean { + return this.cloudProfilesReadyValue; + } + + get cloudProfilesPending(): boolean { + return this.cloudProfileTask.status === TaskStatus.PENDING; + } + + get catalogRetrying(): boolean { + return this.catalogRetryingValue; + } + + get client(): ApplicationContext["gateway"]["snapshot"]["client"] { + return this.gatewayClientValue; + } + + get gatewayUrl(): string { + return this.gatewayUrlValue; + } + + get recoveryScope(): string { + return this.gatewayRecoveryScopeValue; + } + + get connected(): boolean { + return this.gatewayConnectedValue; + } + + get connectionEpoch(): number { + return this.gatewayConnectionEpochValue; + } + + get preferenceLoading(): boolean { + return this.preferenceModeValue === "loading"; + } + + synchronize(gateway: ApplicationContext["gateway"]) { + const snapshot = gateway.snapshot; + const connected = snapshot.phase === "connected"; + const firstBind = this.gatewaySource === null; + const gatewayUrlChanged = !firstBind && this.gatewayUrlValue !== gateway.connection.gatewayUrl; + const identityChanged = + !firstBind && (this.gatewaySource !== gateway || this.gatewayClientValue !== snapshot.client); + const connectionChanged = !firstBind && this.gatewayConnectedValue !== connected; + const becameConnected = connected && (identityChanged || !this.gatewayConnectedValue); + const recoveryScopeBecameReady = + connected && snapshot.client?.recoveryScopeReady === true && !this.gatewayRecoveryScopeReady; + const recoveryScope = resolveScope( + { client: snapshot.client, connected }, + this.gatewayRecoveryScopeValue, + firstBind, + ); + this.gatewaySource = gateway; + this.gatewayClientValue = snapshot.client; + this.gatewayUrlValue = gateway.connection.gatewayUrl; + this.gatewayRecoveryScopeValue = recoveryScope.next; + this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true; + this.gatewayConnectedValue = connected; + if (this.read().visibility === "draft" && !this.read().canStartAsDraft) { + this.callbacks.onVisibilityRetired(); + } + if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { + const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed; + this.invalidateDiscovery( + gatewayIdentityChanged, + resolveSubmissionOutcomeReason({ + gatewayIdentityChanged, + cloudDraftOwned: Boolean(this.read().pendingCloud.sessionKey), + }), + ); + } + if ( + firstBind || + gatewayUrlChanged || + recoveryScope.changed || + recoveryScopeBecameReady || + becameConnected + ) { + const pending = this.read().pendingCloud; + if ( + pending.gatewayUrl && + (pending.gatewayUrl !== this.gatewayUrlValue || + pending.recoveryScope !== this.gatewayRecoveryScopeValue) + ) { + this.callbacks.onPendingCloudReset(); + } + if (connected && snapshot.client?.recoveryScopeReady) { + this.callbacks.onRecoveryReady(this.gatewayUrlValue, this.gatewayRecoveryScopeValue); + } + } + if (becameConnected) { + this.gatewayConnectionEpochValue += 1; + this.retryPendingCatalogTarget(); + } + this.synchronizeIdentityPreferences(snapshot.selfUser?.id); + this.callbacks.requestUpdate(); + } + + invalidateDiscovery(resetHostSelection: boolean, submissionOutcome: SubmissionOutcomeReason) { + this.gatewayNameValue = ""; + this.cloudProfilesValue = []; + this.cloudProfilesReadyValue = false; + this.resetCloudProfileRetry(); + this.callbacks.onInvalidate(resetHostSelection, submissionOutcome); + this.callbacks.requestUpdate(); + } + + retryPendingCatalogTarget() { + const { data } = this.read(); + if (this.catalogRetryingValue) { + return; + } + if (!this.gatewayConnectedValue || !catalog.isTarget(data) || catalog.isResolvedTarget(data)) { + globalThis.clearTimeout(this.catalogRetryTimer); + this.catalogRetryTimer = undefined; + this.catalogRetryScope = ""; + this.catalogRetryAttempt = 0; + return; + } + const retryScope = `${this.gatewayConnectionEpochValue}:${catalog.routeKey(data)}`; + if (this.catalogRetryScope !== retryScope) { + globalThis.clearTimeout(this.catalogRetryTimer); + this.catalogRetryTimer = undefined; + this.catalogRetryScope = retryScope; + this.catalogRetryAttempt = 0; + } + if (this.catalogRetryTimer || this.catalogRetryAttempt >= CATALOG_RETRY_DELAYS_MS.length) { + return; + } + const delayMs = CATALOG_RETRY_DELAYS_MS[this.catalogRetryAttempt]; + this.catalogRetryAttempt += 1; + this.catalogRetryTimer = globalThis.setTimeout(() => { + this.catalogRetryTimer = undefined; + const current = this.read(); + if ( + this.catalogRetryScope !== retryScope || + !this.gatewayConnectedValue || + !catalog.isTarget(current.data) || + catalog.isResolvedTarget(current.data) + ) { + return; + } + const revalidation = current.context?.revalidate("new-session"); + if (!revalidation) { + return; + } + void revalidation + .catch(() => undefined) + .then(() => this.callbacks.updateComplete()) + .then(() => this.retryPendingCatalogTarget()); + }, delayMs); + } + + readonly handleCatalogRetry = () => { + const { context, data } = this.read(); + if ( + this.catalogRetryingValue || + !this.gatewayConnectedValue || + !catalog.isTarget(data) || + catalog.isResolvedTarget(data) + ) { + return; + } + const revalidation = context?.revalidate("new-session"); + if (!revalidation) { + return; + } + globalThis.clearTimeout(this.catalogRetryTimer); + this.catalogRetryTimer = undefined; + this.catalogRetryingValue = true; + this.callbacks.requestUpdate(); + void revalidation + .catch(() => undefined) + .then(() => this.callbacks.updateComplete()) + .finally(() => { + this.catalogRetryingValue = false; + this.retryPendingCatalogTarget(); + this.callbacks.requestUpdate(); + }); + }; + + readPreference(agentId: string): NewSessionPreference | null { + const snapshot = this.read(); + if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) { + return null; + } + return this.preferenceModeValue === "remote" + ? (this.identityPreferences[normalizeAgentId(agentId)] ?? null) + : loadNewSessionPreference(this.gatewayUrlValue, agentId); + } + + persistPreference(agentIdValue: string, workspace: string, patch: NewSessionPreference) { + const snapshot = this.read(); + if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) { + return; + } + const agentId = normalizeAgentId(agentIdValue); + const nextPatch = { workspace, ...patch }; + if (this.preferenceModeValue === "local") { + patchNewSessionPreference(this.gatewayUrlValue, agentId, nextPatch); + return; + } + const scope = this.preferenceScope; + const client = this.gatewayClientValue; + const gatewayUrl = this.gatewayUrlValue; + const write = async () => { + await this.preferenceLoad; + if (!client || this.preferenceScope !== scope) { + return; + } + if (this.preferenceModeValue === "local") { + patchNewSessionPreference(gatewayUrl, agentId, nextPatch); + return; + } + const next = { ...this.identityPreferences[agentId], ...nextPatch }; + try { + const result = await client.request("users.prefs.set", { + entries: encodeIdentityPreferences({ [agentId]: next }), + }); + if (result.status !== "ok" || this.preferenceScope !== scope) { + return; + } + this.identityPreferences = { ...this.identityPreferences, [agentId]: next }; + replaceBrowserPreference(gatewayUrl, agentId, next); + this.callbacks.requestUpdate(); + } catch { + // Gateway state is authoritative for identified users; retain the last mirrored value. + } + }; + this.preferenceWrite = this.preferenceWrite.then(write, write); + } + + disconnect() { + this.gatewaySource = null; + this.gatewayClientValue = null; + this.gatewayConnectedValue = false; + this.gatewayConnectionEpochValue = 0; + this.catalogRetryScope = ""; + this.catalogRetryAttempt = 0; + globalThis.clearTimeout(this.catalogRetryTimer); + this.catalogRetryTimer = undefined; + void this.gatewayNameTask.run([null, false, -1]); + void this.cloudProfileTask.run([null, -1, false, ""]); + this.resetCloudProfileRetry(); + } + + private applyCloudProfiles(profiles: DraftCloudProfile[]) { + const recovery = selectProfiles( + profiles, + this.gatewayClientValue, + this.gatewayRecoveryScopeValue, + ); + this.cloudProfilesValue = recovery.profiles; + const snapshot = this.read(); + const pendingCloud = Boolean(snapshot.pendingCloud.sessionKey); + if ((!this.gatewayConnectedValue || !snapshot.isAdmin) && !pendingCloud) { + this.callbacks.onCloudProfileCleared(); + } + const selectionUnavailable = + !pendingCloud && + Boolean(snapshot.cloudProfileId) && + !profiles.some((profile) => profile.id === snapshot.cloudProfileId); + if (selectionUnavailable) { + this.callbacks.onCloudState(t("newSession.catalogUnavailable")); + } else if (recovery.unsupported) { + this.callbacks.onCloudState(t("newSession.cloudRecoveryUnavailable")); + } else { + this.callbacks.onCloudState(null); + } + } + + private resetCloudProfileRetry() { + globalThis.clearTimeout(this.cloudProfileRetryTimer); + this.cloudProfileRetryTimer = undefined; + this.cloudProfileRetryAttempt = 0; + } + + private scheduleCloudProfileRetry() { + if (this.cloudProfileRetryTimer || !this.gatewayConnectedValue || !this.gatewayClientValue) { + return; + } + if (this.cloudProfileRetryAttempt >= CLOUD_PROFILE_RETRY_DELAYS_MS.length) { + this.applyCloudProfiles([]); + this.cloudProfilesReadyValue = true; + return; + } + const delayMs = CLOUD_PROFILE_RETRY_DELAYS_MS[this.cloudProfileRetryAttempt]; + this.cloudProfileRetryAttempt += 1; + this.cloudProfileRetryTimer = globalThis.setTimeout(() => { + this.cloudProfileRetryTimer = undefined; + if (this.gatewayConnectedValue) { + void this.cloudProfileTask.run(); + } + }, delayMs); + } + + private synchronizeIdentityPreferences(profileId: string | undefined) { + const client = this.gatewayConnectedValue ? this.gatewayClientValue : null; + const context = this.read().context; + const advertised = + context && + isGatewayMethodAdvertised(context.gateway.snapshot, "users.prefs.get") === true && + isGatewayMethodAdvertised(context.gateway.snapshot, "users.prefs.set") === true; + const scope = + client && profileId && advertised + ? `${this.gatewayConnectionEpochValue}\0${profileId}` + : "local"; + if (scope === this.preferenceScope) { + return; + } + this.preferenceScope = scope; + this.identityPreferences = {}; + if (!client || !profileId || !advertised) { + this.preferenceModeValue = "local"; + this.preferenceLoad = Promise.resolve(); + return; + } + this.preferenceModeValue = "loading"; + this.preferenceLoad = this.loadIdentityPreferences({ + client, + gatewayUrl: this.gatewayUrlValue, + scope, + }); + } + + private async loadIdentityPreferences(params: { + client: NonNullable; + gatewayUrl: string; + scope: string; + }): Promise { + try { + const result = await params.client.request("users.prefs.get", {}); + if (this.preferenceScope !== params.scope) { + return; + } + if (result.status !== "ok") { + this.preferenceModeValue = "local"; + return; + } + let preferences = decodeIdentityPreferences(result.entries); + const browserPreferences = loadBrowserPreferences(params.gatewayUrl); + if (result.entries[PREFS_MIGRATION_KEY] !== true) { + const missingBrowserPreferences = Object.fromEntries( + Object.entries(browserPreferences).filter( + ([agentId]) => !Object.hasOwn(preferences, agentId), + ), + ); + const migrationEntries = [ + ...Object.entries(encodeIdentityPreferences(missingBrowserPreferences)), + [PREFS_MIGRATION_KEY, true] as const, + ]; + let migrationFailed = false; + for (let offset = 0; offset < migrationEntries.length; offset += 32) { + const batch = Object.fromEntries(migrationEntries.slice(offset, offset + 32)); + let response: UsersPrefsSetResult; + try { + response = await params.client.request("users.prefs.set", { + entries: batch, + }); + } catch { + migrationFailed = true; + break; + } + if (this.preferenceScope !== params.scope) { + return; + } + if (response.status !== "ok") { + migrationFailed = true; + break; + } + Object.assign(preferences, decodeIdentityPreferences(batch)); + } + if (migrationFailed) { + preferences = { ...browserPreferences, ...preferences }; + } + } + this.identityPreferences = preferences; + this.preferenceModeValue = "remote"; + for (const [agentId, preference] of Object.entries(preferences)) { + replaceBrowserPreference(params.gatewayUrl, agentId, preference); + } + if (this.read().agentsHydrated) { + this.callbacks.onAdoptAgentDefaults(); + } + this.callbacks.requestUpdate(); + } catch { + if (this.preferenceScope === params.scope) { + this.preferenceModeValue = "local"; + this.callbacks.requestUpdate(); + } + } + } +} diff --git a/ui/src/pages/new-session/draft-place-browser.test.ts b/ui/src/pages/new-session/draft-place-browser.test.ts new file mode 100644 index 000000000000..e2a0a955d082 --- /dev/null +++ b/ui/src/pages/new-session/draft-place-browser.test.ts @@ -0,0 +1,111 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { ApplicationContext } from "../../app/context.ts"; +import { DraftGatewayState } from "./draft-gateway-state.ts"; +import { DraftPlaceBrowser } from "./draft-place-browser.ts"; + +class ControllerHost implements ReactiveControllerHost { + readonly updateComplete = Promise.resolve(true); + addController(_controller: ReactiveController) {} + removeController(_controller: ReactiveController) {} + requestUpdate() {} +} + +function createBrowser(request: (method: string) => Promise) { + const host = new ControllerHost(); + const client = { request, recoveryScope: "principal-a", recoveryScopeReady: true }; + const context = { + gateway: { + connection: { gatewayUrl: "ws://gateway.example" }, + snapshot: { + phase: "connected", + client, + hello: { + auth: { role: "operator", scopes: ["operator.read"] }, + features: { methods: ["projects.list"] }, + }, + }, + }, + } as unknown as ApplicationContext; + const gateway = new DraftGatewayState( + host, + () => ({ + context, + data: undefined, + isConnected: true, + isAdmin: false, + canStartAsDraft: false, + visibility: "normal", + cloudProfileId: "", + pendingCloud: { sessionKey: "", gatewayUrl: "", recoveryScope: "" }, + agentsHydrated: false, + }), + { + requestUpdate: vi.fn(), + updateComplete: () => Promise.resolve(), + onInvalidate: vi.fn(), + onVisibilityRetired: vi.fn(), + onCloudProfileCleared: vi.fn(), + onCloudState: vi.fn(), + onPendingCloudReset: vi.fn(), + onRecoveryReady: vi.fn(), + onAdoptAgentDefaults: vi.fn(), + }, + ); + gateway.synchronize(context.gateway); + const browser = new DraftPlaceBrowser( + host, + gateway, + () => ({ + context, + projectId: "", + nodes: [], + folder: "", + execNode: "", + isAdmin: false, + }), + { + requestUpdate: vi.fn(), + onProjectMissing: vi.fn(), + onSelectProject: vi.fn(), + onApplyFolder: vi.fn(), + onApprovedListing: vi.fn(), + querySelector: () => null, + activeElement: () => null, + body: () => null, + }, + ); + return browser; +} + +describe("DraftPlaceBrowser", () => { + it.each([ + ["the Gateway omits recents", async () => ({ projects: [] })], + [ + "projects.list fails", + async () => { + throw new Error("projects unavailable"); + }, + ], + ])("keeps roster recents when %s", async (_label, request) => { + const browser = createBrowser(request); + + await browser.refreshProjects(); + + expect( + browser.resolveProjectRecents({ + sessions: [{ execCwd: "/workspace/recent" }], + workspace: "/workspace", + workspaceRoots: ["/workspace"], + execNodes: [], + isAdmin: false, + }), + ).toEqual([ + { + kind: "folder", + folder: "/workspace/recent", + displayName: "recent", + }, + ]); + }); +}); diff --git a/ui/src/pages/new-session/draft-place-browser.ts b/ui/src/pages/new-session/draft-place-browser.ts new file mode 100644 index 000000000000..f4d0bed00551 --- /dev/null +++ b/ui/src/pages/new-session/draft-place-browser.ts @@ -0,0 +1,596 @@ +import { initialState, Task, TaskStatus } from "@lit/task"; +import type { ReactiveControllerHost } from "lit"; +import type { + FsListDirResult, + ProjectRecord, + ProjectRecent, + ProjectsAddResult, + ProjectsListResult, + ProjectsRegisterResult, + ProjectsSearchRemoteResult, + WorktreesBranchesResult, +} from "../../../../packages/gateway-protocol/src/index.js"; +import type { ApplicationContext } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import type { BrowserTarget, DraftNode } from "./discovery.ts"; +import type { DraftGatewayState } from "./draft-gateway-state.ts"; +import { folderDisplayName, isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; +import { projectCloneInput } from "./place-picker.ts"; +import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts"; + +const PROJECT_SEARCH_DEBOUNCE_MS = 300; + +type DraftPlaceBrowserSnapshot = Readonly<{ + context: ApplicationContext | undefined; + projectId: string; + nodes: readonly DraftNode[]; + folder: string; + execNode: string; + isAdmin: boolean; +}>; + +type DraftPlaceBrowserCallbacks = { + requestUpdate: () => void; + onProjectMissing: () => void; + onSelectProject: (projectId: string) => void; + onApplyFolder: (folder: string, execNode: string, gatewayApproved: boolean) => void; + onApprovedListing: (listing: FsListDirResult) => void; + querySelector: (selector: string) => Element | null; + activeElement: () => Element | null; + body: () => HTMLElement | null; +}; + +export class DraftPlaceBrowser { + private projectsValue: ProjectRecord[] = []; + private projectRecentsValue: ProjectRecent[] | undefined; + private projectQueryValue = ""; + private debouncedProjectQuery = ""; + private projectCloneBusyValue = false; + private projectCloneErrorValue: string | null = null; + private browserLoadingValue = false; + private browserErrorValue: string | null = null; + private browserListingValue: FsListDirResult | null = null; + private browserTargetValue: BrowserTarget | null = null; + private browserProjectPathValue: string | null = null; + private browserRegisteringValue = false; + private placePopoverOpenValue = false; + private placePopoverHidingValue = false; + // Live head input; absolute paths stay applicable even without fs.listDir. + private browserPathDraftValue = ""; + private browserRequestToken = 0; + private projectCloneRequestToken = 0; + private projectSearchTimer: ReturnType | undefined; + + private readonly projectsTask: Task; + private readonly projectSearchTask: Task; + + constructor( + host: ReactiveControllerHost, + private readonly gateway: DraftGatewayState, + private readonly read: () => DraftPlaceBrowserSnapshot, + private readonly callbacks: DraftPlaceBrowserCallbacks, + ) { + this.projectsTask = new Task(host, { + args: () => + [ + this.read().context && this.gateway.connected ? this.gateway.client : null, + isGatewayMethodAdvertised( + this.read().context?.gateway.snapshot ?? {}, + "projects.list", + ) === true, + this.gateway.connectionEpoch, + ] as const, + task: async ([client, advertised]) => { + if (!client || !advertised) { + return { projects: [] } as ProjectsListResult; + } + return await ( + client as NonNullable + ).request("projects.list", {}); + }, + onComplete: (result) => { + const projects = result.projects ?? []; + this.projectsValue = projects; + this.projectRecentsValue = result.recents; + if ( + this.read().projectId && + !projects.some((project) => project.id === this.read().projectId) + ) { + this.callbacks.onProjectMissing(); + } + this.callbacks.requestUpdate(); + }, + onError: () => { + this.projectsValue = []; + this.projectRecentsValue = undefined; + this.callbacks.onProjectMissing(); + this.callbacks.requestUpdate(); + }, + }); + this.projectSearchTask = new Task(host, { + args: () => + [ + this.read().context && this.gateway.connected ? this.gateway.client : null, + this.read().context + ? canCallGatewayMethod( + this.read().context?.gateway.snapshot, + "projects.searchRemote", + "operator.read", + ) + : false, + this.debouncedProjectQuery, + this.gateway.connectionEpoch, + ] as const, + task: ([client, advertised, query], { signal }) => { + if (!client || !advertised || query.length < 2 || projectCloneInput(query)) { + return initialState; + } + return client.request( + "projects.searchRemote", + { query }, + { signal }, + ); + }, + }); + } + + get projects(): readonly ProjectRecord[] { + return this.projectsValue; + } + + get projectRecents(): readonly ProjectRecent[] | undefined { + return this.projectRecentsValue; + } + + get projectQuery(): string { + return this.projectQueryValue; + } + + get projectSearchResult(): ProjectsSearchRemoteResult | null { + return this.projectSearchTask.status === TaskStatus.COMPLETE && + this.debouncedProjectQuery === this.projectQueryValue.trim() + ? (this.projectSearchTask.value ?? null) + : null; + } + + get projectSearchLoading(): boolean { + return ( + this.debouncedProjectQuery.length >= 2 && + this.debouncedProjectQuery === this.projectQueryValue.trim() && + this.projectSearchTask.status === TaskStatus.PENDING + ); + } + + get projectSearchError(): string | null { + if ( + this.projectSearchTask.status !== TaskStatus.ERROR || + this.debouncedProjectQuery !== this.projectQueryValue.trim() + ) { + return null; + } + const error = this.projectSearchTask.error; + return error instanceof Error ? error.message : String(error); + } + + get projectCloneBusy(): boolean { + return this.projectCloneBusyValue; + } + + get projectCloneError(): string | null { + return this.projectCloneErrorValue; + } + + get browserLoading(): boolean { + return this.browserLoadingValue; + } + + get browserError(): string | null { + return this.browserErrorValue; + } + + get browserListing(): FsListDirResult | null { + return this.browserListingValue; + } + + get browserTarget(): BrowserTarget | null { + return this.browserTargetValue; + } + + get browserProjectPath(): string | null { + return this.browserProjectPathValue; + } + + get browserRegistering(): boolean { + return this.browserRegisteringValue; + } + + get placePopoverOpen(): boolean { + return this.placePopoverOpenValue; + } + + get placePopoverHiding(): boolean { + return this.placePopoverHidingValue; + } + + get browserPathDraft(): string { + return this.browserPathDraftValue; + } + + set browserPathDraft(value: string) { + this.browserPathDraftValue = value; + this.callbacks.requestUpdate(); + } + + async refreshProjects(): Promise { + const context = this.read().context; + return await this.projectsTask.run([ + this.gateway.connected ? this.gateway.client : null, + context + ? isGatewayMethodAdvertised(context.gateway.snapshot, "projects.list") === true + : false, + this.gateway.connectionEpoch, + ]); + } + + selectedProject(projectId: string): ProjectRecord | undefined { + return this.projectsValue.find((project) => project.id === projectId); + } + + resolveProjectRecents(params: { + sessions: readonly RecentPlaceSource[]; + workspace: string; + workspaceRoots: readonly string[]; + execNodes: readonly DraftNode[]; + isAdmin: boolean; + }): ProjectRecent[] { + const allowGatewayFolder = (folder: string) => + params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, folder); + const serverRecents = this.projectRecentsValue?.filter((recent) => + recent.kind === "project" + ? this.projectsValue.some((project) => project.id === recent.projectId) + : recent.execNode + ? params.execNodes.some((node) => node.nodeId === recent.execNode) + : allowGatewayFolder(recent.folder), + ); + return ( + serverRecents ?? + recentPlaces(params.sessions, { + workspace: params.workspace, + execNodes: params.execNodes, + allowGatewayFolder, + }).map((recent) => { + const item: ProjectRecent = { + kind: "folder", + folder: recent.folder, + displayName: folderDisplayName(recent.folder), + }; + if (recent.execNode) { + item.execNode = recent.execNode; + } + return item; + }) + ); + } + + changeProjectQuery(query: string) { + this.projectQueryValue = query; + this.projectCloneErrorValue = null; + this.clearProjectSearchTimer(); + this.debouncedProjectQuery = ""; + void this.projectSearchTask.run([null, false, "", this.gateway.connectionEpoch]); + const normalized = query.trim(); + const context = this.read().context; + if ( + normalized.length < 2 || + projectCloneInput(normalized) || + !this.gateway.connected || + !this.gateway.client || + !context || + !canCallGatewayMethod(context.gateway.snapshot, "projects.searchRemote", "operator.read") + ) { + this.callbacks.requestUpdate(); + return; + } + const client = this.gateway.client; + const connectionEpoch = this.gateway.connectionEpoch; + this.projectSearchTimer = globalThis.setTimeout(() => { + this.projectSearchTimer = undefined; + if (client !== this.gateway.client || connectionEpoch !== this.gateway.connectionEpoch) { + return; + } + this.debouncedProjectQuery = normalized; + void this.projectSearchTask.run([client, true, normalized, connectionEpoch]); + this.callbacks.requestUpdate(); + }, PROJECT_SEARCH_DEBOUNCE_MS); + this.callbacks.requestUpdate(); + } + + async addRemoteProject(gitUrl: string) { + const client = this.gateway.client; + const context = this.read().context; + if ( + !client || + !this.gateway.connected || + this.projectCloneBusyValue || + !context || + !canCallGatewayMethod(context.gateway.snapshot, "projects.add", "operator.write") + ) { + return; + } + const requestId = ++this.projectCloneRequestToken; + const connectionEpoch = this.gateway.connectionEpoch; + this.projectCloneBusyValue = true; + this.projectCloneErrorValue = null; + this.callbacks.requestUpdate(); + try { + const project = await client.request( + "projects.add", + { gitUrl }, + { timeoutMs: null }, + ); + if ( + requestId !== this.projectCloneRequestToken || + client !== this.gateway.client || + connectionEpoch !== this.gateway.connectionEpoch + ) { + return; + } + await this.projectsTask.run([client, true, connectionEpoch]); + if ( + requestId !== this.projectCloneRequestToken || + client !== this.gateway.client || + connectionEpoch !== this.gateway.connectionEpoch + ) { + return; + } + this.callbacks.onSelectProject(project.id); + this.close(); + } catch (error) { + if (requestId === this.projectCloneRequestToken && client === this.gateway.client) { + this.projectCloneErrorValue = error instanceof Error ? error.message : String(error); + } + } finally { + if (requestId === this.projectCloneRequestToken) { + this.projectCloneBusyValue = false; + this.callbacks.requestUpdate(); + } + } + } + + resetProjectSearch() { + this.clearProjectSearchTimer(); + this.projectCloneRequestToken += 1; + this.projectQueryValue = ""; + this.debouncedProjectQuery = ""; + this.projectCloneBusyValue = false; + this.projectCloneErrorValue = null; + this.callbacks.requestUpdate(); + } + + resetProjects() { + this.projectsValue = []; + this.projectRecentsValue = undefined; + this.resetProjectSearch(); + } + + close() { + this.resetBrowser(true); + const popover = this.callbacks.querySelector(".new-session-page__place-popover") as + | (HTMLElement & { + open: boolean; + }) + | null; + if (popover) { + popover.open = false; + } + } + + showRoot() { + this.resetBrowser(false); + } + + usableBrowserPath(): string | null { + const draft = this.browserPathDraftValue.trim(); + if (draft.length === 0) { + return ""; + } + return isAbsolutePath(draft) ? draft : null; + } + + selectBrowserTarget(target: BrowserTarget) { + const snapshot = this.read(); + const folder = snapshot.folder.trim(); + const matchesCurrentTarget = target.nodeId === snapshot.execNode; + const path = matchesCurrentTarget && isAbsolutePath(folder) ? folder : undefined; + this.browserTargetValue = target; + this.loadBrowser(path); + } + + loadBrowser(path: string | undefined) { + const snapshot = this.read(); + const gatewaySnapshot = snapshot.context?.gateway.snapshot; + const client = gatewaySnapshot?.client; + const target = this.browserTargetValue; + if (gatewaySnapshot?.phase !== "connected" || !client || !target) { + return; + } + const targetNode = snapshot.nodes.find((node) => node.nodeId === target.nodeId); + if (targetNode?.canExec && !targetNode.canBrowse) { + this.showRoot(); + this.browserTargetValue = target; + this.browserPathDraftValue = path ?? ""; + this.callbacks.requestUpdate(); + return; + } + const requestId = ++this.browserRequestToken; + this.browserLoadingValue = true; + this.browserErrorValue = null; + this.browserProjectPathValue = null; + this.browserListingValue = null; + this.browserPathDraftValue = path ?? ""; + const draftAtRequest = this.browserPathDraftValue; + this.callbacks.requestUpdate(); + void client + .request("fs.listDir", { + ...(path ? { path } : {}), + ...(target.nodeId ? { nodeId: target.nodeId } : {}), + }) + .then((result) => { + if (requestId !== this.browserRequestToken) { + return; + } + this.browserListingValue = result ?? null; + if (result) { + this.callbacks.onApprovedListing(result); + } + if (result?.path && this.browserPathDraftValue === draftAtRequest) { + this.browserPathDraftValue = result.path; + } + if (result?.path && !target.nodeId && snapshot.isAdmin) { + void client + .request("worktrees.branches", { + repoRoot: result.path, + includeRepositoryStatus: true, + }) + .then((branches) => { + if ( + requestId === this.browserRequestToken && + this.browserListingValue?.path === result.path && + branches.repositoryStatus === "git" + ) { + this.browserProjectPathValue = result.path; + this.callbacks.requestUpdate(); + } + }) + .catch(() => undefined); + } + this.callbacks.requestUpdate(); + }) + .catch(() => { + if (requestId !== this.browserRequestToken) { + return; + } + if (path) { + this.loadBrowser(undefined); + return; + } + this.browserErrorValue = t("newSession.browserLoadFailed"); + this.callbacks.requestUpdate(); + }) + .finally(() => { + if (requestId === this.browserRequestToken) { + this.browserLoadingValue = false; + this.callbacks.requestUpdate(); + } + }); + } + + async registerBrowserProject(path: string) { + const snapshot = this.read(); + const gatewaySnapshot = snapshot.context?.gateway.snapshot; + const client = gatewaySnapshot?.client; + if ( + gatewaySnapshot?.phase !== "connected" || + !client || + !snapshot.isAdmin || + this.browserTargetValue?.nodeId || + this.browserProjectPathValue !== path || + this.browserRegisteringValue + ) { + return; + } + const requestId = this.browserRequestToken; + const connectionEpoch = this.gateway.connectionEpoch; + this.browserRegisteringValue = true; + this.browserErrorValue = null; + this.callbacks.requestUpdate(); + try { + const project = await client.request("projects.register", { path }); + if (requestId !== this.browserRequestToken || client !== this.gateway.client) { + return; + } + await this.projectsTask.run([client, true, connectionEpoch]); + if (requestId !== this.browserRequestToken || client !== this.gateway.client) { + return; + } + this.callbacks.onSelectProject(project.id); + this.close(); + } catch (error) { + if (requestId === this.browserRequestToken && client === this.gateway.client) { + this.browserErrorValue = error instanceof Error ? error.message : String(error); + } + } finally { + if (requestId === this.browserRequestToken) { + this.browserRegisteringValue = false; + this.callbacks.requestUpdate(); + } + } + } + + onPopoverShow() { + this.placePopoverOpenValue = true; + this.showRoot(); + } + + onPopoverHide() { + this.placePopoverOpenValue = false; + this.placePopoverHidingValue = true; + this.showRoot(); + } + + onPopoverAfterHide() { + this.placePopoverHidingValue = false; + this.restorePopoverTrigger("new-session-place-trigger", ".new-session-page__place-popover"); + this.callbacks.requestUpdate(); + } + + guardPopoverTransition(event: Event) { + if (!this.placePopoverHidingValue) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + } + + clearPopoverHiding() { + this.placePopoverHidingValue = false; + this.callbacks.requestUpdate(); + } + + disconnect() { + this.clearProjectSearchTimer(); + void this.projectsTask.run([null, false, -1]); + void this.projectSearchTask.run([null, false, "", -1]); + } + + private resetBrowser(closePopover: boolean) { + this.browserRequestToken += 1; + this.browserLoadingValue = false; + this.browserErrorValue = null; + this.browserListingValue = null; + this.browserTargetValue = null; + this.browserProjectPathValue = null; + this.browserRegisteringValue = false; + this.browserPathDraftValue = ""; + if (closePopover) { + this.placePopoverOpenValue = false; + } + this.callbacks.requestUpdate(); + } + + private clearProjectSearchTimer() { + globalThis.clearTimeout(this.projectSearchTimer); + this.projectSearchTimer = undefined; + } + + private restorePopoverTrigger(id: string, popoverSelector: string) { + const active = this.callbacks.activeElement(); + const popover = this.callbacks.querySelector(popoverSelector); + const body = this.callbacks.body(); + if (active && active !== body && !popover?.contains(active)) { + return; + } + (this.callbacks.querySelector(`#${id}`) as HTMLButtonElement | null)?.focus(); + } +} diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts new file mode 100644 index 000000000000..d198189e1a97 --- /dev/null +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -0,0 +1,744 @@ +import type { + FsListDirResult, + WorktreesBranchesResult, +} from "../../../../packages/gateway-protocol/src/index.js"; +import type { ApplicationContext } from "../../app/context.ts"; +import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; +import { t } from "../../i18n/index.ts"; +import { listSelectableAgents } from "../../lib/agents/display.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; +import * as catalog from "./catalog-target.ts"; +import type { DraftNode, DraftRepositoryState } from "./discovery.ts"; +import { readDraftNodes } from "./discovery.ts"; +import type { DraftGatewayState } from "./draft-gateway-state.ts"; +import type { DraftPlaceBrowser } from "./draft-place-browser.ts"; +import { isMissingRestoredFolderError } from "./folder-validation.ts"; +import type { NewSessionRouteData } from "./location.ts"; +import { newSessionSearch } from "./location.ts"; +import { NewSessionModelControl } from "./model-control.ts"; +import { isKnownWorkspacePath } from "./path.ts"; + +type DraftPlaceSnapshot = Readonly<{ + context: ApplicationContext | undefined; + data: NewSessionRouteData | undefined; + submitting: boolean; + pendingCloudSessionKey: string; +}>; + +type DraftPlaceCallbacks = { + requestUpdate: () => void; + onError: (error: string | null) => void; + onClearError: (error: string) => void; +}; + +export class DraftPlaceState { + private agentIdValue = ""; + private folderValue = ""; + private projectIdValue = ""; + private worktreeValue = false; + private worktreeNameValue = ""; + private baseRefValue = ""; + private repositoryValue: DraftRepositoryState = { kind: "idle" }; + private nodesValue: DraftNode[] = []; + private execNodeValue = ""; + private cloudProfileIdValue = ""; + private restoredFolderValidation: "none" | "checking" | "failed" = "none"; + private gatewayApprovedWorkspaceRoots: string[] = []; + private agentsHydratedValue = false; + private nodesHydrated = false; + private agentSelectedByUser = false; + private folderSelectedByUser = false; + private folderGatewayApproved = false; + private preferredWorktreeRestore = false; + private worktreeSelectedByUser = false; + private nodesRequestToken = 0; + private branchesRequestToken = 0; + private baseRefEditGeneration = 0; + private restoredFolderValidationToken = 0; + + readonly modelControl: NewSessionModelControl; + + constructor( + private readonly gateway: DraftGatewayState, + readonly browser: DraftPlaceBrowser, + private readonly read: () => DraftPlaceSnapshot, + private readonly callbacks: DraftPlaceCallbacks, + ) { + this.modelControl = new NewSessionModelControl( + callbacks.requestUpdate, + (selection) => this.persistPreference(selection), + (catalogId) => + this.read().context?.navigate("new-session", { + search: newSessionSearch(this.agentIdValue, { catalogId }), + }), + ); + } + + get agentId(): string { + return this.agentIdValue; + } + + get folder(): string { + return this.folderValue; + } + + get projectId(): string { + return this.projectIdValue; + } + + get worktree(): boolean { + return this.worktreeValue; + } + + get worktreeName(): string { + return this.worktreeNameValue; + } + + get baseRef(): string { + return this.baseRefValue; + } + + get repository(): DraftRepositoryState { + return this.repositoryValue; + } + + get nodes(): readonly DraftNode[] { + return this.nodesValue; + } + + get execNode(): string { + return this.execNodeValue; + } + + get cloudProfileId(): string { + return this.cloudProfileIdValue; + } + + get agentsHydrated(): boolean { + return this.agentsHydratedValue; + } + + get worktreePreferenceReady(): boolean { + return !this.preferredWorktreeRestore; + } + + setAgentsHydrated(value: boolean) { + this.agentsHydratedValue = value; + } + + agents() { + return listSelectableAgents(this.read().context?.agents.state.agentsList?.agents ?? []); + } + + selectedAgent() { + const agentId = normalizeAgentId(this.agentIdValue); + return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); + } + + selectedProject() { + return this.browser.selectedProject(this.projectIdValue); + } + + execNodes(): DraftNode[] { + return this.nodesValue.filter((node) => node.canExec); + } + + execNodeReady(): boolean { + return ( + !this.execNodeValue || + (this.nodesHydrated && this.execNodes().some((node) => node.nodeId === this.execNodeValue)) + ); + } + + isAdmin(): boolean { + return hasOperatorAdminAccess(this.read().context?.gateway.snapshot.hello?.auth ?? null); + } + + canWrite(): boolean { + return hasOperatorWriteAccess(this.read().context?.gateway.snapshot.hello?.auth ?? null); + } + + workspacePath(): string { + return normalizeOptionalString(this.selectedAgent()?.workspace) ?? ""; + } + + knownWorkspaceRoots(): string[] { + const configuredWorkspace = this.workspacePath(); + return configuredWorkspace + ? [configuredWorkspace, ...this.gatewayApprovedWorkspaceRoots] + : this.gatewayApprovedWorkspaceRoots; + } + + recordGatewayApprovedListing(listing: FsListDirResult) { + if (this.isAdmin()) { + return; + } + const roots = new Set(this.gatewayApprovedWorkspaceRoots); + roots.add(listing.path); + if (listing.parent) { + roots.add(listing.parent); + } + if (roots.size !== this.gatewayApprovedWorkspaceRoots.length) { + this.gatewayApprovedWorkspaceRoots = [...roots]; + this.callbacks.requestUpdate(); + } + } + + folderSubmissionBlocked(): boolean { + if (this.projectIdValue) { + return !this.selectedProject(); + } + if (this.restoredFolderValidation !== "none") { + return true; + } + if ( + !this.usesCustomFolder() || + this.isAdmin() || + this.folderGatewayApproved || + isKnownWorkspacePath(this.knownWorkspaceRoots(), this.folderValue) + ) { + return false; + } + // Free-typed paths still reach sessions.create so the Gateway can return + // the authoritative missing-scope error instead of the UI dead-ending. + return false; + } + + adoptAgentDefaults( + options: { preserveSelectedAgent?: boolean; preserveSelectedFolder?: boolean } = {}, + ) { + const snapshot = this.read(); + const agents = this.agents(); + const configuredDefault = snapshot.context?.agents.state.agentsList?.defaultId; + const fallback = agents.some((agent) => agent.id === configuredDefault) + ? (configuredDefault ?? "main") + : (agents[0]?.id ?? "main"); + const keepSelectedAgent = + options.preserveSelectedAgent && this.agentSelectedByUser && Boolean(this.selectedAgent()); + if (!keepSelectedAgent) { + this.agentIdValue = catalog.resolveAgentId(snapshot.data, agents, fallback); + this.agentSelectedByUser = false; + } + const preference = this.gateway.readPreference(this.agentIdValue); + const keepSelectedFolder = options.preserveSelectedFolder && this.folderSelectedByUser; + if (!this.execNodeValue && !keepSelectedFolder && !snapshot.pendingCloudSessionKey) { + const workspace = this.workspacePath(); + const storedFolder = preference?.folder ?? ""; + const storedWorkspaceMoved = + Boolean(storedFolder) && + storedFolder === preference?.workspace && + preference.workspace !== workspace; + const storedFolderUsable = Boolean(storedFolder) && !storedWorkspaceMoved; + this.folderValue = storedFolderUsable ? storedFolder : workspace; + this.folderGatewayApproved = false; + this.folderSelectedByUser = false; + this.preferredWorktreeRestore = preference?.worktree === true; + this.worktreeSelectedByUser = false; + if (storedWorkspaceMoved) { + this.persistPreference({ folder: workspace }); + } + } + if ( + keepSelectedFolder && + !this.execNodeValue && + !snapshot.pendingCloudSessionKey && + this.agentIdValue + ) { + this.persistPreference({ folder: this.folderValue, worktree: this.worktreeValue }); + } + void this.loadNodes(); + this.modelControl.load(snapshot.context, this.agentIdValue, !catalog.isTarget(snapshot.data), { + agent: this.selectedAgent(), + preference, + }); + if ( + !this.folderSelectedByUser && + this.folderValue !== this.workspacePath() && + !this.execNodeValue && + !snapshot.pendingCloudSessionKey + ) { + this.validateRestoredFolder(this.folderValue); + } else { + this.cancelRestoredFolderValidation(); + this.maybeLoadBranches(); + } + this.callbacks.requestUpdate(); + } + + resetDraft() { + this.agentSelectedByUser = false; + this.folderValue = ""; + this.projectIdValue = ""; + this.browser.resetProjectSearch(); + this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.gatewayApprovedWorkspaceRoots = []; + this.cancelRestoredFolderValidation(); + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = false; + this.worktreeValue = false; + this.worktreeNameValue = ""; + this.baseRefValue = ""; + this.repositoryValue = { kind: "idle" }; + this.execNodeValue = ""; + this.modelControl.reset(); + this.cloudProfileIdValue = ""; + this.callbacks.requestUpdate(); + } + + invalidateGatewayDiscovery(resetHostSelection: boolean) { + this.nodesRequestToken += 1; + this.nodesHydrated = false; + this.branchesRequestToken += 1; + this.repositoryValue = { kind: "idle" }; + this.baseRefValue = ""; + this.agentsHydratedValue = false; + this.modelControl.invalidate(resetHostSelection); + this.browser.close(); + this.cancelRestoredFolderValidation(); + this.gatewayApprovedWorkspaceRoots = []; + this.folderGatewayApproved = false; + this.browser.resetProjectSearch(); + if (!resetHostSelection) { + this.callbacks.requestUpdate(); + return; + } + this.agentIdValue = ""; + this.agentSelectedByUser = false; + this.folderValue = ""; + this.browser.resetProjects(); + this.projectIdValue = ""; + this.folderSelectedByUser = false; + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = false; + this.worktreeValue = false; + this.worktreeNameValue = ""; + this.baseRefEditGeneration += 1; + this.nodesValue = []; + this.execNodeValue = ""; + this.cloudProfileIdValue = ""; + this.callbacks.requestUpdate(); + } + + applyPendingCloud(params: { agentId: string; profileId: string; cwd?: string }) { + this.agentIdValue = params.agentId; + this.cloudProfileIdValue = params.profileId; + this.worktreeValue = true; + this.folderValue = params.cwd ?? ""; + this.folderGatewayApproved = false; + this.callbacks.requestUpdate(); + } + + clearCloudProfile() { + this.cloudProfileIdValue = ""; + this.browser.close(); + this.callbacks.requestUpdate(); + } + + clearProjectSelection() { + this.projectIdValue = ""; + this.maybeLoadBranches(); + this.callbacks.requestUpdate(); + } + + selectAgentId(agentId: string) { + const snapshot = this.read(); + if (snapshot.submitting || snapshot.pendingCloudSessionKey || catalog.isTarget(snapshot.data)) { + return; + } + if (normalizeAgentId(agentId) === normalizeAgentId(this.agentIdValue)) { + return; + } + this.agentIdValue = normalizeAgentId(agentId); + this.cancelRestoredFolderValidation(); + this.modelControl.reset(); + this.callbacks.onError(null); + this.agentSelectedByUser = true; + this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.gatewayApprovedWorkspaceRoots = []; + this.projectIdValue = ""; + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = false; + this.cloudProfileIdValue = ""; + this.worktreeValue = false; + this.worktreeNameValue = ""; + this.browser.close(); + if (this.execNodeValue) { + this.folderValue = ""; + } + this.adoptAgentDefaults({ preserveSelectedAgent: true }); + } + + applyFolder(folder: string, execNode = this.execNodeValue, gatewayApproved = false) { + const snapshot = this.read(); + if (snapshot.submitting || snapshot.pendingCloudSessionKey) { + return; + } + this.execNodeValue = execNode; + this.projectIdValue = ""; + this.cancelRestoredFolderValidation(); + if (execNode) { + this.cloudProfileIdValue = ""; + } + this.callbacks.onError(null); + this.folderValue = folder.trim(); + this.folderGatewayApproved = gatewayApproved && !execNode && !this.isAdmin(); + this.folderSelectedByUser = true; + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = true; + if (this.execNodeValue || !this.cloudProfileIdValue) { + this.worktreeValue = false; + } + this.worktreeNameValue = ""; + if (!this.execNodeValue && this.agentsHydratedValue) { + this.persistPreference({ folder: this.folderValue, worktree: this.worktreeValue }); + } + this.maybeLoadBranches(); + } + + selectProjectId(projectId: string) { + const snapshot = this.read(); + if (snapshot.submitting || snapshot.pendingCloudSessionKey) { + return; + } + const project = this.browser.selectedProject(projectId); + if (!project) { + return; + } + this.cancelRestoredFolderValidation(); + this.browser.resetProjectSearch(); + this.projectIdValue = project.id; + this.execNodeValue = ""; + this.cloudProfileIdValue = ""; + this.callbacks.onError(null); + this.folderSelectedByUser = false; + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = true; + this.worktreeValue = false; + this.worktreeNameValue = ""; + this.maybeLoadBranches(); + } + + selectExecNode(execNode: string) { + const snapshot = this.read(); + if (snapshot.submitting || snapshot.pendingCloudSessionKey) { + return; + } + if (execNode === this.execNodeValue && !this.cloudProfileIdValue) { + return; + } + const keepGatewayFolder = !execNode && !this.execNodeValue; + this.cancelRestoredFolderValidation(); + const keepWorktree = keepGatewayFolder && this.worktreeValue && this.worktreeAvailable(); + this.execNodeValue = execNode; + this.cloudProfileIdValue = ""; + if (!keepGatewayFolder) { + this.folderValue = execNode ? "" : this.workspacePath(); + this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.projectIdValue = ""; + } + this.worktreeValue = keepWorktree; + this.browser.close(); + if (!this.branchesMatchCurrentRepo()) { + this.maybeLoadBranches(); + } + this.callbacks.requestUpdate(); + } + + selectCloudProfile(profileId: string) { + const snapshot = this.read(); + if ( + snapshot.submitting || + snapshot.pendingCloudSessionKey || + !this.worktreeAvailable() || + !this.gateway.cloudProfiles.some((profile) => profile.id === profileId) + ) { + return; + } + this.cloudProfileIdValue = profileId; + this.projectIdValue = ""; + this.callbacks.onError(null); + this.worktreeValue = true; + this.browser.close(); + if (!this.branchesMatchCurrentRepo()) { + this.maybeLoadBranches(); + } + this.callbacks.requestUpdate(); + } + + toggleWorktree() { + if (this.cloudProfileIdValue) { + return; + } + this.worktreeValue = !this.worktreeValue; + this.preferredWorktreeRestore = false; + this.worktreeSelectedByUser = true; + this.persistPreference({ + folder: this.folderValue.trim() || this.workspacePath(), + worktree: this.worktreeValue, + }); + if (this.worktreeValue && this.repositoryValue.kind !== "git") { + this.maybeLoadBranches(); + } + this.callbacks.requestUpdate(); + } + + setBaseRef(baseRef: string) { + if (!this.read().submitting) { + this.baseRefEditGeneration += 1; + this.baseRefValue = baseRef; + this.callbacks.requestUpdate(); + } + } + + setWorktreeName(worktreeName: string) { + if (!this.read().submitting) { + this.worktreeNameValue = worktreeName; + this.callbacks.requestUpdate(); + } + } + + browseAvailable(): boolean { + return this.gateway.connected && (this.isAdmin() || Boolean(this.workspacePath())); + } + + worktreeAvailable(): boolean { + if (this.execNodeValue) { + return false; + } + if (this.selectedProject()?.repoRoot) { + return true; + } + if (this.repositoryValue.kind === "git") { + return true; + } + return ( + this.repositoryValue.kind === "unavailable" && + this.repositoryValue.repoRoot === this.workspacePath() && + this.selectedAgent()?.workspaceGit === true + ); + } + + private usesCustomFolder(): boolean { + if (this.projectIdValue) { + return false; + } + const folder = this.folderValue.trim(); + return Boolean(folder) && folder !== this.workspacePath(); + } + + private persistPreference(patch: Parameters[2]) { + this.gateway.persistPreference(this.agentIdValue, this.workspacePath(), patch); + } + + private cancelRestoredFolderValidation() { + this.restoredFolderValidationToken += 1; + this.restoredFolderValidation = "none"; + } + + private restoreWorkspaceFolder() { + this.restoredFolderValidation = "none"; + this.folderGatewayApproved = false; + this.callbacks.onClearError(t("newSession.browserLoadFailed")); + this.folderValue = this.workspacePath(); + this.worktreeValue = false; + this.preferredWorktreeRestore = false; + this.persistPreference({ folder: this.folderValue, worktree: false }); + this.maybeLoadBranches(); + } + + private validateRestoredFolder(folder: string) { + const snapshot = this.read().context?.gateway.snapshot; + const client = snapshot?.client; + if (snapshot?.phase !== "connected" || !client) { + this.restoreWorkspaceFolder(); + return; + } + const requestId = ++this.restoredFolderValidationToken; + this.restoredFolderValidation = "checking"; + void client + .request("fs.listDir", { path: folder }) + .then((result) => { + if ( + requestId !== this.restoredFolderValidationToken || + this.folderSelectedByUser || + this.folderValue !== folder + ) { + return; + } + this.recordGatewayApprovedListing(result); + this.folderGatewayApproved = !this.isAdmin(); + this.restoredFolderValidation = "none"; + this.callbacks.onClearError(t("newSession.browserLoadFailed")); + this.maybeLoadBranches(); + }) + .catch((error: unknown) => { + if ( + requestId !== this.restoredFolderValidationToken || + this.folderSelectedByUser || + this.folderValue !== folder + ) { + return; + } + if (!this.isAdmin() || isMissingRestoredFolderError(error)) { + this.restoreWorkspaceFolder(); + return; + } + this.restoredFolderValidation = "failed"; + this.callbacks.onError(t("newSession.browserLoadFailed")); + }); + } + + private async loadNodes() { + const requestId = ++this.nodesRequestToken; + this.nodesHydrated = false; + const snapshot = this.read().context?.gateway.snapshot; + const client = snapshot?.client; + if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) { + this.nodesValue = []; + this.nodesHydrated = true; + this.callbacks.requestUpdate(); + return; + } + try { + const result = await client.request<{ nodes?: unknown }>("node.list", {}); + if (requestId !== this.nodesRequestToken) { + return; + } + const nodes = readDraftNodes(result?.nodes); + this.nodesValue = nodes; + this.nodesHydrated = true; + if ( + this.execNodeValue && + !nodes.some((node) => node.nodeId === this.execNodeValue && node.canExec) + ) { + this.execNodeValue = ""; + this.folderValue = this.workspacePath(); + this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.worktreeValue = false; + this.worktreeNameValue = ""; + this.browser.close(); + this.maybeLoadBranches(); + } + this.callbacks.requestUpdate(); + } catch { + if (requestId === this.nodesRequestToken) { + this.nodesValue = []; + this.nodesHydrated = true; + this.callbacks.requestUpdate(); + } + } + } + + private maybeLoadBranches() { + const requestId = ++this.branchesRequestToken; + const restoreWorktree = this.preferredWorktreeRestore && !this.worktreeSelectedByUser; + const baseRefEditGeneration = this.baseRefEditGeneration; + this.repositoryValue = { kind: "idle" }; + this.baseRefValue = ""; + const selectedProject = this.selectedProject(); + if (this.execNodeValue) { + this.preferredWorktreeRestore = false; + return; + } + if (selectedProject && !selectedProject.repoRoot) { + this.preferredWorktreeRestore = false; + return; + } + const repoRoot = selectedProject?.repoRoot ?? (this.folderValue.trim() || this.workspacePath()); + const agent = this.selectedAgent(); + const usesWorkspace = !selectedProject && repoRoot === this.workspacePath(); + if (!repoRoot) { + this.preferredWorktreeRestore = false; + return; + } + if (usesWorkspace && agent?.workspaceGit !== true) { + this.repositoryValue = { kind: "direct", repoRoot }; + const rejectedWorktree = !this.cloudProfileIdValue && (this.worktreeValue || restoreWorktree); + if (!this.cloudProfileIdValue) { + this.worktreeValue = false; + } + this.preferredWorktreeRestore = false; + if (rejectedWorktree) { + this.persistPreference({ worktree: false }); + } + return; + } + const snapshot = this.read().context?.gateway.snapshot; + const client = snapshot?.client; + if (snapshot?.phase !== "connected" || !client) { + this.preferredWorktreeRestore = false; + return; + } + this.repositoryValue = { kind: "checking", repoRoot }; + void client + .request("worktrees.branches", { + repoRoot, + includeRepositoryStatus: true, + }) + .then((result) => { + if (requestId !== this.branchesRequestToken) { + return; + } + if (result?.repositoryStatus !== "git") { + this.repositoryValue = { + kind: result?.repositoryStatus === "not_git" ? "direct" : "unavailable", + repoRoot, + }; + if (result?.repositoryStatus === "not_git") { + const rejectedWorktree = + !this.cloudProfileIdValue && (this.worktreeValue || restoreWorktree); + if (!this.cloudProfileIdValue) { + this.worktreeValue = false; + } + if (rejectedWorktree) { + this.persistPreference({ worktree: false }); + } + } else if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) { + this.worktreeValue = true; + } + this.preferredWorktreeRestore = false; + this.callbacks.requestUpdate(); + return; + } + this.repositoryValue = { + kind: "git", + repoRoot, + branches: result.branches, + ...(result.defaultBranch ? { defaultBranch: result.defaultBranch } : {}), + ...(result.headBranch ? { headBranch: result.headBranch } : {}), + }; + if (restoreWorktree && !this.worktreeSelectedByUser && !this.execNodeValue) { + this.worktreeValue = true; + } + this.preferredWorktreeRestore = false; + if (baseRefEditGeneration === this.baseRefEditGeneration) { + this.baseRefValue = result.defaultBranch ?? result.headBranch ?? ""; + } + this.callbacks.requestUpdate(); + }) + .catch(() => { + if (requestId !== this.branchesRequestToken) { + return; + } + this.repositoryValue = { kind: "unavailable", repoRoot }; + if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) { + this.worktreeValue = true; + } + this.preferredWorktreeRestore = false; + this.callbacks.requestUpdate(); + }); + } + + private branchesMatchCurrentRepo(): boolean { + if (this.execNodeValue || this.repositoryValue.kind === "idle") { + return false; + } + const repoRoot = this.folderValue.trim() || this.workspacePath(); + return this.repositoryValue.repoRoot === repoRoot; + } +} diff --git a/ui/src/pages/new-session/draft-submission-flow.test.ts b/ui/src/pages/new-session/draft-submission-flow.test.ts new file mode 100644 index 000000000000..2476967ca9bb --- /dev/null +++ b/ui/src/pages/new-session/draft-submission-flow.test.ts @@ -0,0 +1,205 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ApplicationContext } from "../../app/context.ts"; +import { buildDraftSessionCreateParams } from "./create-params.ts"; +import { DraftGatewayState } from "./draft-gateway-state.ts"; +import { DraftPlaceBrowser } from "./draft-place-browser.ts"; +import { DraftPlaceState } from "./draft-place-state.ts"; +import { DraftSubmissionFlow } from "./draft-submission-flow.ts"; + +class ControllerHost implements ReactiveControllerHost { + readonly updateComplete = Promise.resolve(true); + addController(_controller: ReactiveController) {} + removeController(_controller: ReactiveController) {} + requestUpdate() {} +} + +afterEach(() => { + sessionStorage.clear(); +}); + +describe("DraftSubmissionFlow", () => { + it("hands cloud startup to the application owner and navigates immediately", async () => { + const createResult = vi.fn(async (params: Record) => ({ + key: String(params.key), + initialRun: { status: "idle" as const }, + })); + const start = vi.fn( + (_input: Parameters[0]) => + new Promise(() => { + // Application-owned startup intentionally outlives this route. + }), + ); + const navigate = vi.fn(); + const setSessionKey = vi.fn(); + const selectAgent = vi.fn(); + const client = { + recoveryScope: "principal-a", + recoveryScopeReady: true, + request: vi.fn(async (method: string) => { + if (method === "node.list") { + return { nodes: [] }; + } + if (method === "worktrees.branches") { + return { repositoryStatus: "git", branches: [] }; + } + return {}; + }), + }; + const context = { + basePath: "", + gateway: { + connection: { gatewayUrl: "ws://gateway.example" }, + snapshot: { + phase: "connected", + client, + hello: { + auth: { + role: "operator", + scopes: ["operator.read", "operator.write", "operator.admin"], + }, + features: { methods: ["sessions.create", "sessions.dispatch"] }, + }, + }, + setSessionKey, + }, + agents: { + state: { + connected: true, + client, + agentsList: { + defaultId: "cloud", + mainKey: "main", + agents: [{ id: "cloud", workspace: "/workspace", workspaceGit: true }], + }, + }, + }, + agentSelection: { state: { selectedId: "cloud" }, set: selectAgent }, + sessions: { state: { result: null }, createResult }, + cloudStartup: { start }, + config: { current: {} }, + navigate, + } as unknown as ApplicationContext; + const host = new ControllerHost(); + const gateway = new DraftGatewayState( + host, + () => ({ + context, + data: undefined, + isConnected: true, + isAdmin: place?.isAdmin() ?? true, + canStartAsDraft: flow?.canStartAsDraft() ?? false, + visibility: flow?.visibility ?? "normal", + cloudProfileId: place?.cloudProfileId ?? "", + pendingCloud: flow?.pendingCloud ?? { + sessionKey: "", + gatewayUrl: "", + recoveryScope: "", + }, + agentsHydrated: place?.agentsHydrated ?? false, + }), + { + requestUpdate: vi.fn(), + updateComplete: () => Promise.resolve(), + onInvalidate: vi.fn(), + onVisibilityRetired: () => flow?.setVisibility("normal"), + onCloudProfileCleared: () => place?.clearCloudProfile(), + onCloudState: (error) => flow?.setError(error), + onPendingCloudReset: () => flow?.resetPendingCloudWithoutClearingStorage(), + onRecoveryReady: (gatewayUrl, recoveryScope) => + flow?.restorePendingCloudRecovery(gatewayUrl, recoveryScope), + onAdoptAgentDefaults: () => place?.adoptAgentDefaults(), + }, + ); + const browser = new DraftPlaceBrowser( + host, + gateway, + () => ({ + context, + projectId: place?.projectId ?? "", + nodes: place?.nodes ?? [], + folder: place?.folder ?? "", + execNode: place?.execNode ?? "", + isAdmin: place?.isAdmin() ?? true, + }), + { + requestUpdate: vi.fn(), + onProjectMissing: () => place?.clearProjectSelection(), + onSelectProject: (projectId) => place?.selectProjectId(projectId), + onApplyFolder: (folder, execNode, approved) => + place?.applyFolder(folder, execNode, approved), + onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing), + querySelector: () => null, + activeElement: () => null, + body: () => null, + }, + ); + const place = new DraftPlaceState( + gateway, + browser, + () => ({ + context, + data: undefined, + submitting: flow?.submitting ?? false, + pendingCloudSessionKey: flow?.pendingCloud.sessionKey ?? "", + }), + { + requestUpdate: vi.fn(), + onError: (error) => flow?.setError(error), + onClearError: (error) => flow?.clearErrorIf(error), + }, + ); + const flow = new DraftSubmissionFlow( + gateway, + place, + () => ({ context, data: undefined, isConnected: true }), + { requestUpdate: vi.fn(), closeTransientUi: vi.fn() }, + ); + gateway.synchronize(context.gateway); + place.setAgentsHydrated(true); + place.adoptAgentDefaults(); + const apiAttachments = [{ fileName: "note.txt", content: "SGk=" }]; + const createParams = buildDraftSessionCreateParams({ + agentId: "cloud", + message: "", + worktree: true, + cwd: "/workspace", + workspace: "/workspace", + }); + flow.pendingCloud.stageCreate({ + agentId: "cloud", + profileId: "aws", + message: "keep this cloud task", + attachments: apiAttachments, + gatewayUrl: "ws://gateway.example", + recoveryScope: "principal-a", + createParams, + }); + flow.pendingCloud.retryAllowed = true; + place.applyPendingCloud({ agentId: "cloud", profileId: "aws", cwd: "/workspace" }); + flow.attachmentDraft.replace([ + { + id: "attachment-1", + dataUrl: "data:text/plain;base64,SGk=", + mimeType: "text/plain", + fileName: "note.txt", + }, + ]); + + await flow.submit(); + + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[0].recovery).toMatchObject({ + message: "keep this cloud task", + attachments: apiAttachments, + phase: "dispatching", + }); + expect(flow.pendingCloud.capture()).toBeNull(); + expect(flow.attachmentDraft.attachments).toHaveLength(0); + expect(flow.submitting).toBe(false); + expect(createResult).toHaveBeenCalledOnce(); + expect(setSessionKey).toHaveBeenCalledWith(start.mock.calls[0]?.[0].recovery.sessionKey); + expect(selectAgent).toHaveBeenCalledWith("cloud"); + expect(navigate).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts new file mode 100644 index 000000000000..91270d9768bf --- /dev/null +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -0,0 +1,690 @@ +import type { SessionsCatalogStartTerminalResult } from "../../../../packages/gateway-protocol/src/index.js"; +import { selectApplicationSession } from "../../app/agent-selection.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { + readSessionMethodAccess, + type SessionMethodAccess, +} from "../../lib/session-method-access.ts"; +import { openTerminalSessionInTerminal } from "../../lib/sessions/catalog-terminal.ts"; +import type { CloudSessionRecovery } from "../../lib/sessions/cloud-recovery.ts"; +import { deleteCloudDraftSession } from "../../lib/sessions/cloud-startup.ts"; +import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; +import { isTerminalAvailable } from "../../lib/terminal-availability.ts"; +import { createManagedWorktree } from "../../lib/worktrees/create-worktree.ts"; +import { buildChatApiAttachments, restoreChatApiAttachments } from "../chat/attachment-api.ts"; +import { requiresChatModelSetup } from "../chat/chat-model-setup.ts"; +import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts"; +import { NewSessionAttachmentDraft } from "./attachment-draft.ts"; +import * as catalog from "./catalog-target.ts"; +import { PendingCloudRecoveryState, type SubmissionOutcomeReason } from "./cloud-recovery-state.ts"; +import { NewSessionComposerTextareaController } from "./composer.ts"; +import { + buildDraftSessionCreateParams as assembleDraftSessionCreateParams, + canStartSessionAsDraft, + isWorktreeNameValid, + type NewSessionVisibility, +} from "./create-params.ts"; +import type { DraftGatewayState } from "./draft-gateway-state.ts"; +import type { DraftPlaceState } from "./draft-place-state.ts"; +import type { NewSessionRouteData } from "./location.ts"; +import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; + +type DraftSubmissionSnapshot = Readonly<{ + context: ApplicationContext | undefined; + data: NewSessionRouteData | undefined; + isConnected: boolean; +}>; + +type DraftSubmissionCallbacks = { + requestUpdate: () => void; + closeTransientUi: () => void; +}; + +export class DraftSubmissionFlow { + private visibilityValue: NewSessionVisibility = "normal"; + private messageValue = ""; + private submittingValue = false; + private submissionOutcomeUnknownValue: SubmissionOutcomeReason | null = null; + private errorValue: string | null = null; + private submitRequestToken = 0; + readonly pendingCloud = new PendingCloudRecoveryState(); + readonly attachmentDraft: NewSessionAttachmentDraft; + readonly composerTextarea = new NewSessionComposerTextareaController(); + + constructor( + private readonly gateway: DraftGatewayState, + private readonly place: DraftPlaceState, + private readonly read: () => DraftSubmissionSnapshot, + private readonly callbacks: DraftSubmissionCallbacks, + ) { + this.attachmentDraft = new NewSessionAttachmentDraft(callbacks.requestUpdate); + } + + get visibility(): NewSessionVisibility { + return this.visibilityValue; + } + + get message(): string { + return this.messageValue; + } + + get submitting(): boolean { + return this.submittingValue; + } + + get submissionOutcomeUnknown(): SubmissionOutcomeReason | null { + return this.submissionOutcomeUnknownValue; + } + + get error(): string | null { + return this.errorValue; + } + + setMessage(message: string) { + this.messageValue = message; + this.callbacks.requestUpdate(); + } + + setVisibility(visibility: NewSessionVisibility) { + this.visibilityValue = visibility; + this.callbacks.requestUpdate(); + } + + setError(error: string | null) { + if (error === null && this.errorValue === t("newSession.cloudRecoveryUnavailable")) { + this.errorValue = null; + } else if (error !== null) { + this.errorValue = error; + } + this.callbacks.requestUpdate(); + } + + clearError() { + this.errorValue = null; + this.callbacks.requestUpdate(); + } + + clearErrorIf(error: string) { + if (this.errorValue === error) { + this.errorValue = null; + this.callbacks.requestUpdate(); + } + } + + markPendingCloudUnavailable(outcome: SubmissionOutcomeReason) { + this.pendingCloud.retryAllowed = false; + this.submissionOutcomeUnknownValue = outcome; + this.callbacks.requestUpdate(); + } + + canStartAsDraft(): boolean { + return canStartSessionAsDraft({ + allowedVisibilities: + this.read().context?.gateway.snapshot.hello?.policy?.allowedSessionVisibilities, + hasMultipleIdentities: + this.read().context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities, + }); + } + + showStartInTerminal(): boolean { + const { context, data } = this.read(); + return Boolean( + context && + catalog.isTarget(data) && + data?.startTerminal && + context.config.current.cliAgentsEnabled === true && + isTerminalAvailable( + context.gateway.snapshot, + context.config.current.terminalEnabled ?? false, + ), + ); + } + + private buildDraftSessionCreateParams( + options: { + message?: string; + attachments?: unknown[]; + visibility?: NewSessionVisibility; + } = {}, + ): Record { + return assembleDraftSessionCreateParams({ + agentId: this.place.agentId, + message: options.message ?? "", + model: this.place.modelControl.selected, + thinkingLevel: this.place.modelControl.thinkingLevel, + visibility: options.visibility ?? this.visibilityValue, + attachments: options.attachments, + projectId: this.place.projectId, + worktree: this.place.worktree, + baseRef: this.place.baseRef, + worktreeName: this.place.worktreeName, + cwd: this.place.folder, + workspace: this.place.workspacePath(), + execNode: this.place.execNode, + catalogId: this.read().data?.catalogId, + }); + } + + submissionAccess( + createParams: Record = this.pendingCloud.createParams ?? + this.buildDraftSessionCreateParams(), + ): SessionMethodAccess { + const gateway = this.read().context?.gateway.snapshot; + const pendingCloud = Boolean(this.pendingCloud.sessionKey); + if (!pendingCloud || this.pendingCloud.phase === "creating") { + const createAccess = readSessionMethodAccess(gateway, { + method: "sessions.create", + params: createParams, + }); + if (!createAccess.allowed || !this.cloudProfileForSubmission()) { + return createAccess; + } + } + return readSessionMethodAccess(gateway, { + method: "sessions.dispatch", + requiredScope: "operator.admin", + }); + } + + submitDisabledReason(): string | undefined { + const access = this.submissionAccess(); + return access.allowed ? undefined : access.reason; + } + + terminalStartDisabledReason(): string | undefined { + const access = this.terminalStartAccess(); + return access.allowed ? undefined : access.reason; + } + + incognitoDisabledReason(): string | undefined { + const access = readSessionMethodAccess(this.read().context?.gateway.snapshot, { + method: "sessions.create", + params: this.buildDraftSessionCreateParams({ visibility: "incognito" }), + }); + return access.allowed ? undefined : access.reason; + } + + canSubmit(kind: "session" | "terminal" = "session"): boolean { + const pendingCloud = Boolean(this.pendingCloud.sessionKey); + const cloudProfileId = this.cloudProfileForSubmission(); + const message = pendingCloud ? this.pendingCloud.message : this.messageValue.trim(); + const hasAttachments = pendingCloud + ? Boolean(this.pendingCloud.attachments?.length) + : this.attachmentDraft.attachments.length > 0; + const gateway = this.read().context?.gateway; + if ( + this.submittingValue || + this.gateway.preferenceLoading || + this.requiresModelSetup() || + this.attachmentDraft.pendingReads > 0 || + (!pendingCloud && this.submissionOutcomeUnknownValue) || + (kind === "session" && !message && !hasAttachments) || + gateway?.snapshot.phase !== "connected" || + !gateway.snapshot.client + ) { + return false; + } + const access = kind === "terminal" ? this.terminalStartAccess() : this.submissionAccess(); + if (!access.allowed || this.place.folderSubmissionBlocked()) { + return false; + } + if (this.place.modelControl.isRestoringPreference() || !this.place.worktreePreferenceReady) { + return false; + } + if (pendingCloud) { + return Boolean( + this.pendingCloud.retryAllowed && + gateway.snapshot.client.recoveryScopeReady && + cloudProfileId && + this.pendingCloud.agentId && + this.pendingCloud.gatewayUrl === gateway.connection.gatewayUrl && + this.pendingCloud.recoveryScope === gateway.snapshot.client?.recoveryScope && + this.place.isAdmin(), + ); + } + if (this.place.agents().length === 0) { + return false; + } + if (!catalog.allowsSelectedAgent(this.read().data, this.place.selectedAgent())) { + return false; + } + if (!this.place.execNodeReady()) { + return false; + } + if ( + cloudProfileId && + (!this.place.isAdmin() || + !gateway.snapshot.client.recoveryScope || + !gateway.snapshot.client.recoveryScopeReady || + !this.gateway.cloudProfilesReady || + this.gateway.cloudProfilesPending || + !this.place.worktree || + !this.gateway.cloudProfiles.some((profile) => profile.id === cloudProfileId) || + Boolean(this.cloudRuntimeUnsupportedReason())) + ) { + return false; + } + if (this.place.execNode && this.place.worktree) { + return false; + } + if (this.place.worktree && !this.place.worktreeAvailable()) { + return false; + } + if (this.place.worktree && !isWorktreeNameValid(this.place.worktreeName)) { + return false; + } + if (kind === "terminal" && !(this.place.folder.trim() || this.place.workspacePath())) { + return false; + } + return true; + } + + requiresModelSetup(): boolean { + const selectedAgent = this.place.selectedAgent(); + return requiresChatModelSetup({ + catalog: + catalog.isTarget(this.read().data) || + Boolean(this.place.cloudProfileId) || + Boolean(this.pendingCloud.sessionKey), + connected: this.gateway.connected, + agentsLoaded: this.read().context?.agents.state.agentsList !== null, + selectedAgentFound: selectedAgent !== undefined, + agentModel: selectedAgent?.model?.primary, + }); + } + + cloudDisabledReason(): string | undefined { + const runtimeReason = this.cloudRuntimeUnsupportedReason(); + if (runtimeReason) { + return runtimeReason; + } + if (this.place.repository.kind === "checking") { + return t("newSession.checkingGit"); + } + if (this.place.repository.kind === "unavailable" && !this.place.worktreeAvailable()) { + return t("newSession.gitCheckUnavailable"); + } + return this.place.worktreeAvailable() ? undefined : t("newSession.cloudRequiresWorktree"); + } + + invalidate(outcomeUnknown: SubmissionOutcomeReason | null = null) { + this.submitRequestToken += 1; + if (outcomeUnknown && this.submittingValue) { + this.submissionOutcomeUnknownValue = outcomeUnknown; + } + this.submittingValue = false; + this.callbacks.requestUpdate(); + } + + resetDraft() { + const preservePendingCloud = Boolean(this.pendingCloud.sessionKey); + this.invalidate(); + this.submissionOutcomeUnknownValue = preservePendingCloud + ? (this.submissionOutcomeUnknownValue ?? "cloud-interrupted") + : null; + this.visibilityValue = "normal"; + this.attachmentDraft.reset({ release: true }); + if (preservePendingCloud) { + if (!this.pendingCloud.restored) { + this.pendingCloud.retryAllowed = false; + } + const recovery = this.pendingCloud.capture(); + if (recovery) { + this.applyRecoveryDraft(recovery); + } + this.pendingCloud.restored = false; + } else { + this.clearPendingCloudRecovery(); + this.messageValue = ""; + } + this.errorValue = null; + this.callbacks.requestUpdate(); + } + + clearPendingCloudRecovery() { + this.pendingCloud.clear(); + this.submissionOutcomeUnknownValue = null; + this.callbacks.requestUpdate(); + } + + resetPendingCloudWithoutClearingStorage() { + this.pendingCloud.reset(); + this.submissionOutcomeUnknownValue = null; + this.callbacks.requestUpdate(); + } + + restorePendingCloudRecovery(gatewayUrl: string, recoveryScope: string) { + const recovery = this.pendingCloud.restore(gatewayUrl, recoveryScope); + if (!recovery) { + return; + } + this.applyRecoveryDraft(recovery); + this.callbacks.requestUpdate(); + } + + async submit() { + const context = this.read().context; + if (!context || !this.canSubmit()) { + return; + } + const pendingCloud = Boolean(this.pendingCloud.sessionKey); + const message = pendingCloud ? this.pendingCloud.message : this.messageValue.trim(); + const attachments = this.attachmentDraft.attachments; + const apiAttachments = pendingCloud + ? this.pendingCloud.attachments + : buildChatApiAttachments(attachments); + const submissionAgentId = pendingCloud + ? this.pendingCloud.agentId + : normalizeAgentId(this.place.agentId); + const submissionGatewayUrl = pendingCloud + ? this.pendingCloud.gatewayUrl + : context.gateway.connection.gatewayUrl; + const submissionClient = context.gateway.snapshot.client; + if (!submissionClient || !context.gateway.snapshot.hello) { + return; + } + const submissionRecoveryScope = pendingCloud + ? this.pendingCloud.recoveryScope + : submissionClient.recoveryScope; + const requestId = ++this.submitRequestToken; + const submittedAt = Date.now(); + this.submittingValue = true; + this.errorValue = null; + this.place.browser.close(); + this.callbacks.closeTransientUi(); + this.callbacks.requestUpdate(); + try { + const cloudProfileId = this.cloudProfileForSubmission(); + const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft(); + const createParams = this.buildDraftSessionCreateParams({ + message: cloudProfileId ? "" : message, + visibility: draftRetired ? "normal" : this.visibilityValue, + attachments: cloudProfileId ? undefined : apiAttachments, + }); + const cloudCreateParams = cloudProfileId + ? pendingCloud + ? this.pendingCloud.createParams + : this.pendingCloud.stageCreate({ + agentId: submissionAgentId, + profileId: cloudProfileId, + message, + attachments: apiAttachments, + gatewayUrl: submissionGatewayUrl, + recoveryScope: submissionRecoveryScope, + createParams, + persistent: this.visibilityValue !== "incognito", + }) + : undefined; + const requestAccess = this.submissionAccess(cloudCreateParams ?? createParams); + if (!requestAccess.allowed) { + this.errorValue = requestAccess.reason; + return; + } + if (cloudProfileId && !pendingCloud && !cloudCreateParams) { + this.errorValue = t("newSession.cloudStartFailed", { + error: "cloud recovery storage is unavailable", + }); + return; + } + const submissionCloudRecovery = cloudProfileId ? this.pendingCloud.capture() : null; + if (cloudProfileId && !submissionCloudRecovery) { + this.errorValue = t("newSession.cloudStartFailed", { + error: "cloud recovery storage is unavailable", + }); + return; + } + const recoveryOwnerKey = submissionCloudRecovery?.sessionKey ?? ""; + const ownsSubmissionRecovery = () => + this.pendingCloud.owns(submissionGatewayUrl, submissionRecoveryScope, recoveryOwnerKey); + const isSubmissionLifecycleCurrent = () => + this.read().isConnected && + submissionClient.recoveryScopeReady && + requestId === this.submitRequestToken && + this.gateway.client === submissionClient && + this.gateway.gatewayUrl === submissionGatewayUrl && + this.gateway.recoveryScope === submissionRecoveryScope; + const result = + pendingCloud && this.pendingCloud.phase !== "creating" + ? { key: this.pendingCloud.sessionKey, initialRun: { status: "idle" as const } } + : await context.sessions.createResult(cloudCreateParams ?? createParams, { + reconciliation: "background", + }); + if (requestId !== this.submitRequestToken && !cloudProfileId) { + return; + } + if (!result) { + if (requestId !== this.submitRequestToken) { + return; + } + this.errorValue = context.sessions.state.error ?? t("newSession.createFailed"); + return; + } + if (cloudProfileId && submissionCloudRecovery) { + if ( + submissionCloudRecovery.phase === "creating" && + (!isSubmissionLifecycleCurrent() || !ownsSubmissionRecovery()) + ) { + const cleanupError = await deleteCloudDraftSession( + submissionClient, + result.key, + submissionAgentId, + ); + if (cleanupError) { + this.pendingCloud.promoteToDispatching(result.key); + this.pendingCloud.retryAllowed = true; + this.errorValue = t("newSession.cloudStartFailed", { error: cleanupError }); + this.callbacks.requestUpdate(); + } else { + this.clearPendingCloudRecovery(); + } + return; + } + if ( + submissionCloudRecovery.phase === "creating" && + isSubmissionLifecycleCurrent() && + ownsSubmissionRecovery() + ) { + if (!this.pendingCloud.promoteToDispatching(result.key)) { + this.errorValue = t("newSession.cloudStartFailed", { + error: "cloud recovery storage is unavailable", + }); + return; + } + } + const recovery = this.pendingCloud.capture(); + if (!recovery || recovery.phase === "creating") { + this.errorValue = t("newSession.cloudStartFailed", { + error: "cloud recovery storage is unavailable", + }); + return; + } + if (requestId !== this.submitRequestToken) { + return; + } + context.cloudStartup.start({ + recovery, + persistRecovery: this.pendingCloud.persistent, + recovering: pendingCloud, + createdAt: submittedAt, + }); + if ( + requestId !== this.submitRequestToken || + !isSubmissionLifecycleCurrent() || + !this.pendingCloud.owns( + submissionGatewayUrl, + submissionRecoveryScope, + recovery.sessionKey, + ) + ) { + return; + } + this.pendingCloud.reset(); + this.attachmentDraft.clearAfterSubmit(true); + selectApplicationSession({ + selection: context.agentSelection, + gateway: context.gateway, + sessionKey: result.key, + agentId: submissionAgentId, + }); + context.navigate( + "chat", + sessionNavigationTarget({ + context, + face: "chat", + sessionKey: result.key, + agentId: this.place.agentId, + }).options, + ); + return; + } + if (requestId !== this.submitRequestToken) { + return; + } + const handedOffAttachments = + result.initialRun.status === "rejected" && + retainRejectedInitialTurn({ + agentId: this.place.agentId, + attachments, + context, + error: result.initialRun.error, + message, + sessionKey: result.key, + }); + if (result.initialRun.status === "started") { + prepareInitialUserMessageHandoff( + context.initialUserMessage, + result.key, + { text: message, attachments, createdAt: submittedAt }, + submissionClient, + { runId: result.initialRun.runId, messageSeq: result.initialRun.messageSeq }, + ); + } + this.attachmentDraft.clearAfterSubmit(!handedOffAttachments); + if (requestId !== this.submitRequestToken) { + return; + } + selectApplicationSession({ + selection: context.agentSelection, + gateway: context.gateway, + sessionKey: result.key, + agentId: submissionAgentId, + }); + context.navigate( + "chat", + sessionNavigationTarget({ + context, + face: "chat", + sessionKey: result.key, + agentId: this.place.agentId, + }).options, + ); + } finally { + if (requestId === this.submitRequestToken) { + this.submittingValue = false; + this.callbacks.requestUpdate(); + } + } + } + + async startInTerminal() { + const { context, data } = this.read(); + const client = context?.gateway.snapshot.client; + const catalogId = data?.catalogId.trim() ?? ""; + const agentId = normalizeAgentId(this.place.agentId); + if (!context || !client || !catalogId || !agentId || !this.canSubmit("terminal")) { + return; + } + const requestId = ++this.submitRequestToken; + const initialMessage = this.messageValue.trim(); + this.submittingValue = true; + this.errorValue = null; + this.place.browser.close(); + this.callbacks.closeTransientUi(); + this.callbacks.requestUpdate(); + try { + let cwd = this.place.folder.trim() || this.place.workspacePath(); + if (this.place.worktree) { + const created = await createManagedWorktree(client, { + repoRoot: cwd, + name: this.place.worktreeName, + baseRef: this.place.baseRef, + }); + if (requestId !== this.submitRequestToken || this.gateway.client !== client) { + return; + } + cwd = created.path; + } + const result = await client.request( + "sessions.catalog.startTerminal", + { + catalogId, + ...(this.place.execNode ? { hostId: `node:${this.place.execNode}` } : {}), + agentId, + cwd, + ...(initialMessage ? { initialMessage } : {}), + }, + ); + if (requestId !== this.submitRequestToken || this.gateway.client !== client) { + return; + } + this.messageValue = ""; + openTerminalSessionInTerminal(result.sessionId); + } catch (error) { + if (requestId === this.submitRequestToken && this.gateway.client === client) { + this.errorValue = error instanceof Error ? error.message : String(error); + } + } finally { + if (requestId === this.submitRequestToken) { + this.submittingValue = false; + this.callbacks.requestUpdate(); + } + } + } + + disconnect() { + this.attachmentDraft.reset({ release: true }); + this.composerTextarea.disconnect(); + } + + private terminalStartAccess(): SessionMethodAccess { + const gateway = this.read().context?.gateway.snapshot; + const terminalAccess = readSessionMethodAccess(gateway, { + method: "sessions.catalog.startTerminal", + requiredScope: "operator.admin", + }); + if (!terminalAccess.allowed || !this.place.worktree) { + return terminalAccess; + } + return readSessionMethodAccess(gateway, { + method: "worktrees.create", + requiredScope: "operator.admin", + }); + } + + private cloudProfileForSubmission(): string { + return this.pendingCloud.sessionKey ? this.pendingCloud.profileId : this.place.cloudProfileId; + } + + private cloudRuntimeUnsupportedReason(): string | undefined { + const runtime = this.place.modelControl.resolveAgentRuntimeId({ + agent: this.place.selectedAgent(), + context: this.read().context, + }); + return runtime && runtime !== "openclaw" + ? t("newSession.cloudRequiresOpenClawRuntime", { runtime }) + : undefined; + } + + private applyRecoveryDraft(recovery: CloudSessionRecovery) { + this.place.applyPendingCloud({ + agentId: recovery.agentId, + profileId: recovery.profileId, + cwd: recovery.createParams?.cwd, + }); + this.visibilityValue = recovery.createParams?.incognito === true ? "incognito" : "normal"; + this.messageValue = recovery.message; + this.attachmentDraft.replace(restoreChatApiAttachments(recovery.attachments)); + } +} diff --git a/ui/src/pages/new-session/new-session-page.test.ts b/ui/src/pages/new-session/new-session-page.test.ts index 6cf9c48505a6..b0531119ca13 100644 --- a/ui/src/pages/new-session/new-session-page.test.ts +++ b/ui/src/pages/new-session/new-session-page.test.ts @@ -1,44 +1,10 @@ -import { render, type TemplateResult } from "lit"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ApplicationContext } from "../../app/context.ts"; -import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; -import type { CloudSessionRecovery } from "../../lib/sessions/cloud-recovery.ts"; +import { afterEach, describe, expect, it } from "vitest"; import type { NewSessionRouteData } from "./location.ts"; import "./new-session-page.ts"; -type TestNewSessionPage = { +type NewSessionElement = HTMLElement & { data: NewSessionRouteData | undefined; - folder: string; - message: string; - openedFor: string | null; - visibility: "normal" | "draft" | "incognito"; - worktree: boolean; - agentId: string; - cloudProfileId: string; - context: ApplicationContext; - error: string | null; - submitting: boolean; - gatewayClient: ApplicationContext["gateway"]["snapshot"]["client"]; - gatewayConnected: boolean; - gatewayRecoveryScope: string; - gatewayUrl: string; - projectRecents: unknown; - projectsTask: { - run( - args: readonly [ApplicationContext["gateway"]["snapshot"]["client"], boolean, number], - ): Promise; - }; - pendingCloud: { capture(): CloudSessionRecovery | null }; - attachmentDraft: { - attachments: ChatAttachment[]; - replace(attachments: ChatAttachment[]): void; - }; - canSubmit(): boolean; - submissionAccess(): { allowed: true }; - submit(): Promise; - setMessageFromUser(message: string): void; - renderPlaceSelect(): TemplateResult; - updated(): void; + updateComplete: Promise; }; function routeData(agentId: string, catalogId = ""): NewSessionRouteData { @@ -52,6 +18,34 @@ function routeData(agentId: string, catalogId = ""): NewSessionRouteData { }; } +async function mount(data: NewSessionRouteData): Promise { + const page = document.createElement("openclaw-new-session-page") as NewSessionElement; + page.data = data; + document.body.append(page); + await settle(page); + return page; +} + +async function settle(page: NewSessionElement) { + await page.updateComplete; + await page.updateComplete; +} + +async function enterMessage(page: NewSessionElement, value: string) { + const textarea = page.querySelector(".new-session-page__message"); + expect(textarea).not.toBeNull(); + if (!textarea) { + return; + } + textarea.value = value; + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + await settle(page); +} + +function message(page: NewSessionElement): string { + return page.querySelector(".new-session-page__message")?.value ?? ""; +} + afterEach(() => { document.querySelectorAll("openclaw-new-session-page").forEach((element) => element.remove()); sessionStorage.clear(); @@ -59,203 +53,44 @@ afterEach(() => { }); describe("new session draft route ownership", () => { - it("clears all source draft state when destination data is still pending", () => { - const page = document.createElement( - "openclaw-new-session-page", - ) as unknown as TestNewSessionPage; - page.data = routeData("research"); - page.updated(); + it("clears source draft state when destination data is still pending", async () => { + const page = await mount(routeData("research")); window.history.replaceState({}, "", "/new?agent=research"); - page.setMessageFromUser("source draft"); - page.folder = "/workspace/source"; - page.visibility = "incognito"; - page.worktree = true; + await enterMessage(page, "source draft"); window.history.replaceState({}, "", "/new?agent=research&catalog=claude"); page.data = undefined; - page.updated(); + await settle(page); - expect(page.message).toBe(""); - expect(page.folder).toBe(""); - expect(page.visibility).toBe("normal"); - expect(page.worktree).toBe(false); - expect(page.openedFor).toBe(JSON.stringify(["research", "claude"])); + expect(message(page)).toBe(""); }); - it("keeps destination input through pending data, settlement, and agent resolution", () => { - const page = document.createElement( - "openclaw-new-session-page", - ) as unknown as TestNewSessionPage; - page.data = routeData("research"); - page.updated(); + it("keeps destination input through pending data, settlement, and agent resolution", async () => { + const page = await mount(routeData("research")); window.history.replaceState({}, "", "/new?agent=research&catalog=claude"); page.data = undefined; - page.updated(); - page.setMessageFromUser("keep this fast draft"); + await settle(page); + await enterMessage(page, "keep this fast draft"); - page.data = { - ...routeData("", "claude"), - requestedAgentId: "research", - }; - page.updated(); - expect(page.message).toBe("keep this fast draft"); + page.data = { ...routeData("", "claude"), requestedAgentId: "research" }; + await settle(page); + expect(message(page)).toBe("keep this fast draft"); page.data = routeData("research", "claude"); - page.updated(); - - expect(page.message).toBe("keep this fast draft"); + await settle(page); + expect(message(page)).toBe("keep this fast draft"); }); - it("clears a draft when a different route settles without destination-owned input", () => { - const page = document.createElement( - "openclaw-new-session-page", - ) as unknown as TestNewSessionPage; - page.data = routeData("research", "claude"); - page.updated(); + it("clears a draft when a different route settles without destination-owned input", async () => { + const page = await mount(routeData("research", "claude")); window.history.replaceState({}, "", "/new?agent=research&catalog=claude"); - page.setMessageFromUser("route-owned draft"); + await enterMessage(page, "route-owned draft"); window.history.replaceState({}, "", "/new?agent=main&catalog=codex"); page.data = undefined; - page.updated(); + await settle(page); - expect(page.message).toBe(""); - }); - - it("hands cloud startup to the application owner and navigates immediately", async () => { - window.history.replaceState({}, "", "/new"); - const page = document.createElement( - "openclaw-new-session-page", - ) as unknown as TestNewSessionPage; - Object.defineProperty(page, "isConnected", { configurable: true, value: true }); - const client = { recoveryScope: "principal-a", recoveryScopeReady: true }; - const createResult = vi.fn(async (params: Record) => ({ - key: String(params.key), - initialRun: { status: "idle" as const }, - })); - const start = vi.fn( - (_input: Parameters[0]) => - new Promise(() => { - // The application owner keeps loading after this route commits. - }), - ); - const navigate = vi.fn(); - const setSessionKey = vi.fn(); - const selectAgent = vi.fn(); - page.context = { - basePath: "", - gateway: { - connection: { gatewayUrl: "ws://gateway.example" }, - snapshot: { - phase: "connected", - client, - hello: { auth: { role: "operator", scopes: ["operator.admin"] } }, - }, - setSessionKey, - }, - agents: { state: { agentsList: null } }, - agentSelection: { state: { selectedId: "cloud" }, set: selectAgent }, - sessions: { state: { result: null }, createResult }, - cloudStartup: { start }, - navigate, - } as unknown as ApplicationContext; - page.agentId = "cloud"; - page.cloudProfileId = "aws"; - page.message = "keep this cloud task"; - page.visibility = "normal"; - page.worktree = true; - page.gatewayClient = client as ApplicationContext["gateway"]["snapshot"]["client"]; - page.gatewayConnected = true; - page.gatewayRecoveryScope = client.recoveryScope; - page.gatewayUrl = "ws://gateway.example"; - page.canSubmit = () => true; - page.submissionAccess = () => ({ allowed: true }); - page.attachmentDraft.replace([ - { - id: "attachment-1", - dataUrl: "data:text/plain;base64,SGk=", - mimeType: "text/plain", - fileName: "note.txt", - }, - ]); - - await page.submit(); - expect(start).toHaveBeenCalledOnce(); - expect(start.mock.calls[0]?.[0].recovery).toMatchObject({ - message: "keep this cloud task", - attachments: [{ fileName: "note.txt", content: "SGk=" }], - phase: "dispatching", - }); - expect(page.pendingCloud.capture()).toBeNull(); - expect(page.attachmentDraft.attachments).toHaveLength(0); - expect(page.submitting).toBe(false); - expect(createResult).toHaveBeenCalledOnce(); - expect(setSessionKey).toHaveBeenCalledWith(start.mock.calls[0]?.[0].recovery.sessionKey); - expect(selectAgent).toHaveBeenCalledWith("cloud"); - expect(navigate).toHaveBeenCalledOnce(); - }); -}); - -describe("new session project recents", () => { - const recentSession = { execCwd: "/workspace/recent" }; - - function createRecentsPage(request: (method: string) => Promise) { - const page = document.createElement( - "openclaw-new-session-page", - ) as unknown as TestNewSessionPage; - const client = { request } as unknown as ApplicationContext["gateway"]["snapshot"]["client"]; - page.agentId = "main"; - page.gatewayClient = client; - page.gatewayConnected = true; - page.gatewayUrl = "ws://gateway.example"; - page.context = { - gateway: { - connection: { gatewayUrl: page.gatewayUrl }, - snapshot: { - phase: "connected", - client, - selfUser: { id: "profile-a" }, - hello: { auth: { role: "operator", scopes: ["operator.read"] } }, - }, - }, - agents: { - state: { - agentsList: { - defaultId: "main", - mainKey: "main", - agents: [{ id: "main", workspace: "/workspace" }], - }, - }, - }, - sessions: { state: { result: { sessions: [recentSession] } } }, - config: { current: {} }, - } as unknown as ApplicationContext; - return { client, page }; - } - - async function expectRosterRecent(page: TestNewSessionPage) { - expect(page.projectRecents).toBeUndefined(); - const host = document.createElement("div"); - render(page.renderPlaceSelect(), host); - expect(host.querySelector('[data-value="recent::/workspace/recent"]')).not.toBeNull(); - } - - it("falls back to roster recents when projects.list omits server recents", async () => { - const { client, page } = createRecentsPage(async () => ({ projects: [] })); - - await page.projectsTask.run([client, true, 1]); - - await expectRosterRecent(page); - }); - - it("falls back to roster recents when projects.list fails", async () => { - const { client, page } = createRecentsPage(async () => { - throw new Error("projects unavailable"); - }); - - await page.projectsTask.run([client, true, 1]); - - await expectRosterRecent(page); + expect(message(page)).toBe(""); }); }); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index e31708e723fb..5c73aa02e6f4 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -1,102 +1,44 @@ import { consume } from "@lit/context"; -import { initialState, Task, TaskStatus } from "@lit/task"; -import { html, nothing } from "lit"; -import { property, state } from "lit/decorators.js"; -import type { - FsListDirResult, - ProjectRecord, - ProjectRecent, - ProjectsAddResult, - ProjectsListResult, - ProjectsRegisterResult, - ProjectsSearchRemoteResult, - SessionsCatalogStartTerminalResult, - UsersPrefsGetResult, - UsersPrefsSetResult, - WorktreesBranchesResult, -} from "../../../../packages/gateway-protocol/src/index.js"; +import { html, nothing, type ReactiveController, type ReactiveControllerHost } from "lit"; +import { property } from "lit/decorators.js"; import { selectApplicationSession } from "../../app/agent-selection.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts"; -import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; import { loadSettings } from "../../app/settings.ts"; import "../../components/tooltip.ts"; import "../../components/web-awesome-popover.ts"; import { t } from "../../i18n/index.ts"; -import { listSelectableAgents } from "../../lib/agents/display.ts"; -import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; -import { - readSessionMethodAccess, - type SessionMethodAccess, -} from "../../lib/session-method-access.ts"; -import { openTerminalSessionInTerminal } from "../../lib/sessions/catalog-terminal.ts"; -import { deleteCloudDraftSession } from "../../lib/sessions/cloud-startup.ts"; +import { canCallGatewayMethod } from "../../lib/gateway-methods.ts"; import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts"; -import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts"; -import { normalizeOptionalString } from "../../lib/string-coerce.ts"; -import { isTerminalAvailable } from "../../lib/terminal-availability.ts"; -import { createManagedWorktree } from "../../lib/worktrees/create-worktree.ts"; +import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import "../../styles/chat.css"; import "../../styles/new-session.css"; -import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { buildChatApiAttachments, restoreChatApiAttachments } from "../chat/attachment-api.ts"; -import { requiresChatModelSetup } from "../chat/chat-model-setup.ts"; import { clearChatModelSearchOnEscape } from "../chat/components/chat-model-picker.ts"; import { renderWelcomeState } from "../chat/components/chat-welcome.ts"; -import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts"; -import { NewSessionAttachmentDraft } from "./attachment-draft.ts"; import * as catalog from "./catalog-target.ts"; -import { - CLOUD_PROFILE_RETRY_DELAYS_MS, - discoverCloudProfiles, - selectProfiles, -} from "./cloud-profile-discovery.ts"; -import { - PendingCloudRecoveryState, - resolveSubmissionOutcomeReason, - resolveScope, - type SubmissionOutcomeReason, -} from "./cloud-recovery-state.ts"; -import { - NewSessionComposerTextareaController, - renderDraftError, - renderNewSessionDraftComposer, -} from "./composer.ts"; -import { - buildDraftSessionCreateParams, - canStartSessionAsDraft, - isWorktreeNameValid, - type NewSessionVisibility, -} from "./create-params.ts"; -import { - type BrowserTarget, - type DraftCloudProfile, - type DraftNode, - type DraftRepositoryState, - readDraftNodes, -} from "./discovery.ts"; -import { isMissingRestoredFolderError } from "./folder-validation.ts"; -import { discoverGatewayName } from "./gateway-name-discovery.ts"; -import { newSessionSearch, type NewSessionRouteData } from "./location.ts"; -import { NewSessionModelControl } from "./model-control.ts"; -import { isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; -import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts"; -import { - decodeIdentityPreferences, - encodeIdentityPreferences, - loadBrowserPreferences, - loadNewSessionPreference, - patchNewSessionPreference, - PREFS_MIGRATION_KEY, - replaceBrowserPreference, - type NewSessionPreference, -} from "./preferences.ts"; -import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; +import type { SubmissionOutcomeReason } from "./cloud-recovery-state.ts"; +import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts"; +import { isWorktreeNameValid } from "./create-params.ts"; +import { DraftGatewayState } from "./draft-gateway-state.ts"; +import { DraftPlaceBrowser } from "./draft-place-browser.ts"; +import { DraftPlaceState } from "./draft-place-state.ts"; +import { DraftSubmissionFlow } from "./draft-submission-flow.ts"; +import type { NewSessionRouteData } from "./location.ts"; +import { renderPlaceSelect } from "./place-picker.ts"; import { renderAgentSelect } from "./target-controls.ts"; -const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const; -const PROJECT_SEARCH_DEBOUNCE_MS = 300; +function controllerHost(element: OpenClawLightDomElement): ReactiveControllerHost { + return { + addController: (controller: ReactiveController) => element.addController(controller), + removeController: (controller: ReactiveController) => element.removeController(controller), + requestUpdate: () => element.requestUpdate(), + get updateComplete() { + return element.updateComplete; + }, + }; +} class NewSessionPage extends OpenClawLightDomElement { @property({ attribute: false }) data: NewSessionRouteData | undefined; @@ -104,506 +46,119 @@ class NewSessionPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; - @state() private agentId = ""; - @state() private folder = ""; - @state() private projects: ProjectRecord[] = []; - @state() private projectRecents: ProjectRecent[] | undefined; - @state() private projectId = ""; - @state() private projectQuery = ""; - @state() private debouncedProjectQuery = ""; - @state() private projectCloneBusy = false; - @state() private projectCloneError: string | null = null; - @state() private worktree = false; - @state() private visibility: NewSessionVisibility = "normal"; - @state() private worktreeName = ""; - @state() private baseRef = ""; - @state() private repository: DraftRepositoryState = { kind: "idle" }; - @state() private nodes: DraftNode[] = []; - @state() private gatewayName = ""; - @state() private execNode = ""; - @state() private cloudProfiles: DraftCloudProfile[] = []; - @state() private cloudProfilesReady = false; - @state() private cloudProfileId = ""; - @state() private message = ""; - @state() private submitting = false; - @state() private submissionOutcomeUnknown: SubmissionOutcomeReason | null = null; - @state() private error: string | null = null; - @state() private catalogRetrying = false; - @state() private browserLoading = false; - @state() private browserError: string | null = null; - @state() private browserListing: FsListDirResult | null = null; - @state() private browserTarget: BrowserTarget | null = null; - @state() private browserProjectPath: string | null = null; - @state() private browserRegistering = false; - @state() private placePopoverOpen = false; - @state() private placePopoverHiding = false; - // Live head input; absolute paths stay applicable even without fs.listDir. - @state() private browserPathDraft = ""; - @state() private restoredFolderValidation: "none" | "checking" | "failed" = "none"; - @state() private gatewayApprovedWorkspaceRoots: string[] = []; - private openedFor: string | null = null; private openedAgentId = ""; private messageOwnerKey = ""; - private agentsHydrated = false; - private nodesHydrated = false; - // Discovery retry provenance separates user choices from Gateway-derived defaults. - private agentSelectedByUser = false; - private folderSelectedByUser = false; - private folderGatewayApproved = false; - private preferredWorktreeRestore = false; - private worktreeSelectedByUser = false; - private submitRequestToken = 0; - private nodesRequestToken = 0; - private readonly pendingCloud = new PendingCloudRecoveryState(); - private branchesRequestToken = 0; - private baseRefEditGeneration = 0; - private browserRequestToken = 0; - private projectCloneRequestToken = 0; - private projectSearchTimer: ReturnType | undefined; - private restoredFolderValidationToken = 0; - private readonly attachmentDraft = new NewSessionAttachmentDraft(() => this.requestUpdate()); - private readonly composerTextarea = new NewSessionComposerTextareaController(); - private readonly modelControl = new NewSessionModelControl( - () => this.requestUpdate(), - (selection) => this.persistPreference(selection), - (catalogId) => - this.context?.navigate("new-session", { - search: newSessionSearch(this.agentId, { catalogId }), + private readonly gateway: DraftGatewayState; + private readonly browser: DraftPlaceBrowser; + private readonly place: DraftPlaceState; + private readonly submission: DraftSubmissionFlow; + private readonly subscriptions: SubscriptionsController; + + constructor() { + super(); + const host = controllerHost(this); + this.gateway = new DraftGatewayState( + host, + () => ({ + context: this.context, + data: this.data, + isConnected: this.isConnected, + isAdmin: this.place?.isAdmin() ?? false, + canStartAsDraft: this.submission?.canStartAsDraft() ?? false, + visibility: this.submission?.visibility ?? "normal", + cloudProfileId: this.place?.cloudProfileId ?? "", + pendingCloud: this.submission?.pendingCloud ?? { + sessionKey: "", + gatewayUrl: "", + recoveryScope: "", + }, + agentsHydrated: this.place?.agentsHydrated ?? false, }), - ); - private gatewaySource: ApplicationContext["gateway"] | null = null; - private gatewayClient: ApplicationContext["gateway"]["snapshot"]["client"] = null; - private gatewayUrl = ""; - private gatewayRecoveryScope = ""; - private gatewayRecoveryScopeReady = false; - private gatewayConnected = false; - private gatewayConnectionEpoch = 0; - private catalogRetryScope = ""; - private catalogRetryAttempt = 0; - private catalogRetryTimer: ReturnType | undefined; - private cloudProfileRetryAttempt = 0; - private cloudProfileRetryTimer: ReturnType | undefined; - private preferenceScope = ""; - private preferenceMode: "local" | "loading" | "remote" = "local"; - private identityPreferences: Record = {}; - private preferenceLoad: Promise = Promise.resolve(); - private preferenceWrite: Promise = Promise.resolve(); - - // Re-render when agents/sessions hydrate so the hero identity and the - // recent-chats list appear without a route change. - private readonly subscriptions = new SubscriptionsController(this) - .watch( - () => this.context?.gateway, - (gateway, notify) => gateway.subscribe(notify), - (gateway) => this.synchronizeGateway(gateway), - ) - .watch( - () => this.context?.agents, - (agents, notify) => agents.subscribe(notify), - ) - .watch( - () => this.context?.sessions, - (sessions, notify) => sessions.subscribe(notify), - ) - .watch( - () => this.context?.config, - (config, notify) => config.subscribe(() => notify()), + { + requestUpdate: () => this.requestUpdate(), + updateComplete: () => this.updateComplete, + onInvalidate: (resetHostSelection, outcome) => + this.invalidateGatewayDiscovery(resetHostSelection, outcome), + onVisibilityRetired: () => this.submission.setVisibility("normal"), + onCloudProfileCleared: () => this.place.clearCloudProfile(), + onCloudState: (error) => this.submission.setError(error), + onPendingCloudReset: () => this.submission.resetPendingCloudWithoutClearingStorage(), + onRecoveryReady: (gatewayUrl, recoveryScope) => + this.submission.restorePendingCloudRecovery(gatewayUrl, recoveryScope), + onAdoptAgentDefaults: () => + this.place.adoptAgentDefaults({ + preserveSelectedAgent: true, + preserveSelectedFolder: true, + }), + }, ); - - private readonly gatewayNameTask = new Task(this, { - args: () => - [ - this.isConnected && this.gatewayConnected ? this.gatewayClient : null, - this.context - ? isGatewayMethodAdvertised(this.context.gateway.snapshot, "system.info") === true - : false, - this.gatewayConnectionEpoch, - ] as const, - task: ([client, advertised, _connectionEpoch], { signal }) => - discoverGatewayName(client, advertised, signal), - onComplete: (name) => { - this.gatewayName = name; - }, - }); - - private readonly projectsTask = new Task(this, { - args: () => - [ - this.isConnected && this.gatewayConnected ? this.gatewayClient : null, - this.context - ? isGatewayMethodAdvertised(this.context.gateway.snapshot, "projects.list") === true - : false, - this.gatewayConnectionEpoch, - ] as const, - task: async ([client, advertised]) => { - if (!client || !advertised) { - return { projects: [] } as ProjectsListResult; - } - return await client.request("projects.list", {}); - }, - onComplete: (result) => { - const projects = result.projects ?? []; - this.projects = projects; - this.projectRecents = result.recents; - if (this.projectId && !projects.some((project) => project.id === this.projectId)) { - this.projectId = ""; - this.maybeLoadBranches(); - } - }, - onError: () => { - this.projects = []; - this.projectRecents = undefined; - this.projectId = ""; - }, - }); - - private readonly projectSearchTask = new Task(this, { - args: () => - [ - this.isConnected && this.gatewayConnected ? this.gatewayClient : null, - this.context - ? canCallGatewayMethod( - this.context.gateway.snapshot, - "projects.searchRemote", - "operator.read", - ) - : false, - this.debouncedProjectQuery, - this.gatewayConnectionEpoch, - ] as const, - task: ([client, advertised, query, _connectionEpoch], { signal }) => { - if (!client || !advertised || query.length < 2 || projectCloneInput(query)) { - return initialState; - } - return client.request( - "projects.searchRemote", - { query }, - { signal }, - ); - }, - }); - - private readonly cloudProfileTask = new Task(this, { - args: () => - [ - this.isConnected && this.gatewayConnected ? this.gatewayClient : null, - this.gatewayConnectionEpoch, - this.isAdmin(), - this.gatewayRecoveryScope, - ] as const, - task: ([client, _connectionEpoch, admin]) => - client ? discoverCloudProfiles(client, admin) : initialState, - onComplete: (profiles) => { - this.resetCloudProfileRetry(); - this.applyCloudProfiles(profiles); - this.cloudProfilesReady = true; - }, - onError: () => { - this.cloudProfiles = []; - this.cloudProfilesReady = false; - this.scheduleCloudProfileRetry(); - }, - }); - - private applyCloudProfiles(profiles: DraftCloudProfile[]) { - const recovery = selectProfiles(profiles, this.gatewayClient, this.gatewayRecoveryScope); - this.cloudProfiles = recovery.profiles; - const pendingCloud = Boolean(this.pendingCloud.sessionKey); - if ((!this.gatewayConnected || !this.isAdmin()) && !pendingCloud) { - this.cloudProfileId = ""; - this.closeBrowser(); - } - const selectionUnavailable = - !pendingCloud && - Boolean(this.cloudProfileId) && - !profiles.some((profile) => profile.id === this.cloudProfileId); - if (selectionUnavailable) { - this.error = t("newSession.catalogUnavailable"); - } else if (recovery.unsupported) { - this.error = t("newSession.cloudRecoveryUnavailable"); - } else if (this.error === t("newSession.cloudRecoveryUnavailable")) { - this.error = null; - } - } - - private resetCloudProfileRetry() { - globalThis.clearTimeout(this.cloudProfileRetryTimer); - this.cloudProfileRetryTimer = undefined; - this.cloudProfileRetryAttempt = 0; - } - - private scheduleCloudProfileRetry() { - if (this.cloudProfileRetryTimer || !this.gatewayConnected || !this.gatewayClient) { - return; - } - if (this.cloudProfileRetryAttempt >= CLOUD_PROFILE_RETRY_DELAYS_MS.length) { - this.applyCloudProfiles([]); - this.cloudProfilesReady = true; - return; - } - const delayMs = CLOUD_PROFILE_RETRY_DELAYS_MS[this.cloudProfileRetryAttempt]; - this.cloudProfileRetryAttempt += 1; - this.cloudProfileRetryTimer = globalThis.setTimeout(() => { - this.cloudProfileRetryTimer = undefined; - if (this.gatewayConnected) { - void this.cloudProfileTask.run(); - } - }, delayMs); - } - - private synchronizeGateway(gateway: ApplicationContext["gateway"]) { - const snapshot = gateway.snapshot; - const connected = snapshot.phase === "connected"; - const firstBind = this.gatewaySource === null; - const gatewayUrlChanged = !firstBind && this.gatewayUrl !== gateway.connection.gatewayUrl; - const identityChanged = - !firstBind && (this.gatewaySource !== gateway || this.gatewayClient !== snapshot.client); - const connectionChanged = !firstBind && this.gatewayConnected !== connected; - const becameConnected = connected && (identityChanged || !this.gatewayConnected); - const recoveryScopeBecameReady = - connected && snapshot.client?.recoveryScopeReady === true && !this.gatewayRecoveryScopeReady; - const recoveryScope = resolveScope( - { client: snapshot.client, connected }, - this.gatewayRecoveryScope, - firstBind, + this.browser = new DraftPlaceBrowser( + host, + this.gateway, + () => ({ + context: this.context, + projectId: this.place?.projectId ?? "", + nodes: this.place?.nodes ?? [], + folder: this.place?.folder ?? "", + execNode: this.place?.execNode ?? "", + isAdmin: this.place?.isAdmin() ?? false, + }), + { + requestUpdate: () => this.requestUpdate(), + onProjectMissing: () => this.place.clearProjectSelection(), + onSelectProject: (projectId) => this.place.selectProjectId(projectId), + onApplyFolder: (folder, execNode, gatewayApproved) => + this.place.applyFolder(folder, execNode, gatewayApproved), + onApprovedListing: (listing) => this.place.recordGatewayApprovedListing(listing), + querySelector: (selector) => this.querySelector(selector), + activeElement: () => this.ownerDocument.activeElement, + body: () => this.ownerDocument.body, + }, ); - this.gatewaySource = gateway; - this.gatewayClient = snapshot.client; - this.gatewayUrl = gateway.connection.gatewayUrl; - this.gatewayRecoveryScope = recoveryScope.next; - this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true; - this.gatewayConnected = connected; - if (this.visibility === "draft" && !this.canStartAsDraft()) { - this.visibility = "normal"; - } - if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { - const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed; - this.invalidateGatewayDiscovery( - gatewayIdentityChanged, - resolveSubmissionOutcomeReason({ - gatewayIdentityChanged, - cloudDraftOwned: Boolean(this.pendingCloud.sessionKey), - }), + this.place = new DraftPlaceState( + this.gateway, + this.browser, + () => ({ + context: this.context, + data: this.data, + submitting: this.submission?.submitting ?? false, + pendingCloudSessionKey: this.submission?.pendingCloud.sessionKey ?? "", + }), + { + requestUpdate: () => this.requestUpdate(), + onError: (error) => + error === null ? this.submission.clearError() : this.submission.setError(error), + onClearError: (error) => this.submission.clearErrorIf(error), + }, + ); + this.submission = new DraftSubmissionFlow( + this.gateway, + this.place, + () => ({ context: this.context, data: this.data, isConnected: this.isConnected }), + { + requestUpdate: () => this.requestUpdate(), + closeTransientUi: () => this.closeOpenDropdowns(), + }, + ); + this.subscriptions = new SubscriptionsController(this) + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.gateway.synchronize(gateway), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + ) + .watch( + () => this.context?.sessions, + (sessions, notify) => sessions.subscribe(notify), + ) + .watch( + () => this.context?.config, + (config, notify) => config.subscribe(() => notify()), ); - } - if ( - firstBind || - gatewayUrlChanged || - recoveryScope.changed || - recoveryScopeBecameReady || - becameConnected - ) { - if ( - this.pendingCloud.gatewayUrl && - (this.pendingCloud.gatewayUrl !== this.gatewayUrl || - this.pendingCloud.recoveryScope !== this.gatewayRecoveryScope) - ) { - this.pendingCloud.reset(); - this.submissionOutcomeUnknown = null; - } - if (connected && snapshot.client?.recoveryScopeReady) { - this.restorePendingCloudRecovery(this.gatewayUrl, this.gatewayRecoveryScope); - } - } - if (becameConnected || recoveryScope.changed) { - if (becameConnected) { - this.gatewayConnectionEpoch += 1; - this.retryPendingCatalogTarget(); - } - } - this.synchronizeIdentityPreferences(snapshot.selfUser?.id); - } - - private synchronizeIdentityPreferences(profileId: string | undefined) { - const client = this.gatewayConnected ? this.gatewayClient : null; - const advertised = - this.context && - isGatewayMethodAdvertised(this.context.gateway.snapshot, "users.prefs.get") === true && - isGatewayMethodAdvertised(this.context.gateway.snapshot, "users.prefs.set") === true; - const scope = - client && profileId && advertised ? `${this.gatewayConnectionEpoch}\0${profileId}` : "local"; - if (scope === this.preferenceScope) { - return; - } - this.preferenceScope = scope; - this.identityPreferences = {}; - if (!client || !profileId || !advertised) { - this.preferenceMode = "local"; - this.preferenceLoad = Promise.resolve(); - return; - } - this.preferenceMode = "loading"; - this.preferenceLoad = this.loadIdentityPreferences({ - client, - gatewayUrl: this.gatewayUrl, - scope, - }); - } - - private async loadIdentityPreferences(params: { - client: NonNullable; - gatewayUrl: string; - scope: string; - }): Promise { - try { - const result = await params.client.request("users.prefs.get", {}); - if (this.preferenceScope !== params.scope) { - return; - } - if (result.status !== "ok") { - this.preferenceMode = "local"; - return; - } - let preferences = decodeIdentityPreferences(result.entries); - const browserPreferences = loadBrowserPreferences(params.gatewayUrl); - if (result.entries[PREFS_MIGRATION_KEY] !== true) { - const missingBrowserPreferences = Object.fromEntries( - Object.entries(browserPreferences).filter( - ([agentId]) => !Object.hasOwn(preferences, agentId), - ), - ); - const migrationEntries = [ - ...Object.entries(encodeIdentityPreferences(missingBrowserPreferences)), - [PREFS_MIGRATION_KEY, true] as const, - ]; - let migrationFailed = false; - for (let offset = 0; offset < migrationEntries.length; offset += 32) { - const batch = Object.fromEntries(migrationEntries.slice(offset, offset + 32)); - let response: UsersPrefsSetResult; - try { - response = await params.client.request("users.prefs.set", { - entries: batch, - }); - } catch { - migrationFailed = true; - break; - } - if (this.preferenceScope !== params.scope) { - return; - } - if (response.status !== "ok") { - migrationFailed = true; - break; - } - Object.assign(preferences, decodeIdentityPreferences(batch)); - } - if (migrationFailed) { - preferences = { ...browserPreferences, ...preferences }; - } - } - this.identityPreferences = preferences; - this.preferenceMode = "remote"; - for (const [agentId, preference] of Object.entries(preferences)) { - replaceBrowserPreference(params.gatewayUrl, agentId, preference); - } - if (this.agentsHydrated) { - this.adoptAgentDefaults({ preserveSelectedAgent: true, preserveSelectedFolder: true }); - } - } catch { - if (this.preferenceScope === params.scope) { - this.preferenceMode = "local"; - } - } - } - - private invalidateGatewayDiscovery( - resetHostSelection: boolean, - submissionOutcome: SubmissionOutcomeReason, - ) { - this.nodesRequestToken += 1; - this.nodesHydrated = false; - this.gatewayName = ""; - this.cloudProfiles = []; - this.cloudProfilesReady = false; - this.resetCloudProfileRetry(); - this.branchesRequestToken += 1; - this.repository = { kind: "idle" }; - this.baseRef = ""; // Never carry a derived ref across a transport epoch. - this.agentsHydrated = false; - this.modelControl.invalidate(resetHostSelection); - this.attachmentDraft.abortReads(); - this.closeBrowser(); - this.cancelRestoredFolderValidation(); - this.gatewayApprovedWorkspaceRoots = []; - this.folderGatewayApproved = false; - this.resetProjectSearch(); - this.invalidateSubmission(submissionOutcome); - if (!resetHostSelection) { - return; - } - if (this.pendingCloud.sessionKey) { - // Keep the original Gateway identity so a failed teardown cannot hide a worker elsewhere. - this.pendingCloud.retryAllowed = false; - this.submissionOutcomeUnknown = submissionOutcome; - } - // A replacement client may target another Gateway. Keep the user's task, - // but retire every selection and discovery result owned by the old host. - this.agentId = ""; - this.agentSelectedByUser = false; - this.folder = ""; - this.projects = []; - this.projectRecents = undefined; - this.projectId = ""; - this.folderSelectedByUser = false; - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = false; - this.worktree = false; - this.visibility = "normal"; - this.worktreeName = ""; - this.baseRefEditGeneration += 1; - this.nodes = []; - this.execNode = ""; - this.cloudProfileId = ""; - this.error = null; - } - - private retryPendingCatalogTarget() { - if (this.catalogRetrying) { - return; - } - if ( - !this.gatewayConnected || - !catalog.isTarget(this.data) || - catalog.isResolvedTarget(this.data) - ) { - globalThis.clearTimeout(this.catalogRetryTimer); - this.catalogRetryTimer = undefined; - this.catalogRetryScope = ""; - this.catalogRetryAttempt = 0; - return; - } - const retryScope = `${this.gatewayConnectionEpoch}:${catalog.routeKey(this.data)}`; - if (this.catalogRetryScope !== retryScope) { - globalThis.clearTimeout(this.catalogRetryTimer); - this.catalogRetryTimer = undefined; - this.catalogRetryScope = retryScope; - this.catalogRetryAttempt = 0; - } - if (this.catalogRetryTimer || this.catalogRetryAttempt >= CATALOG_RETRY_DELAYS_MS.length) { - return; - } - const delayMs = CATALOG_RETRY_DELAYS_MS[this.catalogRetryAttempt]; - this.catalogRetryAttempt += 1; - this.catalogRetryTimer = globalThis.setTimeout(() => { - this.catalogRetryTimer = undefined; - if ( - this.catalogRetryScope !== retryScope || - !this.gatewayConnected || - !catalog.isTarget(this.data) || - catalog.isResolvedTarget(this.data) - ) { - return; - } - const revalidation = this.context?.revalidate("new-session"); - if (!revalidation) { - return; - } - void revalidation - .catch(() => undefined) - .then(() => this.updateComplete) - .then(() => this.retryPendingCatalogTarget()); - }, delayMs); } handleEvent(event: Event) { @@ -627,7 +182,6 @@ class NewSessionPage extends OpenClawLightDomElement { const restoreFocus = event.composedPath().includes(picker); keyEvent.preventDefault(); picker.open = false; - // Closing details does not move focus out of its now-hidden controls. if (restoreFocus) { picker.querySelector("summary")?.focus(); } @@ -642,8 +196,6 @@ class NewSessionPage extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); - // /new renders chat controls without ChatPane, so the route owns pointer - // and Escape light-dismissal for both picker popovers. document.addEventListener("keydown", this, true); document.addEventListener("pointerdown", this, true); } @@ -652,59 +204,40 @@ class NewSessionPage extends OpenClawLightDomElement { document.removeEventListener("keydown", this, true); document.removeEventListener("pointerdown", this, true); this.subscriptions.clear(); - // This invalidates submitRequestToken before payload release below, so a - // late sessions.create result cannot navigate with attachments we no longer own. - this.invalidateGatewayDiscovery( + this.gateway.invalidateDiscovery( true, - resolveSubmissionOutcomeReason({ - gatewayIdentityChanged: false, - cloudDraftOwned: Boolean(this.pendingCloud.sessionKey), - }), + this.submission.pendingCloud.sessionKey ? "cloud-interrupted" : "gateway-changed", ); - this.gatewaySource = null; - this.gatewayClient = null; - this.gatewayConnected = false; - this.gatewayConnectionEpoch = 0; - this.catalogRetryScope = ""; - this.catalogRetryAttempt = 0; - globalThis.clearTimeout(this.catalogRetryTimer); - this.catalogRetryTimer = undefined; - this.attachmentDraft.reset({ release: true }); - this.composerTextarea.disconnect(); - void this.gatewayNameTask.run([null, false, -1]); - void this.projectsTask.run([null, false, -1]); - void this.projectSearchTask.run([null, false, "", -1]); - void this.cloudProfileTask.run([null, -1, false, ""]); - this.resetCloudProfileRetry(); + this.gateway.disconnect(); + this.browser.disconnect(); + this.submission.disconnect(); super.disconnectedCallback(); } override updated() { - this.retryPendingCatalogTarget(); - this.modelControl.loadCatalogTargets( + this.gateway.retryPendingCatalogTarget(); + this.place.modelControl.loadCatalogTargets( this.context, - this.agentId, + this.place.agentId, this.context?.config.current.cliAgentsEnabled === true && !catalog.isTarget(this.data), ); const agentState = this.context?.agents.state; const agentsReady = Boolean( - this.gatewayConnected && - this.gatewayClient && + this.gateway.connected && + this.gateway.client && agentState?.connected && - agentState.client === this.gatewayClient && - this.agents().length > 0, + agentState.client === this.gateway.client && + this.place.agents().length > 0, ); const openKey = this.data ? catalog.routeKey(this.data) : catalog.routeKeyFromSearch(window.location.search); const resolvedAgentId = this.data?.agentId ?? ""; if (this.openedFor !== openKey) { - // Route changes reset every source-owned control. Only text typed after - // the URL changed belongs to the destination and may survive that reset. - const ownedMessage = this.messageOwnerKey === openKey ? this.message : ""; + const ownedMessage = this.messageOwnerKey === openKey ? this.submission.message : ""; this.openedFor = openKey; this.openedAgentId = resolvedAgentId; - this.agentsHydrated = agentsReady; + this.place.setAgentsHydrated(agentsReady); this.resetDraft(); if (ownedMessage) { this.setMessage(ownedMessage, openKey); @@ -712,1346 +245,55 @@ class NewSessionPage extends OpenClawLightDomElement { return; } if (this.openedAgentId !== resolvedAgentId) { - // The route named the target agent after the draft opened, so the page is - // still holding the fallback agent it adopted while the id was unknown. this.openedAgentId = resolvedAgentId; - this.agentsHydrated = false; + this.place.setAgentsHydrated(false); } - // A hard reload can land here before agents.list resolves. Once the list - // arrives, adopt only agent-derived defaults; a full reset would discard - // anything the user already typed while the list was loading. - if (!this.agentsHydrated && agentsReady) { - this.agentsHydrated = true; - this.adoptAgentDefaults({ preserveSelectedAgent: true, preserveSelectedFolder: true }); - } - } - - private readonly handleCatalogRetry = () => { - if ( - this.catalogRetrying || - !this.gatewayConnected || - !catalog.isTarget(this.data) || - catalog.isResolvedTarget(this.data) - ) { - return; - } - const revalidation = this.context?.revalidate("new-session"); - if (!revalidation) { - return; - } - globalThis.clearTimeout(this.catalogRetryTimer); - this.catalogRetryTimer = undefined; - this.catalogRetrying = true; - void revalidation - .catch(() => undefined) - .then(() => this.updateComplete) - .finally(() => { - this.catalogRetrying = false; - this.retryPendingCatalogTarget(); + if (!this.place.agentsHydrated && agentsReady) { + this.place.setAgentsHydrated(true); + this.place.adoptAgentDefaults({ + preserveSelectedAgent: true, + preserveSelectedFolder: true, }); - }; - - private agents() { - return listSelectableAgents(this.context?.agents.state.agentsList?.agents ?? []); - } - - private selectedAgent() { - const agentId = normalizeAgentId(this.agentId); - return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); - } - - private selectedProject() { - return this.projects.find((project) => project.id === this.projectId); - } - - private get projectSearchResult(): ProjectsSearchRemoteResult | null { - return this.projectSearchTask.status === TaskStatus.COMPLETE && - this.debouncedProjectQuery === this.projectQuery.trim() - ? (this.projectSearchTask.value ?? null) - : null; - } - - private get projectSearchLoading(): boolean { - return ( - this.debouncedProjectQuery.length >= 2 && - this.debouncedProjectQuery === this.projectQuery.trim() && - this.projectSearchTask.status === TaskStatus.PENDING - ); - } - - private get projectSearchError(): string | null { - if ( - this.projectSearchTask.status !== TaskStatus.ERROR || - this.debouncedProjectQuery !== this.projectQuery.trim() - ) { - return null; - } - const error = this.projectSearchTask.error; - return error instanceof Error ? error.message : String(error); - } - - private clearProjectSearchTimer() { - globalThis.clearTimeout(this.projectSearchTimer); - this.projectSearchTimer = undefined; - } - - private resetProjectSearch() { - this.clearProjectSearchTimer(); - this.projectCloneRequestToken += 1; - this.projectQuery = ""; - this.debouncedProjectQuery = ""; - this.projectCloneBusy = false; - this.projectCloneError = null; - } - - private changeProjectQuery(query: string) { - this.projectQuery = query; - this.projectCloneError = null; - this.clearProjectSearchTimer(); - this.debouncedProjectQuery = ""; - void this.projectSearchTask.run([null, false, "", this.gatewayConnectionEpoch]); - const normalized = query.trim(); - if ( - normalized.length < 2 || - projectCloneInput(normalized) || - !this.gatewayConnected || - !this.gatewayClient || - !this.context || - !canCallGatewayMethod(this.context.gateway.snapshot, "projects.searchRemote", "operator.read") - ) { - return; - } - const client = this.gatewayClient; - const connectionEpoch = this.gatewayConnectionEpoch; - this.projectSearchTimer = globalThis.setTimeout(() => { - this.projectSearchTimer = undefined; - if (client !== this.gatewayClient || connectionEpoch !== this.gatewayConnectionEpoch) { - return; - } - this.debouncedProjectQuery = normalized; - void this.projectSearchTask.run([client, true, normalized, connectionEpoch]); - }, PROJECT_SEARCH_DEBOUNCE_MS); - } - - private async addRemoteProject(gitUrl: string) { - const client = this.gatewayClient; - if ( - !client || - !this.gatewayConnected || - this.projectCloneBusy || - !this.context || - !canCallGatewayMethod(this.context.gateway.snapshot, "projects.add", "operator.write") - ) { - return; - } - const requestId = ++this.projectCloneRequestToken; - const connectionEpoch = this.gatewayConnectionEpoch; - this.projectCloneBusy = true; - this.projectCloneError = null; - try { - const project = await client.request( - "projects.add", - { gitUrl }, - { timeoutMs: null }, - ); - if ( - requestId !== this.projectCloneRequestToken || - client !== this.gatewayClient || - connectionEpoch !== this.gatewayConnectionEpoch - ) { - return; - } - await this.projectsTask.run([client, true, connectionEpoch]); - if ( - requestId !== this.projectCloneRequestToken || - client !== this.gatewayClient || - connectionEpoch !== this.gatewayConnectionEpoch - ) { - return; - } - this.selectProjectId(project.id); - this.closeBrowser(); - } catch (error) { - if (requestId === this.projectCloneRequestToken && client === this.gatewayClient) { - this.projectCloneError = error instanceof Error ? error.message : String(error); - } - } finally { - if (requestId === this.projectCloneRequestToken) { - this.projectCloneBusy = false; - } } } - private execNodes(): DraftNode[] { - return this.nodes.filter((node) => node.canExec); - } - - private isAdmin(): boolean { - return hasOperatorAdminAccess(this.context?.gateway.snapshot.hello?.auth ?? null); - } - - private canWrite(): boolean { - return hasOperatorWriteAccess(this.context?.gateway.snapshot.hello?.auth ?? null); - } - - private showStartInTerminal(): boolean { - const context = this.context; - return Boolean( - context && - catalog.isTarget(this.data) && - this.data?.startTerminal && - context.config.current.cliAgentsEnabled === true && - isTerminalAvailable( - context.gateway.snapshot, - context.config.current.terminalEnabled ?? false, - ), - ); - } - - private canStartAsDraft(): boolean { - return canStartSessionAsDraft({ - allowedVisibilities: this.context?.gateway.snapshot.hello?.policy?.allowedSessionVisibilities, - hasMultipleIdentities: - this.context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities, - }); - } - - private workspacePath(): string { - return normalizeOptionalString(this.selectedAgent()?.workspace) ?? ""; - } - - private knownWorkspaceRoots(): string[] { - const configuredWorkspace = this.workspacePath(); - return configuredWorkspace - ? [configuredWorkspace, ...this.gatewayApprovedWorkspaceRoots] - : this.gatewayApprovedWorkspaceRoots; - } - - private recordGatewayApprovedListing(listing: FsListDirResult) { - if (this.isAdmin()) { - return; - } - const roots = new Set(this.gatewayApprovedWorkspaceRoots); - roots.add(listing.path); - if (listing.parent) { - roots.add(listing.parent); - } - if (roots.size !== this.gatewayApprovedWorkspaceRoots.length) { - this.gatewayApprovedWorkspaceRoots = [...roots]; - } - } - - private usesCustomFolder(): boolean { - if (this.projectId) { - return false; - } - const folder = this.folder.trim(); - return Boolean(folder) && folder !== this.workspacePath(); - } - - private folderSubmissionMode(): "blocked" | "approved" | "server" { - if (this.projectId) { - return this.selectedProject() ? "approved" : "blocked"; - } - if (this.restoredFolderValidation !== "none") { - return "blocked"; - } - if ( - !this.usesCustomFolder() || - this.isAdmin() || - this.folderGatewayApproved || - isKnownWorkspacePath(this.knownWorkspaceRoots(), this.folder) - ) { - return "approved"; - } - // Free-typed paths still reach sessions.create so the Gateway can return - // the authoritative missing-scope error instead of the UI dead-ending. - return "server"; - } - - private buildCreateParamsForAccess( - visibility: NewSessionVisibility = this.visibility, - ): Record { - return buildDraftSessionCreateParams({ - agentId: this.agentId, - message: "", - model: this.modelControl.selected, - thinkingLevel: this.modelControl.thinkingLevel, - visibility, - projectId: this.projectId, - worktree: this.worktree, - baseRef: this.baseRef, - worktreeName: this.worktreeName, - cwd: this.folder, - workspace: this.workspacePath(), - execNode: this.execNode, - catalogId: this.data?.catalogId, - }); - } - - private submissionAccess( - createParams: Record = this.pendingCloud.createParams ?? - this.buildCreateParamsForAccess(), - ): SessionMethodAccess { - const gateway = this.context?.gateway.snapshot; - const pendingCloud = Boolean(this.pendingCloud.sessionKey); - if (!pendingCloud || this.pendingCloud.phase === "creating") { - const createAccess = readSessionMethodAccess(gateway, { - method: "sessions.create", - params: createParams, - }); - if (!createAccess.allowed || !this.cloudProfileForSubmission()) { - return createAccess; - } - } - return readSessionMethodAccess(gateway, { - method: "sessions.dispatch", - requiredScope: "operator.admin", - }); - } - - private submitDisabledReason(): string | undefined { - const access = this.submissionAccess(); - return access.allowed ? undefined : access.reason; - } - - private terminalStartAccess(): SessionMethodAccess { - const gateway = this.context?.gateway.snapshot; - const terminalAccess = readSessionMethodAccess(gateway, { - method: "sessions.catalog.startTerminal", - requiredScope: "operator.admin", - }); - if (!terminalAccess.allowed || !this.worktree) { - return terminalAccess; - } - return readSessionMethodAccess(gateway, { - method: "worktrees.create", - requiredScope: "operator.admin", - }); - } - - private terminalStartDisabledReason(): string | undefined { - const access = this.terminalStartAccess(); - return access.allowed ? undefined : access.reason; - } - - private incognitoDisabledReason(): string | undefined { - const access = readSessionMethodAccess(this.context?.gateway.snapshot, { - method: "sessions.create", - params: this.buildCreateParamsForAccess("incognito"), - }); - return access.allowed ? undefined : access.reason; - } - - private preference(): NewSessionPreference | null { - if (catalog.isTarget(this.data) || this.pendingCloud.sessionKey) { - return null; - } - return this.preferenceMode === "remote" - ? (this.identityPreferences[normalizeAgentId(this.agentId)] ?? null) - : loadNewSessionPreference(this.gatewayUrl, this.agentId); - } - - private persistPreference(patch: NewSessionPreference) { - if (catalog.isTarget(this.data) || this.pendingCloud.sessionKey) { - return; - } - const agentId = normalizeAgentId(this.agentId); - const nextPatch = { - workspace: this.workspacePath(), - ...patch, - }; - if (this.preferenceMode === "local") { - patchNewSessionPreference(this.gatewayUrl, agentId, nextPatch); - return; - } - const scope = this.preferenceScope; - const client = this.gatewayClient; - const gatewayUrl = this.gatewayUrl; - const write = async () => { - await this.preferenceLoad; - if (!client || this.preferenceScope !== scope) { - return; - } - if (this.preferenceMode === "local") { - patchNewSessionPreference(gatewayUrl, agentId, nextPatch); - return; - } - const next = { ...this.identityPreferences[agentId], ...nextPatch }; - try { - const result = await client.request("users.prefs.set", { - entries: encodeIdentityPreferences({ [agentId]: next }), - }); - if (result.status !== "ok" || this.preferenceScope !== scope) { - return; - } - this.identityPreferences = { ...this.identityPreferences, [agentId]: next }; - replaceBrowserPreference(gatewayUrl, agentId, next); - } catch { - // Gateway state is authoritative for identified users; retain the last mirrored value. - } - }; - this.preferenceWrite = this.preferenceWrite.then(write, write); - } - - private cancelRestoredFolderValidation() { - this.restoredFolderValidationToken += 1; - this.restoredFolderValidation = "none"; - } - - private restoreWorkspaceFolder() { - this.restoredFolderValidation = "none"; - this.folderGatewayApproved = false; - if (this.error === t("newSession.browserLoadFailed")) { - this.error = null; - } - this.folder = this.workspacePath(); - this.worktree = false; - this.preferredWorktreeRestore = false; - this.persistPreference({ folder: this.folder, worktree: false }); - this.maybeLoadBranches(); - } - - private validateRestoredFolder(folder: string) { - const snapshot = this.context?.gateway.snapshot; - const client = snapshot?.client; - if (snapshot?.phase !== "connected" || !client) { - this.restoreWorkspaceFolder(); - return; - } - const requestId = ++this.restoredFolderValidationToken; - this.restoredFolderValidation = "checking"; - void client - .request("fs.listDir", { path: folder }) - .then((result) => { - if ( - requestId !== this.restoredFolderValidationToken || - this.folderSelectedByUser || - this.folder !== folder - ) { - return; - } - this.recordGatewayApprovedListing(result); - this.folderGatewayApproved = !this.isAdmin(); - this.restoredFolderValidation = "none"; - if (this.error === t("newSession.browserLoadFailed")) { - this.error = null; - } - this.maybeLoadBranches(); - }) - .catch((error: unknown) => { - if ( - requestId !== this.restoredFolderValidationToken || - this.folderSelectedByUser || - this.folder !== folder - ) { - return; - } - if (!this.isAdmin() || isMissingRestoredFolderError(error)) { - this.restoreWorkspaceFolder(); - return; - } - this.restoredFolderValidation = "failed"; - this.error = t("newSession.browserLoadFailed"); - }); - } - - private adoptAgentDefaults( - options: { preserveSelectedAgent?: boolean; preserveSelectedFolder?: boolean } = {}, + private invalidateGatewayDiscovery( + resetHostSelection: boolean, + submissionOutcome: SubmissionOutcomeReason, ) { - const agents = this.agents(); - const configuredDefault = this.context?.agents.state.agentsList?.defaultId; - const fallback = agents.some((agent) => agent.id === configuredDefault) - ? (configuredDefault ?? "main") - : (agents[0]?.id ?? "main"); - const keepSelectedAgent = - options.preserveSelectedAgent && this.agentSelectedByUser && Boolean(this.selectedAgent()); - if (!keepSelectedAgent) { - this.agentId = catalog.resolveAgentId(this.data, agents, fallback); - this.agentSelectedByUser = false; + this.place.invalidateGatewayDiscovery(resetHostSelection); + this.submission.attachmentDraft.abortReads(); + this.submission.invalidate(submissionOutcome); + if (resetHostSelection && this.submission.pendingCloud.sessionKey) { + this.submission.markPendingCloudUnavailable(submissionOutcome); } - const preference = this.preference(); - const keepSelectedFolder = options.preserveSelectedFolder && this.folderSelectedByUser; - // A node cwd belongs to node discovery, and a locked cloud-recovery draft - // shows its staged repo; neither may be replaced by a workspace refresh. - if (!this.execNode && !keepSelectedFolder && !this.pendingCloud.sessionKey) { - const workspace = this.workspacePath(); - const storedFolder = preference?.folder ?? ""; - // An old agent workspace path is not a custom folder. If the configured - // workspace moved, use the current value instead of reviving the stale path. - const storedWorkspaceMoved = - Boolean(storedFolder) && - storedFolder === preference?.workspace && - preference.workspace !== workspace; - const storedFolderUsable = Boolean(storedFolder) && !storedWorkspaceMoved; - this.folder = storedFolderUsable ? storedFolder : workspace; - this.folderGatewayApproved = false; - this.folderSelectedByUser = false; - this.preferredWorktreeRestore = preference?.worktree === true; - this.worktreeSelectedByUser = false; - if (storedWorkspaceMoved) { - this.persistPreference({ folder: workspace }); - } - } - if (keepSelectedFolder && !this.execNode && !this.pendingCloud.sessionKey && this.agentId) { - // A folder picked before agents.list resolves has no stable preference - // owner. Persist it only after the final agent id is known. - this.persistPreference({ folder: this.folder, worktree: this.worktree }); - } - void this.loadNodes(); - this.modelControl.load(this.context, this.agentId, !catalog.isTarget(this.data), { - agent: this.selectedAgent(), - preference, - }); - if ( - !this.folderSelectedByUser && - this.folder !== this.workspacePath() && - !this.execNode && - !this.pendingCloud.sessionKey - ) { - this.validateRestoredFolder(this.folder); - } else { - this.cancelRestoredFolderValidation(); - this.maybeLoadBranches(); + if (resetHostSelection) { + this.submission.clearError(); } } private resetDraft() { - const preservePendingCloud = Boolean(this.pendingCloud.sessionKey); - this.invalidateSubmission(); - this.submissionOutcomeUnknown = preservePendingCloud - ? (this.submissionOutcomeUnknown ?? "cloud-interrupted") - : null; - this.agentSelectedByUser = false; - this.folder = ""; - this.projectId = ""; - this.resetProjectSearch(); - this.folderSelectedByUser = false; - this.folderGatewayApproved = false; - this.gatewayApprovedWorkspaceRoots = []; - this.cancelRestoredFolderValidation(); - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = false; - this.worktree = false; - this.visibility = "normal"; - this.worktreeName = ""; - this.baseRef = ""; - this.repository = { kind: "idle" }; - this.execNode = ""; - this.modelControl.reset(); - this.attachmentDraft.reset({ release: true }); - this.cloudProfileId = ""; - if (preservePendingCloud) { - if (!this.pendingCloud.restored) { - this.pendingCloud.retryAllowed = false; - } - this.agentId = this.pendingCloud.agentId; - this.cloudProfileId = this.pendingCloud.profileId; - this.worktree = true; - this.visibility = this.pendingCloud.createParams?.incognito === true ? "incognito" : "normal"; - // Show the staged repo (not the agent workspace) while the draft is locked. - this.folder = this.pendingCloud.createParams?.cwd ?? ""; - this.pendingCloud.restored = false; - this.setMessage(this.pendingCloud.message); - this.attachmentDraft.replace(restoreChatApiAttachments(this.pendingCloud.attachments)); - } else { - this.clearPendingCloudRecovery(); - this.setMessage(""); - } - this.error = null; - this.placePopoverHiding = false; + this.place.resetDraft(); + this.submission.resetDraft(); + this.messageOwnerKey = catalog.routeKey(this.data); + this.browser.clearPopoverHiding(); this.closeAgentDropdown(); - this.closeBrowser(); - this.adoptAgentDefaults(); + this.browser.close(); + this.place.adoptAgentDefaults(); void this.updateComplete.then(() => { this.querySelector(".new-session-page__message")?.focus(); }); } private setMessage(message: string, ownerKey = catalog.routeKey(this.data)) { - this.message = message; + this.submission.setMessage(message); this.messageOwnerKey = ownerKey; } private setMessageFromUser(message: string) { - // History changes before an async route loader settles. Input accepted from - // the retained composer belongs to the browser destination, not stale data. this.setMessage(message, catalog.routeKeyFromSearch(window.location.search)); } - private invalidateSubmission(outcomeUnknown: SubmissionOutcomeReason | null = null) { - this.submitRequestToken += 1; - if (outcomeUnknown && this.submitting) { - this.submissionOutcomeUnknown = outcomeUnknown; - } - this.submitting = false; - } - - private clearPendingCloudRecovery() { - this.pendingCloud.clear(); - this.submissionOutcomeUnknown = null; - } - - private restorePendingCloudRecovery(gatewayUrl: string, recoveryScope: string) { - const recovery = this.pendingCloud.restore(gatewayUrl, recoveryScope); - if (!recovery) { - return; - } - this.agentId = recovery.agentId; - this.cloudProfileId = recovery.profileId; - this.worktree = true; - this.visibility = recovery.createParams?.incognito === true ? "incognito" : "normal"; - // Show the staged repo (not the agent workspace) while the draft is locked. - this.folder = recovery.createParams?.cwd ?? ""; - this.folderGatewayApproved = false; - this.setMessage(recovery.message); - this.attachmentDraft.replace(restoreChatApiAttachments(recovery.attachments)); - } - - private async loadNodes() { - const requestId = ++this.nodesRequestToken; - this.nodesHydrated = false; - const snapshot = this.context?.gateway.snapshot; - const client = snapshot?.client; - if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) { - this.nodes = []; - this.nodesHydrated = true; - return; - } - try { - const result = await client.request<{ nodes?: unknown }>("node.list", {}); - if (requestId !== this.nodesRequestToken) { - return; - } - const nodes = readDraftNodes(result?.nodes); - this.nodes = nodes; - this.nodesHydrated = true; - if (this.execNode && !nodes.some((node) => node.nodeId === this.execNode && node.canExec)) { - // A reconnect can remove a device. Its cwd is not meaningful on the - // Gateway, so fall back to the selected agent's workspace as one unit. - this.execNode = ""; - this.folder = this.workspacePath(); - this.folderSelectedByUser = false; - this.folderGatewayApproved = false; - this.worktree = false; - this.worktreeName = ""; - this.closeBrowser(); - this.maybeLoadBranches(); - } - } catch { - if (requestId === this.nodesRequestToken) { - this.nodes = []; - this.nodesHydrated = true; - } - } - } - - private maybeLoadBranches() { - // Repository capability and branch data belong to one Gateway folder. - // Reset them together so a previous checkout can never leak into create params. - const requestId = ++this.branchesRequestToken; - const restoreWorktree = this.preferredWorktreeRestore && !this.worktreeSelectedByUser; - const baseRefEditGeneration = this.baseRefEditGeneration; - this.repository = { kind: "idle" }; - this.baseRef = ""; - const selectedProject = this.selectedProject(); - if (this.execNode) { - this.preferredWorktreeRestore = false; - return; - } - if (selectedProject && !selectedProject.repoRoot) { - this.preferredWorktreeRestore = false; - return; - } - const repoRoot = selectedProject?.repoRoot ?? (this.folder.trim() || this.workspacePath()); - const agent = this.selectedAgent(); - const usesWorkspace = !selectedProject && repoRoot === this.workspacePath(); - if (!repoRoot) { - this.preferredWorktreeRestore = false; - return; - } - if (usesWorkspace && agent?.workspaceGit !== true) { - this.repository = { kind: "direct", repoRoot }; - const rejectedWorktree = !this.cloudProfileId && (this.worktree || restoreWorktree); - if (!this.cloudProfileId) { - this.worktree = false; - } - this.preferredWorktreeRestore = false; - if (rejectedWorktree) { - this.persistPreference({ worktree: false }); - } - return; - } - const snapshot = this.context?.gateway.snapshot; - const client = snapshot?.client; - if (snapshot?.phase !== "connected" || !client) { - this.preferredWorktreeRestore = false; - return; - } - this.repository = { kind: "checking", repoRoot }; - void client - .request("worktrees.branches", { - repoRoot, - includeRepositoryStatus: true, - }) - .then((result) => { - if (requestId !== this.branchesRequestToken) { - return; - } - if (result?.repositoryStatus !== "git") { - this.repository = { - kind: result?.repositoryStatus === "not_git" ? "direct" : "unavailable", - repoRoot, - }; - if (result?.repositoryStatus === "not_git") { - const rejectedWorktree = !this.cloudProfileId && (this.worktree || restoreWorktree); - if (!this.cloudProfileId) { - this.worktree = false; - } - if (rejectedWorktree) { - this.persistPreference({ worktree: false }); - } - } else if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) { - // An inconclusive lookup cannot disprove a stored worktree choice, but - // it may only be restored while the toggle stays usable: an unavailable - // repository disables that control, and a box the user cannot clear - // would strand the draft behind a permanently disabled submit. - this.worktree = true; - } - this.preferredWorktreeRestore = false; - return; - } - this.repository = { - kind: "git", - repoRoot, - branches: result.branches, - ...(result.defaultBranch ? { defaultBranch: result.defaultBranch } : {}), - ...(result.headBranch ? { headBranch: result.headBranch } : {}), - }; - if (restoreWorktree && !this.worktreeSelectedByUser && !this.execNode) { - this.worktree = true; - } - this.preferredWorktreeRestore = false; - // Discovery supplies a default only while the field is untouched; - // a user edit made during the request remains authoritative. - if (baseRefEditGeneration === this.baseRefEditGeneration) { - this.baseRef = result.defaultBranch ?? result.headBranch ?? ""; - } - }) - .catch(() => { - if (requestId !== this.branchesRequestToken) { - return; - } - this.repository = { kind: "unavailable", repoRoot }; - if (restoreWorktree && !this.worktreeSelectedByUser && this.worktreeAvailable()) { - this.worktree = true; - } - this.preferredWorktreeRestore = false; - }); - } - - private worktreeAvailable(): boolean { - if (this.execNode) { - return false; - } - if (this.selectedProject()?.repoRoot) { - return true; - } - if (this.repository.kind === "git") { - return true; - } - return ( - this.repository.kind === "unavailable" && - this.repository.repoRoot === this.workspacePath() && - this.selectedAgent()?.workspaceGit === true - ); - } - - private cloudProfileForSubmission(): string { - return this.pendingCloud.sessionKey ? this.pendingCloud.profileId : this.cloudProfileId; - } - - private cloudRuntimeUnsupportedReason(): string | undefined { - const runtime = this.modelControl.resolveAgentRuntimeId({ - agent: this.selectedAgent(), - context: this.context, - }); - return runtime && runtime !== "openclaw" - ? t("newSession.cloudRequiresOpenClawRuntime", { runtime }) - : undefined; - } - - private cloudDisabledReason(): string | undefined { - const runtimeReason = this.cloudRuntimeUnsupportedReason(); - if (runtimeReason) { - return runtimeReason; - } - if (this.repository.kind === "checking") { - return t("newSession.checkingGit"); - } - if (this.repository.kind === "unavailable" && !this.worktreeAvailable()) { - return t("newSession.gitCheckUnavailable"); - } - return this.worktreeAvailable() ? undefined : t("newSession.cloudRequiresWorktree"); - } - - private canSubmit(kind: "session" | "terminal" = "session"): boolean { - const pendingCloud = Boolean(this.pendingCloud.sessionKey); - const cloudProfileId = this.cloudProfileForSubmission(); - const message = pendingCloud ? this.pendingCloud.message : this.message.trim(); - const hasAttachments = pendingCloud - ? Boolean(this.pendingCloud.attachments?.length) - : this.attachmentDraft.attachments.length > 0; - const gateway = this.context?.gateway; - if ( - this.submitting || - this.preferenceMode === "loading" || - this.requiresModelSetup() || - this.attachmentDraft.pendingReads > 0 || - (!pendingCloud && this.submissionOutcomeUnknown) || - (kind === "session" && !message && !hasAttachments) || - gateway?.snapshot.phase !== "connected" || - !gateway.snapshot.client - ) { - return false; - } - const access = kind === "terminal" ? this.terminalStartAccess() : this.submissionAccess(); - if (!access.allowed) { - return false; - } - if (this.folderSubmissionMode() === "blocked") { - return false; - } - // Stored model and worktree choices are provisional until their current - // Gateway metadata confirms they still exist. Do not let a fast submit - // silently replace either preference with the server default. - if (this.modelControl.isRestoringPreference() || this.preferredWorktreeRestore) { - return false; - } - if (pendingCloud) { - return Boolean( - this.pendingCloud.retryAllowed && - gateway.snapshot.client.recoveryScopeReady && - cloudProfileId && - this.pendingCloud.agentId && - this.pendingCloud.gatewayUrl === gateway.connection.gatewayUrl && - this.pendingCloud.recoveryScope === gateway.snapshot.client?.recoveryScope && - this.isAdmin(), - ); - } - // Pre-hydration the selection is a provisional fallback; submitting then - // would create the session under the wrong agent. - if (this.agents().length === 0) { - return false; - } - if (!catalog.allowsSelectedAgent(this.data, this.selectedAgent())) { - return false; - } - if ( - this.execNode && - (!this.nodesHydrated || !this.execNodes().some((node) => node.nodeId === this.execNode)) - ) { - return false; - } - if ( - cloudProfileId && - (!this.isAdmin() || - !gateway.snapshot.client.recoveryScope || - !gateway.snapshot.client.recoveryScopeReady || - !this.cloudProfilesReady || - this.cloudProfileTask.status === TaskStatus.PENDING || - !this.worktree || - !this.cloudProfiles.some((profile) => profile.id === cloudProfileId) || - Boolean(this.cloudRuntimeUnsupportedReason())) - ) { - return false; - } - if (this.execNode && this.worktree) { - return false; - } - if (this.worktree && !this.worktreeAvailable()) { - return false; - } - if (this.worktree && !isWorktreeNameValid(this.worktreeName)) { - return false; - } - if (kind === "terminal" && !(this.folder.trim() || this.workspacePath())) { - return false; - } - return true; - } - - private requiresModelSetup(): boolean { - const selectedAgent = this.selectedAgent(); - return requiresChatModelSetup({ - catalog: - catalog.isTarget(this.data) || - Boolean(this.cloudProfileId) || - Boolean(this.pendingCloud.sessionKey), - connected: this.gatewayConnected, - agentsLoaded: this.context?.agents.state.agentsList !== null, - selectedAgentFound: selectedAgent !== undefined, - agentModel: selectedAgent?.model?.primary, - }); - } - - private closeOpenDropdowns() { - for (const dropdown of this.querySelectorAll( - "wa-dropdown[open]", - )) { - dropdown.open = false; - } - } - - private async submit() { - const context = this.context; - if (!context || !this.canSubmit()) { - return; - } - const pendingCloud = Boolean(this.pendingCloud.sessionKey); - const message = pendingCloud ? this.pendingCloud.message : this.message.trim(); - const attachments = this.attachmentDraft.attachments; - const apiAttachments = pendingCloud - ? this.pendingCloud.attachments - : buildChatApiAttachments(attachments); - const submissionAgentId = pendingCloud - ? this.pendingCloud.agentId - : normalizeAgentId(this.agentId); - const submissionGatewayUrl = pendingCloud - ? this.pendingCloud.gatewayUrl - : context.gateway.connection.gatewayUrl; - const submissionClient = context.gateway.snapshot.client; - if (!submissionClient || !context.gateway.snapshot.hello) { - return; - } - const submissionRecoveryScope = pendingCloud - ? this.pendingCloud.recoveryScope - : submissionClient.recoveryScope; - const requestId = ++this.submitRequestToken; - const submittedAt = Date.now(); - this.submitting = true; - this.error = null; - // Retire hidden pickers before their late requests can mutate this submitted draft. - this.closeBrowser(); - this.closeOpenDropdowns(); - try { - const cloudProfileId = this.cloudProfileForSubmission(); - // Draft mode can go stale if sharing policy changed since it was selected. - const draftRetired = this.visibility === "draft" && !this.canStartAsDraft(); - const createParams = buildDraftSessionCreateParams({ - agentId: this.agentId, - message: cloudProfileId ? "" : message, - model: this.modelControl.selected, - thinkingLevel: this.modelControl.thinkingLevel, - visibility: draftRetired ? "normal" : this.visibility, - attachments: cloudProfileId ? undefined : apiAttachments, - projectId: this.projectId, - worktree: this.worktree, - baseRef: this.baseRef, - worktreeName: this.worktreeName, - cwd: this.folder, - workspace: this.workspacePath(), - execNode: this.execNode, - catalogId: this.data?.catalogId, - }); - const cloudCreateParams = cloudProfileId - ? pendingCloud - ? this.pendingCloud.createParams - : this.pendingCloud.stageCreate({ - agentId: submissionAgentId, - profileId: cloudProfileId, - message, - attachments: apiAttachments, - gatewayUrl: submissionGatewayUrl, - recoveryScope: submissionRecoveryScope, - createParams, - persistent: this.visibility !== "incognito", - }) - : undefined; - const requestAccess = this.submissionAccess(cloudCreateParams ?? createParams); - if (!requestAccess.allowed) { - this.error = requestAccess.reason; - return; - } - if (cloudProfileId && !pendingCloud && !cloudCreateParams) { - this.error = t("newSession.cloudStartFailed", { - error: "cloud recovery storage is unavailable", - }); - return; - } - const submissionCloudRecovery = cloudProfileId ? this.pendingCloud.capture() : null; - if (cloudProfileId && !submissionCloudRecovery) { - this.error = t("newSession.cloudStartFailed", { - error: "cloud recovery storage is unavailable", - }); - return; - } - const recoveryOwnerKey = submissionCloudRecovery?.sessionKey ?? ""; - const ownsSubmissionRecovery = () => - this.pendingCloud.owns(submissionGatewayUrl, submissionRecoveryScope, recoveryOwnerKey); - const isSubmissionLifecycleCurrent = () => - this.isConnected && - submissionClient.recoveryScopeReady && - requestId === this.submitRequestToken && - this.gatewayClient === submissionClient && - this.gatewayUrl === submissionGatewayUrl && - this.gatewayRecoveryScope === submissionRecoveryScope; - const result = - pendingCloud && this.pendingCloud.phase !== "creating" - ? { key: this.pendingCloud.sessionKey, initialRun: { status: "idle" as const } } - : await context.sessions.createResult(cloudCreateParams ?? createParams, { - reconciliation: "background", - }); - if (requestId !== this.submitRequestToken && !cloudProfileId) { - return; - } - if (!result) { - if (requestId !== this.submitRequestToken) { - return; - } - this.error = context.sessions.state.error ?? t("newSession.createFailed"); - return; - } - if (cloudProfileId && submissionCloudRecovery) { - if ( - submissionCloudRecovery.phase === "creating" && - (!isSubmissionLifecycleCurrent() || !ownsSubmissionRecovery()) - ) { - const cleanupError = await deleteCloudDraftSession( - submissionClient, - result.key, - submissionAgentId, - ); - if (cleanupError) { - this.pendingCloud.promoteToDispatching(result.key); - this.pendingCloud.retryAllowed = true; - this.error = t("newSession.cloudStartFailed", { error: cleanupError }); - } else { - this.clearPendingCloudRecovery(); - } - return; - } - if ( - submissionCloudRecovery.phase === "creating" && - isSubmissionLifecycleCurrent() && - ownsSubmissionRecovery() - ) { - if (!this.pendingCloud.promoteToDispatching(result.key)) { - this.error = t("newSession.cloudStartFailed", { - error: "cloud recovery storage is unavailable", - }); - return; - } - } - const recovery = this.pendingCloud.capture(); - if (!recovery || recovery.phase === "creating") { - this.error = t("newSession.cloudStartFailed", { - error: "cloud recovery storage is unavailable", - }); - return; - } - if (requestId !== this.submitRequestToken) { - return; - } - context.cloudStartup.start({ - recovery, - persistRecovery: this.pendingCloud.persistent, - recovering: pendingCloud, - createdAt: submittedAt, - }); - if ( - requestId !== this.submitRequestToken || - !isSubmissionLifecycleCurrent() || - !this.pendingCloud.owns( - submissionGatewayUrl, - submissionRecoveryScope, - recovery.sessionKey, - ) - ) { - return; - } - // The coordinator captured durable attachment bytes and recovery identity. - // Release only this route's draft before navigation unmounts it. - this.pendingCloud.reset(); - this.attachmentDraft.clearAfterSubmit(true); - selectApplicationSession({ - selection: context.agentSelection, - gateway: context.gateway, - sessionKey: result.key, - agentId: submissionAgentId, - }); - context.navigate( - "chat", - sessionNavigationTarget({ - context, - face: "chat", - sessionKey: result.key, - agentId: this.agentId, - }).options, - ); - return; - } - if (requestId !== this.submitRequestToken) { - return; - } - const handedOffAttachments = - result.initialRun.status === "rejected" && - retainRejectedInitialTurn({ - agentId: this.agentId, - attachments, - context, - error: result.initialRun.error, - message, - sessionKey: result.key, - }); - if (result.initialRun.status === "started") { - prepareInitialUserMessageHandoff( - context.initialUserMessage, - result.key, - { - text: message, - attachments, - createdAt: submittedAt, - }, - submissionClient, - { - runId: result.initialRun.runId, - messageSeq: result.initialRun.messageSeq, - }, - ); - } - this.attachmentDraft.clearAfterSubmit(!handedOffAttachments); - if (requestId !== this.submitRequestToken) { - return; - } - selectApplicationSession({ - selection: context.agentSelection, - gateway: context.gateway, - sessionKey: result.key, - agentId: submissionAgentId, - }); - context.navigate( - "chat", - sessionNavigationTarget({ - context, - face: "chat", - sessionKey: result.key, - agentId: this.agentId, - }).options, - ); - } finally { - if (requestId === this.submitRequestToken) { - this.submitting = false; - } - } - } - - private async startInTerminal() { - const context = this.context; - const client = context?.gateway.snapshot.client; - const catalogId = this.data?.catalogId.trim() ?? ""; - const agentId = normalizeAgentId(this.agentId); - if (!context || !client || !catalogId || !agentId || !this.canSubmit("terminal")) { - return; - } - const requestId = ++this.submitRequestToken; - const initialMessage = this.message.trim(); - this.submitting = true; - this.error = null; - this.closeBrowser(); - this.closeOpenDropdowns(); - try { - let cwd = this.folder.trim() || this.workspacePath(); - if (this.worktree) { - const created = await createManagedWorktree(client, { - repoRoot: cwd, - name: this.worktreeName, - baseRef: this.baseRef, - }); - if (requestId !== this.submitRequestToken || this.gatewayClient !== client) { - return; - } - cwd = created.path; - } - const result = await client.request( - "sessions.catalog.startTerminal", - { - catalogId, - ...(this.execNode ? { hostId: `node:${this.execNode}` } : {}), - agentId, - cwd, - ...(initialMessage ? { initialMessage } : {}), - }, - ); - if (requestId !== this.submitRequestToken || this.gatewayClient !== client) { - return; - } - this.setMessage(""); - openTerminalSessionInTerminal(result.sessionId); - } catch (error) { - if (requestId === this.submitRequestToken && this.gatewayClient === client) { - this.error = error instanceof Error ? error.message : String(error); - } - } finally { - if (requestId === this.submitRequestToken) { - this.submitting = false; - } - } - } - - private selectAgentId(agentId: string) { - if (this.submitting || this.pendingCloud.sessionKey || catalog.isTarget(this.data)) { - return; - } - // Re-picking the checked agent must not reset the draft (the native - // select never fired change for the same option). - if (normalizeAgentId(agentId) === normalizeAgentId(this.agentId)) { - return; - } - this.agentId = normalizeAgentId(agentId); - this.cancelRestoredFolderValidation(); - this.modelControl.reset(); - this.error = null; - this.agentSelectedByUser = true; - this.folderSelectedByUser = false; - this.folderGatewayApproved = false; - this.gatewayApprovedWorkspaceRoots = []; - this.projectId = ""; - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = false; - this.cloudProfileId = ""; - this.worktree = false; - this.worktreeName = ""; - this.closeBrowser(); - if (this.execNode) { - // Node cwd choices are agent-scoped draft state; switching agents must - // not carry the previous agent's remote path into the next session. - this.folder = ""; - } - this.adoptAgentDefaults({ preserveSelectedAgent: true }); - } - - /** - * Loaded branch data already covers the effective Gateway repo selection. - * Branch data is always Gateway-owned: maybeLoadBranches clears and never - * requests while a node is selected, so a path match cannot cross hosts. - */ - private branchesMatchCurrentRepo(): boolean { - if (this.execNode || this.repository.kind === "idle") { - return false; - } - const repoRoot = this.folder.trim() || this.workspacePath(); - return this.repository.repoRoot === repoRoot; - } - - private applyFolder(folder: string, execNode = this.execNode, gatewayApproved = false) { - if (this.submitting || this.pendingCloud.sessionKey) { - return; - } - this.execNode = execNode; - this.projectId = ""; - this.cancelRestoredFolderValidation(); - if (execNode) { - // Node sessions run on that device; a cloud worker cannot sync a node path. - this.cloudProfileId = ""; - } - this.error = null; - this.folder = folder.trim(); - this.folderGatewayApproved = gatewayApproved && !execNode && !this.isAdmin(); - this.folderSelectedByUser = true; - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = true; - if (this.execNode) { - this.worktree = false; - } else if (!this.cloudProfileId) { - // A newly selected Gateway folder starts direct. Git capability discovery - // may reveal the optional managed-worktree control afterward. - this.worktree = false; - } - this.worktreeName = ""; - if (!this.execNode && this.agentsHydrated) { - this.persistPreference({ folder: this.folder, worktree: this.worktree }); - } - this.maybeLoadBranches(); - } - - private selectProjectId(projectId: string) { - if (this.submitting || this.pendingCloud.sessionKey) { - return; - } - const project = this.projects.find((candidate) => candidate.id === projectId); - if (!project) { - return; - } - this.cancelRestoredFolderValidation(); - this.resetProjectSearch(); - this.projectId = project.id; - this.execNode = ""; - this.cloudProfileId = ""; - this.error = null; - this.folderSelectedByUser = false; - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = true; - this.worktree = false; - this.worktreeName = ""; - this.maybeLoadBranches(); - } - - private selectExecNode(execNode: string) { - if (this.submitting || this.pendingCloud.sessionKey) { - return; - } - if (execNode === this.execNode && !this.cloudProfileId) { - return; - } - // Turning a cloud selection back into a plain Gateway session keeps the - // picked repo; only a host change retires the folder path. - const keepGatewayFolder = !execNode && !this.execNode; - this.cancelRestoredFolderValidation(); - const keepWorktree = keepGatewayFolder && this.worktree && this.worktreeAvailable(); - this.execNode = execNode; - this.cloudProfileId = ""; - if (!keepGatewayFolder) { - // Folder paths belong to one host; never carry a Gateway or node path to another host. - this.folder = execNode ? "" : this.workspacePath(); - this.folderSelectedByUser = false; - this.folderGatewayApproved = false; - this.projectId = ""; - } - this.worktree = keepWorktree; - this.closeBrowser(); - if (!this.branchesMatchCurrentRepo()) { - this.maybeLoadBranches(); - } - } - - private selectCloudProfile(profileId: string) { - if ( - this.submitting || - this.pendingCloud.sessionKey || - !this.worktreeAvailable() || - !this.cloudProfiles.some((profile) => profile.id === profileId) - ) { - return; - } - // worktreeAvailable() is false for node targets, so this transition always - // starts from a Gateway selection and the folder is a Gateway path. It - // stays selected: its repo is what the managed worktree checks out and the - // dispatch tunnel syncs to the cloud worker. - this.cloudProfileId = profileId; - this.projectId = ""; - this.error = null; - this.worktree = true; - this.closeBrowser(); - if (!this.branchesMatchCurrentRepo()) { - this.maybeLoadBranches(); - } - } - - private browseAvailable(): boolean { - return this.gatewayConnected && (this.isAdmin() || Boolean(this.workspacePath())); - } - private closeAgentDropdown() { const dropdown = this.querySelector( ".new-session-page__select--agent wa-dropdown", @@ -2061,217 +303,55 @@ class NewSessionPage extends OpenClawLightDomElement { } } - private closeBrowser() { - this.browserRequestToken += 1; - this.browserLoading = false; - this.browserError = null; - this.browserListing = null; - this.browserTarget = null; - this.browserProjectPath = null; - this.browserRegistering = false; - this.browserPathDraft = ""; - this.placePopoverOpen = false; - const popover = this.querySelector( - ".new-session-page__place-popover", - ); - if (popover) { - popover.open = false; + private closeOpenDropdowns() { + for (const dropdown of this.querySelectorAll( + "wa-dropdown[open]", + )) { + dropdown.open = false; } } - private guardPopoverTransition(event: Event, hiding: boolean) { - if (!hiding) { - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - } - - private restorePopoverTrigger(id: string, popoverSelector: string) { - const active = this.ownerDocument.activeElement; - const popover = this.querySelector(popoverSelector); - // Light-dismissal may already have moved focus to another control. Only - // recover when focus stayed in the closing popover or fell back to body. - if (active && active !== this.ownerDocument.body && !popover?.contains(active)) { - return; - } - this.querySelector(`#${id}`)?.focus(); - } - - private showBrowserRoot() { - this.browserRequestToken += 1; - this.browserLoading = false; - this.browserError = null; - this.browserListing = null; - this.browserTarget = null; - this.browserProjectPath = null; - this.browserRegistering = false; - this.browserPathDraft = ""; - } - - /** Use applies the live path; empty means host default, null disables. */ - private usableBrowserPath(): string | null { - const draft = this.browserPathDraft.trim(); - if (draft.length === 0) { - return ""; - } - return isAbsolutePath(draft) ? draft : null; - } - - private selectBrowserTarget(target: BrowserTarget) { - const folder = this.folder.trim(); - const matchesCurrentTarget = target.nodeId === this.execNode; - const path = matchesCurrentTarget && isAbsolutePath(folder) ? folder : undefined; - this.browserTarget = target; - this.loadBrowser(path); - } - - private loadBrowser(path: string | undefined) { - const snapshot = this.context?.gateway.snapshot; - const client = snapshot?.client; - const target = this.browserTarget; - if (snapshot?.phase !== "connected" || !client || !target) { - return; - } - // Exec-only nodes still accept a typed cwd; never probe an unsupported fs.listDir. - const targetNode = this.nodes.find((node) => node.nodeId === target.nodeId); - if (targetNode?.canExec && !targetNode.canBrowse) { - this.showBrowserRoot(); - this.browserTarget = target; - this.browserPathDraft = path ?? ""; - return; - } - const requestId = ++this.browserRequestToken; - this.browserLoading = true; - this.browserError = null; - this.browserProjectPath = null; - // Clear the previous directory immediately: keeping it clickable while the - // request is in flight would let "Use this folder" apply the stale path. - this.browserListing = null; - // Navigation owns the shown path at once, so a mid-flight "Use this - // folder" applies where the user is heading, never the directory they - // just left ("" = the host default while heading home). - this.browserPathDraft = path ?? ""; - const draftAtRequest = this.browserPathDraft; - void client - .request("fs.listDir", { - ...(path ? { path } : {}), - ...(target.nodeId ? { nodeId: target.nodeId } : {}), - }) - .then((result) => { - if (requestId !== this.browserRequestToken) { - return; - } - this.browserListing = result ?? null; - if (result) { - this.recordGatewayApprovedListing(result); - } - // Sync the head input to the listed directory unless the user typed - // while this request was in flight; their edit wins. - if (result?.path && this.browserPathDraft === draftAtRequest) { - this.browserPathDraft = result.path; - } - if (result?.path && !target.nodeId && this.isAdmin()) { - // Browse and worktree selection share the Gateway's Git-checkout verdict; - // fs.listDir stays a filesystem-only contract. - void client - .request("worktrees.branches", { - repoRoot: result.path, - includeRepositoryStatus: true, - }) - .then((branches) => { - if ( - requestId === this.browserRequestToken && - this.browserListing?.path === result.path && - branches.repositoryStatus === "git" - ) { - this.browserProjectPath = result.path; - } - }) - .catch(() => undefined); - } - }) - .catch(() => { - if (requestId !== this.browserRequestToken) { - return; - } - // A stale or mistyped folder should not strand the picker: fall back home. - if (path) { - this.loadBrowser(undefined); - return; - } - this.browserError = t("newSession.browserLoadFailed"); - }) - .finally(() => { - if (requestId === this.browserRequestToken) { - this.browserLoading = false; - } - }); - } - - private async registerBrowserProject(path: string) { - const snapshot = this.context?.gateway.snapshot; - const client = snapshot?.client; - if ( - snapshot?.phase !== "connected" || - !client || - !this.isAdmin() || - this.browserTarget?.nodeId || - this.browserProjectPath !== path || - this.browserRegistering - ) { - return; - } - const requestId = this.browserRequestToken; - const connectionEpoch = this.gatewayConnectionEpoch; - this.browserRegistering = true; - this.browserError = null; - try { - const project = await client.request("projects.register", { path }); - if (requestId !== this.browserRequestToken || client !== this.gatewayClient) { - return; - } - await this.projectsTask.run([client, true, connectionEpoch]); - if (requestId !== this.browserRequestToken || client !== this.gatewayClient) { - return; - } - this.selectProjectId(project.id); - this.closeBrowser(); - } catch (error) { - if (requestId === this.browserRequestToken && client === this.gatewayClient) { - this.browserError = error instanceof Error ? error.message : String(error); - } - } finally { - if (requestId === this.browserRequestToken) { - this.browserRegistering = false; - } - } - } - - private renderAgentSelect(agents: ReturnType) { + private renderAgentSelect() { return renderAgentSelect({ - agents, - agentId: this.agentId, - disabled: this.submitting || Boolean(this.pendingCloud.sessionKey), - onSelect: (agentId) => this.selectAgentId(agentId), + agents: this.place.agents(), + agentId: this.place.agentId, + disabled: this.submission.submitting || Boolean(this.submission.pendingCloud.sessionKey), + onSelect: (agentId) => this.place.selectAgentId(agentId), + }); + } + + private renderTargetBar() { + const agents = this.place.agents(); + return catalog.renderBar({ + data: this.data, + agentSelect: agents.length > 1 ? this.renderAgentSelect() : nothing, + placeSelect: this.renderPlaceSelect(), + retrying: this.gateway.catalogRetrying, + onRetry: this.gateway.handleCatalogRetry, }); } private renderPlaceSelect() { - const execNodes = this.execNodes(); - const cloudProfiles = catalog.isTarget(this.data) ? [] : this.cloudProfiles; - const branches = this.repository.kind === "git" ? this.repository : null; - const cloudDisabledReason = this.cloudDisabledReason(); + const execNodes = this.place.execNodes(); + const cloudProfiles = catalog.isTarget(this.data) ? [] : this.gateway.cloudProfiles; + const branches = this.place.repository.kind === "git" ? this.place.repository : null; return renderPlaceSelect({ - browseAvailable: this.browseAvailable(), - isAdmin: this.isAdmin(), - canWrite: this.canWrite(), - folder: this.folder, - workspace: this.workspacePath(), - workspaceRoots: this.knownWorkspaceRoots(), - projects: catalog.isTarget(this.data) ? [] : this.projects, - recents: catalog.isTarget(this.data) ? [] : this.projectRecents, - projectQuery: this.projectQuery, + browseAvailable: this.place.browseAvailable(), + isAdmin: this.place.isAdmin(), + canWrite: this.place.canWrite(), + folder: this.place.folder, + workspace: this.place.workspacePath(), + projects: catalog.isTarget(this.data) ? [] : this.browser.projects, + recents: catalog.isTarget(this.data) + ? [] + : this.browser.resolveProjectRecents({ + sessions: this.context?.sessions.state.result?.sessions ?? [], + workspace: this.place.workspacePath(), + workspaceRoots: this.place.knownWorkspaceRoots(), + execNodes, + isAdmin: this.place.isAdmin(), + }), + projectQuery: this.browser.projectQuery, projectSearchAvailable: canCallGatewayMethod( this.context?.gateway.snapshot, "projects.searchRemote", @@ -2282,183 +362,138 @@ class NewSessionPage extends OpenClawLightDomElement { "projects.add", "operator.write", ), - remoteProjects: this.projectSearchResult?.projects ?? [], - projectSearchCredential: this.projectSearchResult?.credential ?? null, - projectSearchLoading: this.projectSearchLoading, - projectSearchError: this.projectSearchError, - projectCloneBusy: this.projectCloneBusy, - projectCloneError: this.projectCloneError, - projectId: this.projectId, - sessions: this.context?.sessions.state.result?.sessions ?? [], - execNodes: this.isAdmin() ? execNodes : [], - gatewayName: this.gatewayName, - cloudProfiles: this.isAdmin() ? cloudProfiles : [], - cloudProfileId: this.cloudProfileId, - execNode: this.execNode, - syncFolder: this.folder.trim() || this.workspacePath(), - worktree: this.worktree, - worktreeVisible: this.worktreeAvailable() || Boolean(this.cloudProfileId) || this.worktree, - worktreeAvailable: this.worktreeAvailable(), + remoteProjects: this.browser.projectSearchResult?.projects ?? [], + projectSearchCredential: this.browser.projectSearchResult?.credential ?? null, + projectSearchLoading: this.browser.projectSearchLoading, + projectSearchError: this.browser.projectSearchError, + projectCloneBusy: this.browser.projectCloneBusy, + projectCloneError: this.browser.projectCloneError, + projectId: this.place.projectId, + execNodes: this.place.isAdmin() ? execNodes : [], + gatewayName: this.gateway.gatewayName, + cloudProfiles: this.place.isAdmin() ? cloudProfiles : [], + cloudProfileId: this.place.cloudProfileId, + execNode: this.place.execNode, + syncFolder: this.place.folder.trim() || this.place.workspacePath(), + worktree: this.place.worktree, + worktreeVisible: + this.place.worktreeAvailable() || Boolean(this.place.cloudProfileId) || this.place.worktree, + worktreeAvailable: this.place.worktreeAvailable(), worktreeDisabledReason: - this.repository.kind === "checking" + this.place.repository.kind === "checking" ? t("newSession.checkingGit") - : this.repository.kind === "unavailable" + : this.place.repository.kind === "unavailable" ? t("newSession.gitCheckUnavailable") : undefined, - cloudDisabledReason, + cloudDisabledReason: this.submission.cloudDisabledReason(), branches, - branchesLoading: this.repository.kind === "checking", - baseRef: this.baseRef, - worktreeName: this.worktreeName, - submitting: this.submitting || this.projectCloneBusy, - pendingCloud: Boolean(this.pendingCloud.sessionKey), - // Admin gates only the discovered choices. An existing node or cloud - // selection always keeps the destination axis visible — hiding it (e.g. - // after a failed node.list or an auth downgrade) would misreport a - // remote-targeted draft as Gateway-local. + branchesLoading: this.place.repository.kind === "checking", + baseRef: this.place.baseRef, + worktreeName: this.place.worktreeName, + submitting: this.submission.submitting || this.browser.projectCloneBusy, + pendingCloud: Boolean(this.submission.pendingCloud.sessionKey), showDestinations: - Boolean(this.execNode) || - Boolean(this.cloudProfileId) || - (this.isAdmin() && (execNodes.length > 0 || cloudProfiles.length > 0)), - popoverOpen: this.placePopoverOpen, - popoverHiding: this.placePopoverHiding, - browserTarget: this.browserTarget, - browserListing: this.browserListing, - browserLoading: this.browserLoading, - browserError: this.browserError, - browserPathDraft: this.browserPathDraft, - usableBrowserPath: this.usableBrowserPath(), - registerProjectPath: this.browserProjectPath, - registeringProject: this.browserRegistering, - onGuardTransition: (event) => this.guardPopoverTransition(event, this.placePopoverHiding), - onPopoverShow: () => { - this.placePopoverOpen = true; - this.showBrowserRoot(); - }, - onPopoverHide: () => { - this.placePopoverOpen = false; - this.placePopoverHiding = true; - this.showBrowserRoot(); - }, - onPopoverAfterHide: () => { - this.placePopoverHiding = false; - this.restorePopoverTrigger("new-session-place-trigger", ".new-session-page__place-popover"); - }, - onSelectExecNode: (nodeId) => this.selectExecNode(nodeId), - onSelectCloudProfile: (profileId) => this.selectCloudProfile(profileId), - onSelectProject: (projectId) => this.selectProjectId(projectId), - onProjectQueryInput: (query) => this.changeProjectQuery(query), - onCloneProject: (gitUrl) => void this.addRemoteProject(gitUrl), + Boolean(this.place.execNode) || + Boolean(this.place.cloudProfileId) || + (this.place.isAdmin() && (execNodes.length > 0 || cloudProfiles.length > 0)), + popoverOpen: this.browser.placePopoverOpen, + popoverHiding: this.browser.placePopoverHiding, + browserTarget: this.browser.browserTarget, + browserListing: this.browser.browserListing, + browserLoading: this.browser.browserLoading, + browserError: this.browser.browserError, + browserPathDraft: this.browser.browserPathDraft, + usableBrowserPath: this.browser.usableBrowserPath(), + registerProjectPath: this.browser.browserProjectPath, + registeringProject: this.browser.browserRegistering, + onGuardTransition: (event) => this.browser.guardPopoverTransition(event), + onPopoverShow: () => this.browser.onPopoverShow(), + onPopoverHide: () => this.browser.onPopoverHide(), + onPopoverAfterHide: () => this.browser.onPopoverAfterHide(), + onSelectExecNode: (nodeId) => this.place.selectExecNode(nodeId), + onSelectCloudProfile: (profileId) => this.place.selectCloudProfile(profileId), + onSelectProject: (projectId) => this.place.selectProjectId(projectId), + onProjectQueryInput: (query) => this.browser.changeProjectQuery(query), + onCloneProject: (gitUrl) => void this.browser.addRemoteProject(gitUrl), onApplyFolder: (folder, execNode) => - this.applyFolder(folder, execNode, !execNode && this.browserListing?.path === folder), - onBrowse: (target) => this.selectBrowserTarget(target), + this.place.applyFolder( + folder, + execNode, + !execNode && this.browser.browserListing?.path === folder, + ), + onBrowse: (target) => this.browser.selectBrowserTarget(target), onBrowserPathDraftChange: (value) => { - this.browserPathDraft = value; - }, - onBrowserNavigate: (path) => this.loadBrowser(path), - onBrowserBack: () => this.showBrowserRoot(), - onRegisterProject: (path) => void this.registerBrowserProject(path), - onClose: () => this.closeBrowser(), - onToggleWorktree: () => { - if (this.cloudProfileId) { - return; - } - this.worktree = !this.worktree; - this.preferredWorktreeRestore = false; - this.worktreeSelectedByUser = true; - this.persistPreference({ - folder: this.folder.trim() || this.workspacePath(), - worktree: this.worktree, - }); - if (this.worktree && this.repository.kind !== "git") { - this.maybeLoadBranches(); - } - }, - onBaseRefInput: (baseRef) => { - if (!this.submitting) { - this.baseRefEditGeneration += 1; - this.baseRef = baseRef; - } - }, - onWorktreeNameInput: (worktreeName) => { - if (!this.submitting) { - this.worktreeName = worktreeName; - } + this.browser.browserPathDraft = value; }, + onBrowserNavigate: (path) => this.browser.loadBrowser(path), + onBrowserBack: () => this.browser.showRoot(), + onRegisterProject: (path) => void this.browser.registerBrowserProject(path), + onClose: () => this.browser.close(), + onToggleWorktree: () => this.place.toggleWorktree(), + onBaseRefInput: (baseRef) => this.place.setBaseRef(baseRef), + onWorktreeNameInput: (worktreeName) => this.place.setWorktreeName(worktreeName), }); } - private renderTargetBar() { - const agents = this.agents(); - return catalog.renderBar({ - data: this.data, - agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing, - placeSelect: this.renderPlaceSelect(), - retrying: this.catalogRetrying, - onRetry: this.handleCatalogRetry, - }); - } - - /** Target row + composer, rendered mid-screen between the hero and recents. */ private renderDraftBlock() { - const worktreeNameInvalid = this.worktree && !isWorktreeNameValid(this.worktreeName); + const worktreeNameInvalid = + this.place.worktree && !isWorktreeNameValid(this.place.worktreeName); return html` -
+
${this.renderTargetBar()} ${worktreeNameInvalid ? renderDraftError(t("newSession.worktreeNameInvalid")) : nothing} - ${this.error ? renderDraftError(this.error) : nothing} - ${this.submissionOutcomeUnknown + ${this.submission.error ? renderDraftError(this.submission.error) : nothing} + ${this.submission.submissionOutcomeUnknown ? renderDraftError( t( - this.submissionOutcomeUnknown === "gateway-changed" + this.submission.submissionOutcomeUnknown === "gateway-changed" ? "newSession.createOutcomeUnknown" : "newSession.cloudSetupInterrupted", ), ) : nothing} ${renderNewSessionDraftComposer({ - agent: this.selectedAgent(), - agentId: this.agentId, - attachmentDraft: this.attachmentDraft, - canSubmit: this.canSubmit(), - submitDisabledReason: this.submitDisabledReason(), + agent: this.place.selectedAgent(), + agentId: this.place.agentId, + attachmentDraft: this.submission.attachmentDraft, + canSubmit: this.submission.canSubmit(), + submitDisabledReason: this.submission.submitDisabledReason(), context: this.context, isCatalogTarget: catalog.isTarget(this.data), - message: this.message, - visibility: this.visibility, - draftAvailable: this.canStartAsDraft(), - modelControl: this.modelControl, + message: this.submission.message, + visibility: this.submission.visibility, + draftAvailable: this.submission.canStartAsDraft(), + modelControl: this.place.modelControl, requiresModifier: loadSettings().chatSendShortcut === "modifier-enter", - submitting: this.submitting, - textareaController: this.composerTextarea, - messageLocked: Boolean(this.pendingCloud.sessionKey), - incognitoDisabledReason: this.incognitoDisabledReason(), - terminalAction: this.showStartInTerminal() + submitting: this.submission.submitting, + textareaController: this.submission.composerTextarea, + messageLocked: Boolean(this.submission.pendingCloud.sessionKey), + incognitoDisabledReason: this.submission.incognitoDisabledReason(), + terminalAction: this.submission.showStartInTerminal() ? { - canStart: this.canSubmit("terminal"), - disabledReason: this.terminalStartDisabledReason(), - onStart: () => void this.startInTerminal(), + canStart: this.submission.canSubmit("terminal"), + disabledReason: this.submission.terminalStartDisabledReason(), + onStart: () => void this.submission.startInTerminal(), } : undefined, onInput: (message) => { - if (!this.submitting && !this.pendingCloud.sessionKey) { + if (!this.submission.submitting && !this.submission.pendingCloud.sessionKey) { this.setMessageFromUser(message); } }, onVisibilityChange: (visibility) => { - if (!this.submitting && !this.pendingCloud.sessionKey) { - this.visibility = visibility; + if (!this.submission.submitting && !this.submission.pendingCloud.sessionKey) { + this.submission.setVisibility(visibility); } }, - onSubmit: () => void this.submit(), + onSubmit: () => void this.submission.submit(), })}
`; } - /** Same welcome block as the empty-chat start screen, keyed to the draft's agent. */ private renderWelcome() { - const agent = this.selectedAgent(); + const agent = this.place.selectedAgent(); const identity = agent?.identity; const gateway = this.context?.gateway.snapshot; return renderWelcomeState({ @@ -2467,11 +502,11 @@ class NewSessionPage extends OpenClawLightDomElement { assistantAvatarUrl: identity?.avatarUrl ?? null, hint: t("newSession.hint"), composer: this.renderDraftBlock(), - modelSetupRequired: this.requiresModelSetup(), + modelSetupRequired: this.submission.requiresModelSetup(), onModelSetup: () => this.context?.navigate("model-setup"), sessions: this.context?.sessions.state.result, sessionKey: buildAgentMainSessionKey({ - agentId: this.agentId || "main", + agentId: this.place.agentId || "main", mainKey: this.context?.agents.state.agentsList?.mainKey, }), sessionHost: { @@ -2480,13 +515,13 @@ class NewSessionPage extends OpenClawLightDomElement { hello: gateway?.hello ?? null, }, onDraftChange: (next) => { - if (!this.submitting && !this.pendingCloud.sessionKey) { + if (!this.submission.submitting && !this.submission.pendingCloud.sessionKey) { this.setMessageFromUser(next); } }, - onSend: () => void this.submit(), + onSend: () => void this.submission.submit(), onOpenSession: (sessionKey) => { - if (this.submitting || this.pendingCloud.sessionKey) { + if (this.submission.submitting || this.submission.pendingCloud.sessionKey) { return; } const context = this.context; @@ -2497,15 +532,11 @@ class NewSessionPage extends OpenClawLightDomElement { selection: context.agentSelection, gateway: context.gateway, sessionKey, - agentId: this.agentId, + agentId: this.place.agentId, }); context.navigate( "chat", - sessionNavigationTarget({ - context, - face: "chat", - sessionKey, - }).options, + sessionNavigationTarget({ context, face: "chat", sessionKey }).options, ); }, }); @@ -2516,8 +547,8 @@ class NewSessionPage extends OpenClawLightDomElement {
${this.renderWelcome()} @@ -2530,6 +561,3 @@ class NewSessionPage extends OpenClawLightDomElement { if (!customElements.get("openclaw-new-session-page")) { customElements.define("openclaw-new-session-page", NewSessionPage); } - -export type { NewSessionPage }; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts index c9444f73324b..f5f88d5b1ca9 100644 --- a/ui/src/pages/new-session/place-picker.test.ts +++ b/ui/src/pages/new-session/place-picker.test.ts @@ -11,8 +11,8 @@ function placeParams(overrides: Partial = {}): PlaceSelectPar canWrite: true, folder: "/workspace", workspace: "/workspace", - workspaceRoots: ["/workspace"], projects: [], + recents: [], projectQuery: "", projectSearchAvailable: true, projectAddAvailable: true, @@ -23,7 +23,6 @@ function placeParams(overrides: Partial = {}): PlaceSelectPar projectCloneBusy: false, projectCloneError: null, projectId: "", - sessions: [], execNodes: [], gatewayName: "", cloudProfiles: [], diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 7c581bc29799..7afe7c47cc5c 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -9,9 +9,8 @@ import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { renderCloudProfileMenuItems, renderSessionMenuItem } from "./cloud-target.ts"; import type { BrowserTarget, DraftBranches, DraftCloudProfile, DraftNode } from "./discovery.ts"; -import { folderDisplayName, isKnownWorkspacePath } from "./path.ts"; +import { folderDisplayName } from "./path.ts"; import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts"; -import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts"; function parentFolderDisplayName(path: string): string | undefined { const trimmed = path.replace(/[\\/]+$/u, ""); @@ -165,9 +164,8 @@ export function renderPlaceSelect(params: { canWrite: boolean; folder: string; workspace: string; - workspaceRoots: readonly string[]; projects: readonly ProjectRecord[]; - recents?: readonly ProjectRecent[]; + recents: readonly ProjectRecent[]; projectQuery: string; projectSearchAvailable: boolean; projectAddAvailable: boolean; @@ -178,10 +176,9 @@ export function renderPlaceSelect(params: { projectCloneBusy: boolean; projectCloneError: string | null; projectId: string; - sessions: readonly RecentPlaceSource[]; execNodes: DraftNode[]; gatewayName: string; - cloudProfiles: DraftCloudProfile[]; + cloudProfiles: readonly DraftCloudProfile[]; cloudProfileId: string; execNode: string; syncFolder: string; @@ -261,33 +258,7 @@ export function renderPlaceSelect(params: { : gatewayLabel; const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel; const effectiveFolder = folder || params.workspace; - const allowGatewayFolder = (recentFolder: string) => - params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, recentFolder); - const serverRecents = params.recents?.filter((recent) => - recent.kind === "project" - ? params.projects.some((project) => project.id === recent.projectId) - : recent.execNode - ? params.execNodes.some((node) => node.nodeId === recent.execNode) - : allowGatewayFolder(recent.folder), - ); - const recents: ProjectRecent[] = - serverRecents ?? - recentPlaces(params.sessions, { - workspace: params.workspace, - execNodes: params.execNodes, - allowGatewayFolder, - }).map((recent) => { - const item: ProjectRecent = { - kind: "folder", - folder: recent.folder, - displayName: folderDisplayName(recent.folder), - }; - if (recent.execNode) { - item.execNode = recent.execNode; - } - return item; - }); - const recentItems = recents.map((recent) => { + const recentItems = params.recents.map((recent) => { const node = recent.kind === "folder" && recent.execNode ? params.execNodes.find((candidate) => candidate.nodeId === recent.execNode) @@ -498,7 +469,7 @@ export function renderPlaceSelect(params: { ${t("newSession.projectsAdminHint")}
` : nothing} - ${recents.length > 0 + ${params.recents.length > 0 ? html`
${t("newSession.recentFolders")}
${recentItems.map((recent, index) => { From f6fff4f7fdb6f25e24c73ec22aa363eb4a8fabf3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:18:34 -0700 Subject: [PATCH 007/165] refactor: canonicalize aliases and classify test suites (#122407) * refactor: use canonical re-export names * fix(test): classify suite support as test source * fix(agents): retarget gateway stub session-entry import * test(gateway): retarget session-utils mock keys after alias removal --- .oxlintrc.json | 25 +++++++------------ scripts/lib/changed-path-facts.mjs | 4 +-- scripts/lib/ci-changed-node-test-plan.mts | 7 +++++- scripts/test-projects.test-support.mts | 4 +-- .../tools/embedded-gateway-stub.runtime.ts | 2 +- src/gateway/control-ui-session-prs.ts | 11 +++++--- src/gateway/managed-image-attachments.test.ts | 2 +- src/gateway/managed-image-attachments.ts | 6 ++--- src/gateway/mcp-app-reconstruction.test.ts | 2 +- src/gateway/mcp-app-reconstruction.ts | 4 +-- src/gateway/server-chat.agent-events.test.ts | 2 +- src/gateway/server-chat.ts | 6 ++--- .../__mocks__/tools-effective.runtime.ts | 2 +- src/gateway/server-methods/artifacts.test.ts | 2 +- src/gateway/server-methods/artifacts.ts | 6 ++--- .../server-methods/chat-history-handler.ts | 4 +-- .../chat-message-get-handler.ts | 7 ++++-- .../chat.directive-tags.test.ts | 2 +- src/gateway/server-methods/chat.ts | 4 +-- src/gateway/server-methods/cron.ts | 6 ++--- .../server-methods/cron.validation.test.ts | 2 +- .../deleted-agent-guard.test-helpers.ts | 6 ++--- src/gateway/server-methods/sessions-create.ts | 7 ++++-- .../server-methods/sessions-diff.test.ts | 2 +- src/gateway/server-methods/sessions-diff.ts | 11 +++++--- .../server-methods/sessions-files.test.ts | 2 +- .../sessions-files.touched-files.test.ts | 2 +- src/gateway/server-methods/sessions-files.ts | 4 +-- .../server-methods/sessions-messaging.ts | 4 +-- .../sessions.abort-agent-scope.test.ts | 2 +- ...sions.messages-subscribe-approvals.test.ts | 2 +- .../sessions.send-followup-status.test.ts | 7 +++--- .../server-methods/task-suggestions.test.ts | 6 +++-- .../server-methods/task-suggestions.ts | 8 +++--- .../server-methods/tools-effective.runtime.ts | 2 +- .../server-methods/tools-effective.test.ts | 2 +- src/gateway/server-methods/tools-effective.ts | 4 +-- .../usage.sessions-usage.test.ts | 6 ++--- src/gateway/server-methods/usage.ts | 4 +-- src/gateway/server-session-events.test.ts | 2 +- src/gateway/server-session-events.ts | 6 ++--- src/gateway/session-companion-context.ts | 6 ++--- src/gateway/session-create-service.ts | 13 ++++++---- src/gateway/session-utils.test.ts | 16 ++++++------ src/gateway/session-utils.ts | 2 +- .../worker-session-tool-executor.test.ts | 2 +- .../worker-session-tool-executor.ts | 12 ++++++--- .../worker-session-tool-topology.ts | 14 ++++++----- src/infra/json-files.ts | 10 ++++---- src/infra/openclaw-root.fs.runtime.ts | 4 +-- src/infra/secret-file.ts | 2 +- ...ate-migrations.workspace-setup-receipts.ts | 2 +- src/infra/state-migrations.workspace-setup.ts | 8 +++--- src/media-generation/model-ref.ts | 6 ++--- src/plugin-sdk/command-status.runtime.test.ts | 2 +- src/plugin-sdk/command-status.runtime.ts | 4 +-- src/transcripts/provider-registry.ts | 2 +- src/tui/embedded-backend.test.ts | 2 +- src/tui/embedded-backend.ts | 6 ++--- .../gateway-usage-memory-apis.e2e.test.ts | 4 +-- test/scripts/changed-path-facts.test.ts | 6 +++++ .../scripts/ci-changed-node-test-plan.test.ts | 3 +++ test/scripts/oxlint-config.test.ts | 15 +++++++++-- 63 files changed, 191 insertions(+), 149 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index c12ce2b5494e..7ccf77f27224 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -262,8 +262,8 @@ }, { "files": [ - "**/*.test.ts", - "**/*.test.tsx", + "**/*.{test,suite}.ts", + "**/*.{test,suite}.tsx", "**/*.e2e.test.ts", "**/*.live.test.ts", "**/*test-harness.ts", @@ -282,8 +282,7 @@ "extensions/**/*.{js,ts,mts,cts}" ], "excludeFiles": [ - "**/*.test.*", - "**/*.spec.*", + "**/*.{test,spec,suite}.*", "**/__generated__/**", "**/generated/**", "**/protocol-gen/**", @@ -304,8 +303,7 @@ "extensions/**/*.{jsx,tsx}" ], "excludeFiles": [ - "**/*.test.*", - "**/*.spec.*", + "**/*.{test,spec,suite}.*", "**/__generated__/**", "**/generated/**", "**/protocol-gen/**", @@ -326,8 +324,7 @@ "extensions/**/*.{mjs,cjs}" ], "excludeFiles": [ - "**/*.test.*", - "**/*.spec.*", + "**/*.{test,spec,suite}.*", "**/__generated__/**", "**/generated/**", "**/protocol-gen/**", @@ -342,14 +339,10 @@ }, { "files": [ - "src/**/*.test.*", - "src/**/*.spec.*", - "ui/src/**/*.test.*", - "ui/src/**/*.spec.*", - "packages/**/*.test.*", - "packages/**/*.spec.*", - "extensions/**/*.test.*", - "extensions/**/*.spec.*" + "src/**/*.{test,spec,suite}.*", + "ui/src/**/*.{test,spec,suite}.*", + "packages/**/*.{test,spec,suite}.*", + "extensions/**/*.{test,spec,suite}.*" ], "excludeFiles": [ "**/__generated__/**", diff --git a/scripts/lib/changed-path-facts.mjs b/scripts/lib/changed-path-facts.mjs index ea2d9d93461c..8de19e28f80b 100644 --- a/scripts/lib/changed-path-facts.mjs +++ b/scripts/lib/changed-path-facts.mjs @@ -23,9 +23,9 @@ const SURFACE_PATTERNS = [ ["legacyRootAsset", /^assets\//u], ]; const CHANGED_LANE_TEST_PATH_RE = - /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|e2e|browser\.test)\.[cm]?[jt]sx?$|(?:^|\/)[^/]+\.test-(?:helpers|support)\.[cm]?[jt]sx?$/u; + /(?:^|\/)(?:test|__tests__)\/|(?:\.|\/)(?:test|spec|suite|e2e|browser\.test)\.[cm]?[jt]sx?$|(?:^|\/)[^/]+\.test-(?:helpers|support)\.[cm]?[jt]sx?$/u; const TEST_ONLY_PATH_RE = - /(^test\/|\/test\/|\/tests\/|(?:^|\/)[^/]+\.(?:test|spec|test-utils|test-(?:helpers|support|harness)|e2e-harness)\.[cm]?[jt]sx?$)/u; + /(^test\/|\/test\/|\/tests\/|(?:^|\/)[^/]+\.(?:test|spec|suite|test-utils|test-(?:helpers|support|harness)|e2e-harness)\.[cm]?[jt]sx?$)/u; const NATIVE_ONLY_PATH_RE = /^(?:apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/macos-mlx-tts\/|apps\/shared\/|apps\/swabble\/|Swabble\/|appcast\.xml$)/u; diff --git a/scripts/lib/ci-changed-node-test-plan.mts b/scripts/lib/ci-changed-node-test-plan.mts index 89860e30d00f..a40c74b5d6c2 100644 --- a/scripts/lib/ci-changed-node-test-plan.mts +++ b/scripts/lib/ci-changed-node-test-plan.mts @@ -6,6 +6,7 @@ import { findUnmatchedExplicitTestTargets, hasImportGraphImpactOnTargets, isTestFileTarget, + isTestSupportFileTarget, resolveChangedTestTargetPlan, } from "../test-projects.test-support.mts"; import { @@ -53,7 +54,11 @@ const splitNodeTestConfigs = new Set( ); function isTestOnlyPath(changedPath: string) { - return isTestFileTarget(changedPath) || changedPath.startsWith("test/"); + return ( + isTestFileTarget(changedPath) || + isTestSupportFileTarget(changedPath) || + changedPath.startsWith("test/") + ); } // Inputs `build:ci-artifacts` consumes: runtime/plugin/package sources plus diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 725ee3ddf743..ce4ee8db2914 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -1000,12 +1000,12 @@ export function isTestFileTarget(arg: string) { return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg); } -function isTestSupportFileTarget(arg: string) { +export function isTestSupportFileTarget(arg: string) { if (/(?:^|\/)(?:test-helpers|test-support)(?:\/|$)/u.test(arg)) { return true; } const basename = path.posix.basename(arg).replace(/\.[cm]?[jt]sx?$/u, ""); - return /(?:^|[._-])test-(?:helpers|support)(?:[._-]|$)/u.test(basename); + return /(?:^|[._-])(?:suite|test-(?:helpers|support))(?:[._-]|$)/u.test(basename); } function isLikelyFileTarget(arg: string) { diff --git a/src/agents/tools/embedded-gateway-stub.runtime.ts b/src/agents/tools/embedded-gateway-stub.runtime.ts index b7db33621c11..6add2d7168c0 100644 --- a/src/agents/tools/embedded-gateway-stub.runtime.ts +++ b/src/agents/tools/embedded-gateway-stub.runtime.ts @@ -34,7 +34,7 @@ export { export { listSessionsFromStoreAsync, loadCombinedSessionStoreForGatewayCore, - loadSessionEntryReadOnly as loadSessionEntry, + loadGatewaySessionEntryReadOnly as loadSessionEntry, resolveSessionModelRef, } from "../../gateway/session-utils.js"; export { resolveSessionKeyFromResolveParams } from "../../gateway/sessions-resolve.js"; diff --git a/src/gateway/control-ui-session-prs.ts b/src/gateway/control-ui-session-prs.ts index b081fdfaad36..e40a3aeeb665 100644 --- a/src/gateway/control-ui-session-prs.ts +++ b/src/gateway/control-ui-session-prs.ts @@ -32,7 +32,7 @@ import { type SessionPullRequestGitContext, type SessionPullRequestLocalGitDeps, } from "./control-ui-session-prs-local-git.js"; -import { loadSessionEntryReadOnly } from "./session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "./session-utils.js"; const SUCCESS_CACHE_MS = 60_000; // Back off refetches while GitHub reports quota exhaustion; the UI keeps @@ -94,9 +94,12 @@ type LoadSessionPullRequestDeps = SessionPullRequestLocalGitDeps & { function resolveSessionPullRequestGitRoot( params: ControlUiSessionPullRequestsParams, ): string | null { - const { cfg, entry, storePath, canonicalKey } = loadSessionEntryReadOnly(params.sessionKey, { - agentId: params.agentId, - }); + const { cfg, entry, storePath, canonicalKey } = loadGatewaySessionEntryReadOnly( + params.sessionKey, + { + agentId: params.agentId, + }, + ); // Same session/agent scoping as sessions.files.*: a missing entry means an // unknown or deleted session, which must not fall back to some agent // workspace and surface another checkout's PRs. diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index 575048781728..89b9398fdf55 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -67,7 +67,7 @@ vi.mock("./http-utils.js", () => ({ vi.mock("./session-utils.js", () => ({ loadSessionEntry: loadSessionEntryMock, - loadSessionEntryReadOnly: loadSessionEntryMock, + loadGatewaySessionEntryReadOnly: loadSessionEntryMock, resolveSessionHistoryTranscriptPathAsync: resolveSessionHistoryTranscriptPathMock, })); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 512bc66e7f3e..d9867739f245 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -68,7 +68,7 @@ import { import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { readSessionMessagesWithSourceAsync } from "./session-transcript-readers.js"; import { - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, resolveSessionHistoryTranscriptPathAsync, } from "./session-utils.js"; @@ -974,7 +974,7 @@ async function getSessionManagedOutgoingAttachmentIndex( } const usesRuntimeState = !stateDir || path.resolve(stateDir) === path.resolve(resolveStateDir()); const env = stateDir ? { ...process.env, OPENCLAW_STATE_DIR: stateDir } : process.env; - type SessionEntry = ReturnType["entry"]; + type SessionEntry = ReturnType["entry"]; let matched: { entry: NonNullable; storePath: string } | undefined; for (const target of discovery.targets) { const exact = loadExactSessionEntryReadOnlyResult({ @@ -1014,7 +1014,7 @@ async function getSessionManagedOutgoingAttachmentIndex( let entry: SessionEntry = matched?.entry; let storePath = matched?.storePath ?? discovery.targets[0]?.storePath ?? ""; if (!entry && usesRuntimeState) { - const loaded = loadSessionEntryReadOnly(sessionKey, { agentId: ownerAgentId }); + const loaded = loadGatewaySessionEntryReadOnly(sessionKey, { agentId: ownerAgentId }); const exact = loadExactSessionEntryReadOnlyResult({ agentId: ownerAgentId, clone: false, diff --git a/src/gateway/mcp-app-reconstruction.test.ts b/src/gateway/mcp-app-reconstruction.test.ts index 052397677f2c..68404f6b5fa2 100644 --- a/src/gateway/mcp-app-reconstruction.test.ts +++ b/src/gateway/mcp-app-reconstruction.test.ts @@ -30,7 +30,7 @@ vi.mock("./session-transcript-readers.js", () => ({ })); vi.mock("./session-utils.js", () => ({ loadSessionEntry: mocks.loadSessionEntry, - loadSessionEntryReadOnly: mocks.loadSessionEntry, + loadGatewaySessionEntryReadOnly: mocks.loadSessionEntry, })); import { mintMcpAppViewFromTranscript, restoreMcpAppView } from "./mcp-app-reconstruction.js"; diff --git a/src/gateway/mcp-app-reconstruction.ts b/src/gateway/mcp-app-reconstruction.ts index 911e16103d30..32354eecf27c 100644 --- a/src/gateway/mcp-app-reconstruction.ts +++ b/src/gateway/mcp-app-reconstruction.ts @@ -14,7 +14,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { visitSessionMessagesAsync } from "./session-transcript-readers.js"; -import { loadSessionEntryReadOnly } from "./session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "./session-utils.js"; const MCP_APP_RESTORE_IN_FLIGHT_KEY = Symbol.for("openclaw.mcpAppRestoreInFlight"); @@ -252,7 +252,7 @@ async function reconstructMcpAppView(params: { viewId?: string; }): Promise { const agentId = resolveAgentIdFromSessionKey(params.sessionKey); - const loaded = loadSessionEntryReadOnly(params.sessionKey, { agentId }); + const loaded = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId }); const sessionId = loaded.entry?.sessionId; if (!sessionId) { return undefined; diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 217281b9379f..d40b3d9dbfe4 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -75,7 +75,7 @@ vi.mock("./session-utils.js", () => { })); return { loadSessionEntry, - loadSessionEntryReadOnly: loadSessionEntry, + loadGatewaySessionEntryReadOnly: loadSessionEntry, }; }); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 086b5b7e262f..f08c548d760e 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -65,7 +65,7 @@ import { resolveSessionSubscriptionKey, resolveSessionSubscriptionKeys, } from "./session-subscription-keys.js"; -import { loadSessionEntryReadOnly } from "./session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; export { @@ -522,7 +522,7 @@ export function createAgentEventHandler({ event: AgentEventPayload, ): { suppress: boolean } => { try { - const { entry } = loadSessionEntryReadOnly(sessionKey, { + const { entry } = loadGatewaySessionEntryReadOnly(sessionKey, { ...(agentId ? { agentId } : {}), clone: false, }); @@ -1341,7 +1341,7 @@ export function createAgentEventHandler({ return runVerbose ?? "off"; } try { - const { cfg, entry } = loadSessionEntryReadOnly(sessionKey); + const { cfg, entry } = loadGatewaySessionEntryReadOnly(sessionKey); const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); const sessionUpdatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : undefined; const sessionChangedAfterRunStarted = diff --git a/src/gateway/server-methods/__mocks__/tools-effective.runtime.ts b/src/gateway/server-methods/__mocks__/tools-effective.runtime.ts index 57331d9d9dee..2c33a8da63bf 100644 --- a/src/gateway/server-methods/__mocks__/tools-effective.runtime.ts +++ b/src/gateway/server-methods/__mocks__/tools-effective.runtime.ts @@ -10,7 +10,7 @@ export { getRegisteredAgentHarness } from "../../../agents/harness/registry.js"; export { resolveReplyToMode } from "../../../auto-reply/reply/reply-threading.js"; export { resolveRuntimeConfigCacheKey } from "../../../config/config.js"; export { deliveryContextFromSession } from "../../../utils/delivery-context.shared.js"; -export { loadSessionEntryReadOnly, resolveSessionModelRef } from "../../session-utils.js"; +export { loadGatewaySessionEntryReadOnly, resolveSessionModelRef } from "../../session-utils.js"; export const toolsEffectiveGlobalAgentRuntimeMocks = { resolveEffectiveToolInventory: vi.fn( diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index c5ae97682602..dd9bb3f10326 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -22,7 +22,7 @@ vi.mock("../session-utils.js", async () => { return { ...actual, loadSessionEntry: hoisted.loadSessionEntry, - loadSessionEntryReadOnly: hoisted.loadSessionEntry, + loadGatewaySessionEntryReadOnly: hoisted.loadSessionEntry, }; }); diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index 23b2bb69bca4..c5cbe2289674 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -34,7 +34,7 @@ import { resolveStoredSessionKeyForAgentStore, } from "../session-store-key.js"; import { visitSessionMessagesAsync } from "../session-transcript-readers.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -526,8 +526,8 @@ async function loadArtifacts( const scopedGlobalAgentId = cfg?.session?.scope === "global" && sessionKey === "global" ? resolved.agentId : undefined; const { storePath, entry } = scopedGlobalAgentId - ? loadSessionEntryReadOnly(sessionKey, { agentId: scopedGlobalAgentId }) - : loadSessionEntryReadOnly(sessionKey); + ? loadGatewaySessionEntryReadOnly(sessionKey, { agentId: scopedGlobalAgentId }) + : loadGatewaySessionEntryReadOnly(sessionKey); const sessionId = entry?.sessionId; if (!sessionId || !storePath) { return { sessionKey, artifacts: [] }; diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 9c8f8b0e6b50..5cc24869489d 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -36,7 +36,7 @@ import { capArrayByJsonBytes } from "../session-transcript-readers.js"; import { buildGatewaySessionInfo, getSessionDefaults, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, listAgentsForGateway, resolveSessionModelRef, resolveSessionStoreKey, @@ -203,7 +203,7 @@ async function handleChatHistoryRequest({ const { cfg, storePath, store, entry, canonicalKey } = measureDiagnosticsTimelineSpanSync( `gateway.${method}.session_entry`, () => - loadSessionEntryReadOnly(sessionKey, { + loadGatewaySessionEntryReadOnly(sessionKey, { ...sessionLoadOptions, includeStoreChildEntries: true, }), diff --git a/src/gateway/server-methods/chat-message-get-handler.ts b/src/gateway/server-methods/chat-message-get-handler.ts index 2b80bddc8e78..a2863d96ef30 100644 --- a/src/gateway/server-methods/chat-message-get-handler.ts +++ b/src/gateway/server-methods/chat-message-get-handler.ts @@ -17,7 +17,7 @@ import { readSessionMessageByIdAsync, readSessionMessagesAsync, } from "../session-transcript-readers.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { readChatHistoryMessageId } from "./chat-history-pages.js"; import { resolveRequestedChatAgentId, validateChatSelectedAgent } from "./chat-origin-routing.js"; import { normalizeOptionalChatText as normalizeOptionalText } from "./chat-text-normalization.js"; @@ -74,7 +74,10 @@ export const chatMessageGetHandlers: GatewayRequestHandlers = { agentId: agentIdOverride, }); const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; - const { cfg, storePath, entry } = loadSessionEntryReadOnly(sessionKey, sessionLoadOptions); + const { cfg, storePath, entry } = loadGatewaySessionEntryReadOnly( + sessionKey, + sessionLoadOptions, + ); const selectedAgent = validateChatSelectedAgent({ cfg, requestedSessionKey: sessionKey, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 3bb6388a5d0c..19acbf091ebc 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -256,7 +256,7 @@ vi.mock("../session-utils.js", async () => { return { ...original, loadSessionEntry, - loadSessionEntryReadOnly: loadSessionEntry, + loadGatewaySessionEntryReadOnly: loadSessionEntry, }; }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 487e92132d86..cf9bde1228ed 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -17,7 +17,7 @@ import { import { resolveSessionSubscriptionKeys } from "../session-subscription-keys.js"; import { loadSessionEntry, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, resolveSessionModelRef, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; @@ -84,7 +84,7 @@ export const chatHandlers: GatewayRequestHandlers = { // Session entry carries per-session model overrides; utility routing must // derive its small-model default from the provider this session actually // uses, not the agent's configured default. - const { cfg: sessionCfg, entry } = loadSessionEntryReadOnly( + const { cfg: sessionCfg, entry } = loadGatewaySessionEntryReadOnly( params.sessionKey, selectedAgent.agentId ? { agentId: selectedAgent.agentId } : undefined, ); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 6212d3c04308..b8f9d5305c68 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -54,7 +54,7 @@ import { import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { consumeCronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import { getGatewayProcessInstanceId } from "../process-instance.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { assertActiveAgentRuntimeAuthority, hasActiveAgentRuntimeAuthority, @@ -342,7 +342,7 @@ function assertCronDoesNotTargetAgentHarness(input: { return; } - const loaded = loadSessionEntryReadOnly( + const loaded = loadGatewaySessionEntryReadOnly( targetSessionKey, input.agentId?.trim() ? { agentId: input.agentId.trim() } : {}, ); @@ -405,7 +405,7 @@ export const cronHandlers: GatewayRequestHandlers = { const sessionKey = p.sessionKey?.trim() || undefined; const agentId = p.agentId?.trim() || undefined; if (sessionKey && isAgentHarnessSessionKey(sessionKey)) { - const loaded = loadSessionEntryReadOnly(sessionKey, agentId ? { agentId } : {}); + const loaded = loadGatewaySessionEntryReadOnly(sessionKey, agentId ? { agentId } : {}); const harnessSessionError = loaded.entry ? resolveAgentHarnessSessionStoreEntryError(loaded.canonicalKey, loaded.entry) : AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE; diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 7a29b33bc4e2..832fbd28b652 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -52,7 +52,7 @@ vi.mock("../../config/config.js", async () => { vi.mock("../session-utils.js", () => ({ loadSessionEntry: loadGatewaySessionEntry, - loadSessionEntryReadOnly: loadGatewaySessionEntry, + loadGatewaySessionEntryReadOnly: loadGatewaySessionEntry, })); import { cronHandlers } from "./cron.js"; diff --git a/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts b/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts index e7f788455e10..95f6bc0befd3 100644 --- a/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts +++ b/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts @@ -5,20 +5,20 @@ import { vi } from "vitest"; const deletedAgentSessionMocks = vi.hoisted(() => ({ loadSessionEntry: vi.fn(), - loadSessionEntryReadOnly: vi.fn(), + loadGatewaySessionEntryReadOnly: vi.fn(), resolveDeletedAgentIdFromSessionKey: vi.fn(), })); vi.mock("../session-utils.js", () => ({ loadSessionEntry: deletedAgentSessionMocks.loadSessionEntry, - loadSessionEntryReadOnly: deletedAgentSessionMocks.loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly: deletedAgentSessionMocks.loadGatewaySessionEntryReadOnly, resolveDeletedAgentIdFromSessionKey: deletedAgentSessionMocks.resolveDeletedAgentIdFromSessionKey, })); /** Resets mocked deleted-agent session lookups between tests. */ export function resetDeletedAgentSessionMocks(): void { deletedAgentSessionMocks.loadSessionEntry.mockReset(); - deletedAgentSessionMocks.loadSessionEntryReadOnly.mockReset(); + deletedAgentSessionMocks.loadGatewaySessionEntryReadOnly.mockReset(); deletedAgentSessionMocks.resolveDeletedAgentIdFromSessionKey.mockReset(); } diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 47c6a256d709..e5167ce1196b 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -36,7 +36,10 @@ import type { PrepareGatewaySessionLifecycle } from "../session-lifecycle-prepar import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js"; import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; -import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js"; +import { + loadGatewaySessionEntryReadOnly, + resolveGatewaySessionStoreTarget, +} from "../session-utils.js"; import { resolveSessionPatchModelSelection } from "../sessions-patch.js"; import { createAgentRuntimeAuthorityGuard } from "./agent-runtime-authority.js"; import { chatHandlers } from "./chat.js"; @@ -288,7 +291,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { !hasInitialTurn && cfg.session?.dmScope === "main" ) { - const parent = loadSessionEntryReadOnly( + const parent = loadGatewaySessionEntryReadOnly( parentSessionKey, requestedAgent.agentId ? { agentId: requestedAgent.agentId } : undefined, ); diff --git a/src/gateway/server-methods/sessions-diff.test.ts b/src/gateway/server-methods/sessions-diff.test.ts index 03de1d39cd7b..d78163d0faa5 100644 --- a/src/gateway/server-methods/sessions-diff.test.ts +++ b/src/gateway/server-methods/sessions-diff.test.ts @@ -25,7 +25,7 @@ const hoisted = vi.hoisted(() => ({ vi.mock("../session-utils.js", () => ({ loadSessionEntry: hoisted.loadSessionEntry, - loadSessionEntryReadOnly: hoisted.loadSessionEntry, + loadGatewaySessionEntryReadOnly: hoisted.loadSessionEntry, })); vi.mock("../../agents/agent-scope.js", () => ({ diff --git a/src/gateway/server-methods/sessions-diff.ts b/src/gateway/server-methods/sessions-diff.ts index f58f4119e3bd..c93b0a06a80c 100644 --- a/src/gateway/server-methods/sessions-diff.ts +++ b/src/gateway/server-methods/sessions-diff.ts @@ -9,7 +9,7 @@ import { import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { applySessionDiffBaseline, loadCheckoutDiff } from "../../sessions/session-diff.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -25,9 +25,12 @@ export async function loadSessionDiff(params: SessionsDiffParams): Promise { return { ...actual, loadSessionEntry: hoisted.loadSessionEntry, - loadSessionEntryReadOnly: hoisted.loadSessionEntry, + loadGatewaySessionEntryReadOnly: hoisted.loadSessionEntry, }; }); diff --git a/src/gateway/server-methods/sessions-files.touched-files.test.ts b/src/gateway/server-methods/sessions-files.touched-files.test.ts index cfe956a655cd..7337c7e6e79d 100644 --- a/src/gateway/server-methods/sessions-files.touched-files.test.ts +++ b/src/gateway/server-methods/sessions-files.touched-files.test.ts @@ -32,7 +32,7 @@ vi.mock("../session-utils.js", async () => { return { ...actual, loadSessionEntry: hoisted.loadSessionEntry, - loadSessionEntryReadOnly: hoisted.loadSessionEntry, + loadGatewaySessionEntryReadOnly: hoisted.loadSessionEntry, }; }); diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index e2e0a53f88cc..b64110a7806f 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -31,7 +31,7 @@ import { toTranscriptReadScope, type SessionTranscriptReadScope, } from "../session-transcript-readers.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { execOpenPath, formatOpenPathError, @@ -526,7 +526,7 @@ async function toSessionFileEntry( } function loadSessionFileRoot(params: { sessionKey: string; agentId?: string }) { - const loaded = loadSessionEntryReadOnly(params.sessionKey, { agentId: params.agentId }); + const loaded = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId: params.agentId }); if (!loaded.entry?.sessionId) { return { ...loaded, agentId: undefined, root: undefined, fileRoot: undefined }; } diff --git a/src/gateway/server-methods/sessions-messaging.ts b/src/gateway/server-methods/sessions-messaging.ts index 8c9fd5295ad6..1763314b5172 100644 --- a/src/gateway/server-methods/sessions-messaging.ts +++ b/src/gateway/server-methods/sessions-messaging.ts @@ -22,7 +22,7 @@ import { reactivateCompletedSubagentSession } from "../session-subagent-reactiva import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; import { loadSessionEntry, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, resolveDeletedAgentIdFromSessionKey, } from "../session-utils.js"; import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; @@ -193,7 +193,7 @@ async function createAgentMainSessionForSend(params: { } const createdKey = normalizeOptionalString(createResult.payload?.key) ?? params.canonicalKey; - const loaded = loadSessionEntryReadOnly(createdKey); + const loaded = loadGatewaySessionEntryReadOnly(createdKey); if (!loaded.entry?.sessionId) { return { ok: false, diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index 123f62e896b9..2ead800f799c 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -49,7 +49,7 @@ vi.mock("../session-utils.js", async () => { loadCombinedSessionStoreForGatewayMock(...args), loadSessionEntry: (...args: unknown[]) => loadSessionEntryMock(...(args as [string, { agentId?: string }?])), - loadSessionEntryReadOnly: (...args: unknown[]) => + loadGatewaySessionEntryReadOnly: (...args: unknown[]) => loadSessionEntryMock(...(args as [string, { agentId?: string }?])), loadGatewaySessionRow: (...args: unknown[]) => loadGatewaySessionRowMock(...args), }; diff --git a/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts b/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts index aa5c802d4bc0..0e3c52e735c0 100644 --- a/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts +++ b/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts @@ -17,7 +17,7 @@ vi.mock("../session-utils.js", async () => { ...actual, loadSessionEntry: (...args: unknown[]) => loadSessionEntryMock(...(args as [string, { agentId?: string }?])), - loadSessionEntryReadOnly: (...args: unknown[]) => + loadGatewaySessionEntryReadOnly: (...args: unknown[]) => loadSessionEntryMock(...(args as [string, { agentId?: string }?])), }; }); diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index a0b6999fa915..0cd84f0b0735 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -11,7 +11,7 @@ import { expectSubagentFollowupReactivation } from "./subagent-followup.test-hel import type { GatewayRequestContext, RespondFn } from "./types.js"; const loadSessionEntryMock = vi.fn(); -const loadSessionEntryReadOnlyMock = vi.fn(); +const loadGatewaySessionEntryReadOnlyMock = vi.fn(); const readSessionMessageCountAsyncMock = vi.fn(); const loadGatewaySessionRowMock = vi.fn(); const resolveDeletedAgentIdFromSessionKeyMock = vi.fn(); @@ -49,7 +49,8 @@ vi.mock("../../auto-reply/reply/queue/cleanup.js", async () => { vi.mock("../session-utils.js", () => ({ loadSessionEntry: (...args: unknown[]) => loadSessionEntryMock(...args), - loadSessionEntryReadOnly: (...args: unknown[]) => loadSessionEntryReadOnlyMock(...args), + loadGatewaySessionEntryReadOnly: (...args: unknown[]) => + loadGatewaySessionEntryReadOnlyMock(...args), loadGatewaySessionRow: (...args: unknown[]) => loadGatewaySessionRowMock(...args), resolveDeletedAgentIdFromSessionKey: (...args: unknown[]) => resolveDeletedAgentIdFromSessionKeyMock(...args), @@ -113,7 +114,7 @@ function createRequestContext(overrides: Record = {}): GatewayR describe("sessions.send completed subagent follow-up status", () => { beforeEach(() => { loadSessionEntryMock.mockReset(); - loadSessionEntryReadOnlyMock.mockReset(); + loadGatewaySessionEntryReadOnlyMock.mockReset(); readSessionMessageCountAsyncMock.mockReset().mockResolvedValue(0); loadGatewaySessionRowMock.mockReset(); resolveDeletedAgentIdFromSessionKeyMock.mockReset().mockReturnValue(null); diff --git a/src/gateway/server-methods/task-suggestions.test.ts b/src/gateway/server-methods/task-suggestions.test.ts index cd48db757915..00e7c8737ec5 100644 --- a/src/gateway/server-methods/task-suggestions.test.ts +++ b/src/gateway/server-methods/task-suggestions.test.ts @@ -23,11 +23,13 @@ vi.mock("../session-utils.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadSessionEntryReadOnly: (...args: Parameters) => { + loadGatewaySessionEntryReadOnly: ( + ...args: Parameters + ) => { if (sessionReadState.mode === "throw") { throw new Error("session inspection unavailable"); } - const loaded = actual.loadSessionEntryReadOnly(...args); + const loaded = actual.loadGatewaySessionEntryReadOnly(...args); return sessionReadState.mode === "present" ? { ...loaded, entry: { sessionId: "surviving-session", updatedAt: 1 } } : loaded; diff --git a/src/gateway/server-methods/task-suggestions.ts b/src/gateway/server-methods/task-suggestions.ts index 622fad48a1e5..4b86282fa490 100644 --- a/src/gateway/server-methods/task-suggestions.ts +++ b/src/gateway/server-methods/task-suggestions.ts @@ -18,7 +18,7 @@ import { resolveSessionWorkStartError } from "../../config/sessions.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { buildDashboardSessionKey } from "../session-create-service.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import { abandonTaskSuggestionAcceptance, beginTaskSuggestionAcceptance, @@ -107,7 +107,7 @@ async function rollbackSuggestedTaskSession(params: { return false; } try { - return !loadSessionEntryReadOnly(params.key, { agentId: params.agentId }).entry; + return !loadGatewaySessionEntryReadOnly(params.key, { agentId: params.agentId }).entry; } catch { return false; } @@ -358,9 +358,9 @@ async function deliverSuggestedTaskToSourceSession(params: { const agentId = resolveSuggestionAgentId(params.suggestion, params.options); const fail = (error: NonNullable[2]>) => failSuggestedTaskDelivery({ taskId: params.taskId, options: params.options, error }); - let source: ReturnType; + let source: ReturnType; try { - source = loadSessionEntryReadOnly(params.suggestion.sessionKey, { agentId }); + source = loadGatewaySessionEntryReadOnly(params.suggestion.sessionKey, { agentId }); } catch (error) { return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); } diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index 78d6fa251088..3df04d4830a5 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -25,4 +25,4 @@ export { getActivePluginRegistryVersion, } from "../../plugins/runtime.js"; export { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; -export { loadSessionEntryReadOnly, resolveSessionModelRef } from "../session-utils.js"; +export { loadGatewaySessionEntryReadOnly, resolveSessionModelRef } from "../session-utils.js"; diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 76ff04487639..532824ef8795 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -75,7 +75,7 @@ const runtimeMocks = vi.hoisted(() => ({ vi.mock("./tools-effective.runtime.js", () => ({ ...runtimeMocks, - loadSessionEntryReadOnly: runtimeMocks.loadSessionEntry, + loadGatewaySessionEntryReadOnly: runtimeMocks.loadSessionEntry, })); const nodePluginToolSnapshotMocks = vi.hoisted(() => ({ diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 2fbe974c4c3b..91190a45fb46 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -29,7 +29,7 @@ import { getActivePluginRegistryVersion, getRegisteredAgentHarness, listAgentIds, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, peekSessionMcpRuntime, resolveAgentDir, resolveAgentWorkspaceDir, @@ -531,7 +531,7 @@ function resolveTrustedToolsEffectiveContext(params: { }) { // The effective tools request is read-only but security-sensitive. Derive // routing/account/model context from the persisted session, not client params. - const loaded = loadSessionEntryReadOnly( + const loaded = loadGatewaySessionEntryReadOnly( params.sessionKey, params.requestedAgentId ? { agentId: params.requestedAgentId } : undefined, ); diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 4e50230c9082..4fdd48c99c8a 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -23,7 +23,7 @@ vi.mock("../session-utils.js", async () => { const actual = await vi.importActual("../session-utils.js"); return { ...actual, - loadSessionEntryReadOnly: vi.fn(actual.loadSessionEntryReadOnly), + loadGatewaySessionEntryReadOnly: vi.fn(actual.loadGatewaySessionEntryReadOnly), loadCombinedSessionStoreForGatewayCore: vi.fn(() => ({ storePath: "(multiple)", store: {} })), }; }); @@ -106,7 +106,7 @@ import { } from "../../infra/session-cost-usage.js"; import { loadCombinedSessionStoreForGatewayCore, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, } from "../session-utils.js"; import { testApi, usageHandlers } from "./usage.js"; @@ -190,7 +190,7 @@ function expectSuccessfulSessionsUsage( function mockStoredSession(key: string, sessionId: string) { const entry = { sessionId, updatedAt: 1_000 }; - vi.mocked(loadSessionEntryReadOnly).mockReturnValueOnce({ + vi.mocked(loadGatewaySessionEntryReadOnly).mockReturnValueOnce({ cfg: TEST_RUNTIME_CONFIG, canonicalKey: key, entry, diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 9994721cea49..3f1b841e1fcf 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -68,7 +68,7 @@ import { } from "../session-store-key.js"; import { loadCombinedSessionStoreForGatewayCore, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, } from "../session-utils.js"; import { loadUsageStatusStaleWhileRevalidate } from "./models-auth-status-usage-cache.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -135,7 +135,7 @@ function resolveSessionUsageTarget( config: OpenClawConfig, agentIdHint?: string, ): ResolvedSessionUsageTarget | undefined { - const { canonicalKey, entry, storePath } = loadSessionEntryReadOnly( + const { canonicalKey, entry, storePath } = loadGatewaySessionEntryReadOnly( key, agentIdHint ? { agentId: agentIdHint } : undefined, ); diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts index 157b3f99b391..c72b548e2a86 100644 --- a/src/gateway/server-session-events.test.ts +++ b/src/gateway/server-session-events.test.ts @@ -35,7 +35,7 @@ vi.mock("./session-utils.js", () => ({ attachOpenClawTranscriptMeta: (message: unknown) => message, loadGatewaySessionRow: loadGatewaySessionRowMock, loadSessionEntry: () => ({ entry: undefined, storePath: "" }), - loadSessionEntryReadOnly: loadGatewaySessionEntryReadOnlyMock, + loadGatewaySessionEntryReadOnly: loadGatewaySessionEntryReadOnlyMock, })); vi.mock("./session-transcript-readers.js", async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index febec53791ff..d437494e3c5d 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -35,7 +35,7 @@ import { } from "./session-transcript-readers.js"; import { loadGatewaySessionRow, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, type GatewaySessionRow, } from "./session-utils.js"; @@ -84,7 +84,7 @@ function readTranscriptUpdateLifecycleOwner( const storePath = normalizeOptionalString(update.target?.storePath) ?? marker?.storePath; const entry = storePath ? loadAccessorSessionEntryReadOnly({ agentId, sessionKey, storePath }) - : loadSessionEntryReadOnly(sessionKey, agentId ? { agentId } : undefined)?.entry; + : loadGatewaySessionEntryReadOnly(sessionKey, agentId ? { agentId } : undefined)?.entry; if (!entry || (sessionId && entry.sessionId !== sessionId)) { return undefined; } @@ -300,7 +300,7 @@ async function handleTranscriptUpdateBroadcast( }), storePath: updateStorePath, } - : loadSessionEntryReadOnly(sessionKey, { agentId: routingAgentId }); + : loadGatewaySessionEntryReadOnly(sessionKey, { agentId: routingAgentId }); const entry = fallbackTarget?.entry; const messageSessionId = compatibleLegacyMarker?.sessionId ?? diff --git a/src/gateway/session-companion-context.ts b/src/gateway/session-companion-context.ts index 25eb66e09a8f..ee81848baf42 100644 --- a/src/gateway/session-companion-context.ts +++ b/src/gateway/session-companion-context.ts @@ -12,7 +12,7 @@ import type { SessionCompanionContextMessage, SessionCompanionPreparedContext, } from "./session-companion-state.js"; -import { loadSessionEntryReadOnly } from "./session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "./session-utils.js"; const CONTEXT_MAX_MESSAGES = 40; const CONTEXT_MAX_BYTES = 24 * 1024; @@ -123,7 +123,7 @@ async function readSessionCompanionContext(params: { sessionKey: string; signal?: AbortSignal; }): Promise { - const loaded = loadSessionEntryReadOnly(params.sessionKey, { agentId: params.agentId }); + const loaded = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId: params.agentId }); const sessionId = loaded.entry?.sessionId?.trim(); if (!sessionId) { return { kind: "missing" }; @@ -229,6 +229,6 @@ async function readSessionCompanionContext(params: { export const defaultSessionCompanionContextReader: SessionCompanionContextReader = { currentSessionId: ({ agentId, sessionKey }) => - loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.sessionId?.trim() || undefined, + loadGatewaySessionEntryReadOnly(sessionKey, { agentId }).entry?.sessionId?.trim() || undefined, read: readSessionCompanionContext, }; diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 345f309ce4b9..a08cf8d9c8d5 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -85,7 +85,10 @@ import { resolvePluginSessionOwnershipError } from "./session-plugin-ownership.j import { resolveRequestedSessionAgentId } from "./session-request-agent.js"; import { isSessionVisibilityAllowed, resolveSessionVisibility } from "./session-sharing.js"; import { resolveSessionStoreKey } from "./session-store-key.js"; -import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js"; +import { + loadGatewaySessionEntryReadOnly, + resolveGatewaySessionStoreTarget, +} from "./session-utils.js"; import { projectSessionsPatchEntry, resolveSessionPatchModelSelection } from "./sessions-patch.js"; type TrustedCatalogSessionTarget = { @@ -384,7 +387,7 @@ export async function createGatewaySession(params: { agentId, storePath: durableStorePath, }).some(({ sessionKey }) => sessionKey === explicitTargetKey); - if (durableEntryExists || loadSessionEntryReadOnly(explicitTargetKey).entry) { + if (durableEntryExists || loadGatewaySessionEntryReadOnly(explicitTargetKey).entry) { return { ok: false, error: errorShape( @@ -491,7 +494,7 @@ export async function createGatewaySession(params: { } parentSelectedAgentId = parentRequestedAgent.agentId; } - const parent = loadSessionEntryReadOnly( + const parent = loadGatewaySessionEntryReadOnly( parentSessionKey, parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, ); @@ -708,7 +711,7 @@ export async function createGatewaySession(params: { params.fork === true || params.authorizedPluginId !== undefined) ) { - const currentParent = loadSessionEntryReadOnly( + const currentParent = loadGatewaySessionEntryReadOnly( canonicalParentSessionKey, parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, ); @@ -793,7 +796,7 @@ export async function createGatewaySession(params: { } const target = creationTarget; - const currentTargetEntry = loadSessionEntryReadOnly(target.canonicalKey, { + const currentTargetEntry = loadGatewaySessionEntryReadOnly(target.canonicalKey, { agentId: target.agentId, }).entry; const preparationResult = params.prepareLifecycle diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 193a0715fa98..8b3f4cea3553 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -36,7 +36,7 @@ import { listSessionsFromStore, listSessionsFromStoreAsync, loadSessionEntry, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, resolveCanonicalGatewaySessionStoreKey, resolveDeletedAgentIdFromSessionKey, resolveGatewayModelSupportsImages, @@ -2210,7 +2210,7 @@ describe("gateway session utils", () => { } }); - test("loadSessionEntryReadOnly does not materialize a missing configured agent", async () => { + test("loadGatewaySessionEntryReadOnly does not materialize a missing configured agent", async () => { resetConfigRuntimeState(); try { await withStateDirEnv("session-utils-load-entry-read-only-", async ({ stateDir }) => { @@ -2223,7 +2223,7 @@ describe("gateway session utils", () => { } as OpenClawConfig; setRuntimeConfigSnapshot(cfg, cfg); - const loaded = loadSessionEntryReadOnly("agent:missing:main"); + const loaded = loadGatewaySessionEntryReadOnly("agent:missing:main"); expect(loaded.entry).toBeUndefined(); expect(fs.existsSync(path.join(stateDir, "agents", "missing"))).toBe(false); @@ -2233,7 +2233,7 @@ describe("gateway session utils", () => { } }); - test("loadSessionEntryReadOnly clones only the selected row and direct children", async () => { + test("loadGatewaySessionEntryReadOnly clones only the selected row and direct children", async () => { resetConfigRuntimeState(); try { await withStateDirEnv("session-utils-exact-read-only-", async ({ stateDir }) => { @@ -2261,7 +2261,7 @@ describe("gateway session utils", () => { ).toContain(childKey); const cloneSpy = vi.spyOn(globalThis, "structuredClone"); try { - expect(loadSessionEntryReadOnly(childKey, { clone: false }).entry).toMatchObject({ + expect(loadGatewaySessionEntryReadOnly(childKey, { clone: false }).entry).toMatchObject({ sessionId: "child", spawnedBy: parentKey, }); @@ -2273,7 +2273,7 @@ describe("gateway session utils", () => { storePath, }).map((item) => item.sessionKey), ).toEqual([childKey]); - const loaded = loadSessionEntryReadOnly("main", { + const loaded = loadGatewaySessionEntryReadOnly("main", { includeStoreChildEntries: true, }); @@ -2322,7 +2322,7 @@ describe("gateway session utils", () => { expect(spawnedByReads).toBe(1); }); - test("loadSessionEntryReadOnly rejects a persisted main alias", async () => { + test("loadGatewaySessionEntryReadOnly rejects a persisted main alias", async () => { resetConfigRuntimeState(); try { await withStateDirEnv("session-utils-exact-alias-children-", async ({ stateDir }) => { @@ -2345,7 +2345,7 @@ describe("gateway session utils", () => { setRuntimeConfigSnapshot(cfg, cfg); expect(() => - loadSessionEntryReadOnly("main", { + loadGatewaySessionEntryReadOnly("main", { clone: false, includeStoreChildEntries: true, }), diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index efb0049f895c..d97dbdd74807 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -15,7 +15,7 @@ export { loadCombinedSessionStoreForGatewayCore } from "../config/sessions/combi export { deriveSessionTitle } from "./session-utils-core.js"; export { resolveDeletedAgentIdFromSessionKey } from "./session-utils-store.js"; export { loadGatewaySessionEntry as loadSessionEntry } from "./session-utils-store.js"; -export { loadGatewaySessionEntryReadOnly as loadSessionEntryReadOnly } from "./session-utils-store.js"; +export { loadGatewaySessionEntryReadOnly } from "./session-utils-store.js"; export { resolveCanonicalSessionStoreMatchFromStoreKeys } from "./session-utils-store.js"; export { resolveCanonicalSessionEntryFromStoreKeys } from "./session-utils-store.js"; export { resolveCanonicalGatewaySessionStoreKey } from "./session-utils-store.js"; diff --git a/src/gateway/worker-environments/worker-session-tool-executor.test.ts b/src/gateway/worker-environments/worker-session-tool-executor.test.ts index b13c4ec90d0f..382bcf8fb04f 100644 --- a/src/gateway/worker-environments/worker-session-tool-executor.test.ts +++ b/src/gateway/worker-environments/worker-session-tool-executor.test.ts @@ -28,7 +28,7 @@ vi.mock("../session-utils.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadSessionEntryReadOnly: (sessionKey: string) => ({ + loadGatewaySessionEntryReadOnly: (sessionKey: string) => ({ canonicalKey: sessionKey, entry: structuredClone(sessionEntries.get(sessionKey)), }), diff --git a/src/gateway/worker-environments/worker-session-tool-executor.ts b/src/gateway/worker-environments/worker-session-tool-executor.ts index 003a75b5467d..a603697cef50 100644 --- a/src/gateway/worker-environments/worker-session-tool-executor.ts +++ b/src/gateway/worker-environments/worker-session-tool-executor.ts @@ -23,7 +23,7 @@ import { sha256Base64Url } from "../../infra/crypto-digest.js"; import { redactSensitiveText } from "../../logging/redact.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import type { WorkerConnectionIdentity } from "./connection-identity.js"; import type { WorkerSessionPlacementStore } from "./placement-store.js"; import type { WorkerPlacementDispatchContract } from "./service-contract.js"; @@ -150,7 +150,9 @@ export function createWorkerSessionToolExecutor(params: { } throwIfAborted(operation.signal); exactSource({ identity: operation.identity, placements: params.placements }); - let loaded = loadSessionEntryReadOnly(operation.childSessionKey, { agentId: targetAgentId }); + let loaded = loadGatewaySessionEntryReadOnly(operation.childSessionKey, { + agentId: targetAgentId, + }); let createResponse: Record; let creationAttempted = false; if (loaded.entry?.sessionId) { @@ -192,7 +194,7 @@ export function createWorkerSessionToolExecutor(params: { }, ); } catch (error) { - loaded = loadSessionEntryReadOnly(operation.childSessionKey, { + loaded = loadGatewaySessionEntryReadOnly(operation.childSessionKey, { agentId: targetAgentId, }); if (!loaded.entry?.sessionId) { @@ -205,7 +207,9 @@ export function createWorkerSessionToolExecutor(params: { entry: loaded.entry, }; } - loaded = loadSessionEntryReadOnly(operation.childSessionKey, { agentId: targetAgentId }); + loaded = loadGatewaySessionEntryReadOnly(operation.childSessionKey, { + agentId: targetAgentId, + }); } const childSessionId = loaded.entry?.sessionId; if (!childSessionId) { diff --git a/src/gateway/worker-environments/worker-session-tool-topology.ts b/src/gateway/worker-environments/worker-session-tool-topology.ts index 857f49c2849c..5cf224fe8000 100644 --- a/src/gateway/worker-environments/worker-session-tool-topology.ts +++ b/src/gateway/worker-environments/worker-session-tool-topology.ts @@ -1,4 +1,4 @@ -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../session-utils.js"; import type { WorkerConnectionIdentity } from "./connection-identity.js"; import type { WorkerSessionPlacementStore } from "./placement-store.js"; @@ -14,7 +14,7 @@ export type WorkerSessionToolSource = { ownerEpoch: number; runId: string; }; - entry: NonNullable["entry"]>; + entry: NonNullable["entry"]>; }; export type WorkerSessionToolTarget = { @@ -54,7 +54,9 @@ export function resolveWorkerSessionToolSource(params: { ) { throw new Error("Worker source session placement changed"); } - const loaded = loadSessionEntryReadOnly(placement.sessionKey, { agentId: placement.agentId }); + const loaded = loadGatewaySessionEntryReadOnly(placement.sessionKey, { + agentId: placement.agentId, + }); if ( loaded.canonicalKey !== placement.sessionKey || loaded.entry?.sessionId !== identity.sessionId || @@ -83,7 +85,7 @@ export function resolveWorkerSessionToolTarget(params: { requestedSessionKey: string; placements: WorkerSessionPlacementStore; }): WorkerSessionToolTarget { - const loaded = loadSessionEntryReadOnly(params.requestedSessionKey); + const loaded = loadGatewaySessionEntryReadOnly(params.requestedSessionKey); const entry = loaded.entry; const targetSessionId = entry?.sessionId; if ( @@ -111,7 +113,7 @@ export function resolveWorkerSessionToolTarget(params: { ); const parent = sharedParentIncarnation && sourceParent && sourceParentId - ? loadSessionEntryReadOnly(sourceParent) + ? loadGatewaySessionEntryReadOnly(sourceParent) : undefined; const siblingToSibling = Boolean( parent && @@ -147,7 +149,7 @@ export function assertWorkerSessionToolChild(params: { sourceSessionId: string; targetAgentId: string; }): void { - const loaded = loadSessionEntryReadOnly(params.childSessionKey, { + const loaded = loadGatewaySessionEntryReadOnly(params.childSessionKey, { agentId: params.targetAgentId, }); const parent = diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index 6977b388124a..5d448e45c485 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -10,19 +10,19 @@ type WriteTextAtomicBeforeRename = (params: { export { JsonFileReadError, readJson, - readJson as readJsonFileStrict, + readJson as readJsonFileStrict, // Sanctioned domain alias. readJsonIfExists, - readJsonIfExists as readDurableJsonFile, + readJsonIfExists as readDurableJsonFile, // Sanctioned domain alias. readJsonSync, readRootJsonObjectSync, readRootJsonSync, readRootStructuredFileSync, tryReadJson, - tryReadJson as readJsonFile, + tryReadJson as readJsonFile, // Sanctioned domain alias. tryReadJsonSync, - tryReadJsonSync as readJsonFileSync, + tryReadJsonSync as readJsonFileSync, // Sanctioned domain alias. writeJson, - writeJson as writeJsonAtomic, + writeJson as writeJsonAtomic, // Sanctioned domain alias. writeJsonSync, } from "@openclaw/fs-safe/json"; diff --git a/src/infra/openclaw-root.fs.runtime.ts b/src/infra/openclaw-root.fs.runtime.ts index 3e0ff74996b7..5392374bbd4f 100644 --- a/src/infra/openclaw-root.fs.runtime.ts +++ b/src/infra/openclaw-root.fs.runtime.ts @@ -1,4 +1,4 @@ // OpenClaw root resolution imports fs through this facade so tests can replace // filesystem behavior without mocking node:fs globally. -export { default as openClawRootFsSync } from "node:fs"; -export { default as openClawRootFs } from "node:fs/promises"; +export { default as openClawRootFsSync } from "node:fs"; // Sanctioned domain alias. +export { default as openClawRootFs } from "node:fs/promises"; // Sanctioned domain alias. diff --git a/src/infra/secret-file.ts b/src/infra/secret-file.ts index 965bfa878930..38685dccdc80 100644 --- a/src/infra/secret-file.ts +++ b/src/infra/secret-file.ts @@ -17,7 +17,7 @@ export { readSecretFileSync, type SecretFileReadOptions, } from "@openclaw/fs-safe/secret"; -export { writeSecretFileAtomic as writePrivateSecretFileAtomic } from "@openclaw/fs-safe/secret"; +export { writeSecretFileAtomic as writePrivateSecretFileAtomic } from "@openclaw/fs-safe/secret"; // Sanctioned domain alias. export type SecretFileReadResult = | { diff --git a/src/infra/state-migrations.workspace-setup-receipts.ts b/src/infra/state-migrations.workspace-setup-receipts.ts index 221bab43db22..4e09444fba68 100644 --- a/src/infra/state-migrations.workspace-setup-receipts.ts +++ b/src/infra/state-migrations.workspace-setup-receipts.ts @@ -5,7 +5,7 @@ import { } from "./state-migrations.receipts.js"; import type { LegacyWorkspaceStateSource } from "./state-migrations.workspace-setup.types.js"; -export { markLegacyMigrationSourceRemoved as markSourceRemoved } from "./state-migrations.receipts.js"; +export { markLegacyMigrationSourceRemoved } from "./state-migrations.receipts.js"; export type MigrationReceipt = { sourceKey: string; diff --git a/src/infra/state-migrations.workspace-setup.ts b/src/infra/state-migrations.workspace-setup.ts index 4e59eae55f6f..3bcf645776bf 100644 --- a/src/infra/state-migrations.workspace-setup.ts +++ b/src/infra/state-migrations.workspace-setup.ts @@ -27,7 +27,7 @@ import { } from "./state-migrations.source-snapshot.js"; import type { MigrationMessages } from "./state-migrations.types.js"; import { - markSourceRemoved, + markLegacyMigrationSourceRemoved, readReceipt, type MigrationReceipt, } from "./state-migrations.workspace-setup-receipts.js"; @@ -386,7 +386,7 @@ async function cleanupReceiptSource(params: { const hasClaim = await sourceClaim.exists(true); if (!hasSource && !hasClaim) { if (!params.receipt.removedSource) { - markSourceRemoved(params.receipt.sourceKey, params.env); + markLegacyMigrationSourceRemoved(params.receipt.sourceKey, params.env); } return { changes: [], warnings: [] }; } @@ -433,7 +433,7 @@ async function cleanupReceiptSource(params: { } assertConfiguredWorkspaceIdentity(params.source); await sourceClaim.remove({ skipSourceCheck: true }); - markSourceRemoved(params.receipt.sourceKey, params.env); + markLegacyMigrationSourceRemoved(params.receipt.sourceKey, params.env); return { changes: [], warnings: [], @@ -564,7 +564,7 @@ async function migrateOneSource(params: { throw new Error("legacy workspace claim changed after import"); } await sourceClaim.remove({ removeSource: params.removeSource, skipSourceCheck: true }); - markSourceRemoved(result.sourceKey, params.env); + markLegacyMigrationSourceRemoved(result.sourceKey, params.env); } catch (error) { return { changes: [], diff --git a/src/media-generation/model-ref.ts b/src/media-generation/model-ref.ts index c3500213a1b9..ea4be27cc2f3 100644 --- a/src/media-generation/model-ref.ts +++ b/src/media-generation/model-ref.ts @@ -1,3 +1,3 @@ -export { parseGenerationModelRef as parseImageGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; -export { parseGenerationModelRef as parseMusicGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; -export { parseGenerationModelRef as parseVideoGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; +export { parseGenerationModelRef as parseImageGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; // Sanctioned domain alias. +export { parseGenerationModelRef as parseMusicGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; // Sanctioned domain alias. +export { parseGenerationModelRef as parseVideoGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; // Sanctioned domain alias. diff --git a/src/plugin-sdk/command-status.runtime.test.ts b/src/plugin-sdk/command-status.runtime.test.ts index 519ecd7544fc..f12326c72132 100644 --- a/src/plugin-sdk/command-status.runtime.test.ts +++ b/src/plugin-sdk/command-status.runtime.test.ts @@ -17,7 +17,7 @@ vi.mock("../auto-reply/reply/commands-status.js", () => ({ })); vi.mock("../gateway/session-utils.js", () => ({ - loadSessionEntryReadOnly: loadSessionEntry, + loadGatewaySessionEntryReadOnly: loadSessionEntry, })); vi.mock("../agents/agent-scope.js", () => ({ diff --git a/src/plugin-sdk/command-status.runtime.ts b/src/plugin-sdk/command-status.runtime.ts index 257c8c739c80..f434f972c3a9 100644 --- a/src/plugin-sdk/command-status.runtime.ts +++ b/src/plugin-sdk/command-status.runtime.ts @@ -8,7 +8,7 @@ import { resolveCurrentDirectiveLevels } from "../auto-reply/reply/directive-han import { createModelSelectionState } from "../auto-reply/reply/model-selection.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadSessionEntryReadOnly } from "../gateway/session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../gateway/session-utils.js"; /** Inputs for rendering direct-session status replies outside the active channel turn. */ export type ResolveDirectStatusReplyForSessionParams = { @@ -43,7 +43,7 @@ export async function resolveDirectStatusReplyForSessionCore( return undefined; } - const statusLoaded = loadSessionEntryReadOnly(requestedSessionKey); + const statusLoaded = loadGatewaySessionEntryReadOnly(requestedSessionKey); const statusCfg = statusLoaded.cfg ?? params.cfg; const statusSessionKey = statusLoaded.canonicalKey; const statusEntry = statusLoaded.entry; diff --git a/src/transcripts/provider-registry.ts b/src/transcripts/provider-registry.ts index 23e1198991ae..d6b6cb32211f 100644 --- a/src/transcripts/provider-registry.ts +++ b/src/transcripts/provider-registry.ts @@ -1,5 +1,5 @@ import { createMediaProviderRegistry } from "../media-generation/provider-registry.js"; -export { normalizeCapabilityProviderId as normalizeTranscriptSourceProviderId } from "../plugins/provider-registry-shared.js"; +export { normalizeCapabilityProviderId as normalizeTranscriptSourceProviderId } from "../plugins/provider-registry-shared.js"; // Sanctioned domain alias. /** Transcript providers use targeted lookup to avoid broad capability discovery. */ export const { diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 15acce40750e..2dc3c5b90ade 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -232,7 +232,7 @@ vi.mock("../gateway/session-utils.js", () => ({ loadCombinedSessionStoreForGatewayMock(...args), loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) => loadSessionEntryMock(sessionKey, opts), - loadSessionEntryReadOnly: (sessionKey: string, opts?: { agentId?: string }) => + loadGatewaySessionEntryReadOnly: (sessionKey: string, opts?: { agentId?: string }) => loadSessionEntryMock(sessionKey, opts), resolveCanonicalGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key, diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 42babba3c00a..7acaf4248152 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -75,7 +75,7 @@ import { listSessionsFromStoreAsync, loadCombinedSessionStoreForGatewayCore, loadSessionEntry, - loadSessionEntryReadOnly, + loadGatewaySessionEntryReadOnly, resolveCanonicalGatewaySessionStoreKey, resolveGatewaySessionStoreTargetWithStore, resolveSessionModelRef, @@ -627,7 +627,7 @@ export class EmbeddedTuiBackend implements TuiBackend { async loadHistory(opts: { sessionKey: string; agentId?: string; limit?: number }) { await this.ready; const loadOptions = opts.agentId ? { agentId: opts.agentId } : undefined; - const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntryReadOnly( + const { cfg, storePath, store, entry, canonicalKey } = loadGatewaySessionEntryReadOnly( opts.sessionKey, { ...loadOptions, includeStoreChildEntries: true }, ); @@ -807,7 +807,7 @@ export class EmbeddedTuiBackend implements TuiBackend { async resetSession(key: string, reason?: "new" | "reset", opts?: { agentId?: string }) { await this.ready; - if (loadSessionEntryReadOnly(key, opts).entry?.incognito === true) { + if (loadGatewaySessionEntryReadOnly(key, opts).entry?.incognito === true) { throw new Error("Incognito sessions cannot reset in place."); } const result = await performGatewaySessionReset({ diff --git a/test/e2e/qa-lab/runtime/gateway-usage-memory-apis.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-usage-memory-apis.e2e.test.ts index af32eb9bc65b..e0a71eb10aa7 100644 --- a/test/e2e/qa-lab/runtime/gateway-usage-memory-apis.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-usage-memory-apis.e2e.test.ts @@ -15,7 +15,7 @@ import { READ_SCOPE } from "../../../../src/gateway/method-scopes.js"; import { clearModelAuthStatusUsageCache } from "../../../../src/gateway/server-methods/models-auth-status-usage-cache.js"; import { testApi as usageTestApi } from "../../../../src/gateway/server-methods/usage.js"; import { startGatewayServer } from "../../../../src/gateway/server.js"; -import { loadSessionEntryReadOnly } from "../../../../src/gateway/session-utils.js"; +import { loadGatewaySessionEntryReadOnly } from "../../../../src/gateway/session-utils.js"; import { connectGatewayClient, disconnectGatewayClient, @@ -204,7 +204,7 @@ describe("gateway usage and memory APIs", () => { sessionId: FIXTURE_SESSION_ID, storePath: databasePath, }); - const storedSession = loadSessionEntryReadOnly(FIXTURE_SESSION_KEY); + const storedSession = loadGatewaySessionEntryReadOnly(FIXTURE_SESSION_KEY); expect(storedSession).toMatchObject({ entry: { sessionId: FIXTURE_SESSION_ID, diff --git a/test/scripts/changed-path-facts.test.ts b/test/scripts/changed-path-facts.test.ts index d8dfd89d14a8..de19ff1c64bf 100644 --- a/test/scripts/changed-path-facts.test.ts +++ b/test/scripts/changed-path-facts.test.ts @@ -41,6 +41,12 @@ describe("changed path facts", () => { isTestOnly: true, isNativeOnly: false, }); + expect(getChangedPathFacts("src/gateway/server.auth.control-ui.suite.ts")).toMatchObject({ + surface: "source", + isChangedLaneTest: true, + isTestOnly: true, + isNativeOnly: false, + }); expect(getChangedPathFacts("apps/shared/OpenClawKit/Sources/Foo.swift")).toMatchObject({ surface: "app", isChangedLaneTest: false, diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index 7acdc9084cf5..efbe456b9b20 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -78,6 +78,9 @@ describe("CI changed Node test plan", () => { expect(hasBuildArtifactAffectingChange(["src/agents/foo.test.ts", "test/helpers/x.ts"])).toBe( false, ); + expect(hasBuildArtifactAffectingChange(["src/gateway/server.auth.control-ui.suite.ts"])).toBe( + false, + ); expect(hasBuildArtifactAffectingChange(["src/agents/foo.ts"])).toBe(true); // Build-input classification: only sources and the build pipeline can // change dist bytes; repo scripts, workflows, and qa scenarios cannot. diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index 6b37f5e5fad2..fd89bc9778a2 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -210,8 +210,8 @@ describe("oxlint config", () => { }, { files: [ - "**/*.test.ts", - "**/*.test.tsx", + "**/*.{test,suite}.ts", + "**/*.{test,suite}.tsx", "**/*.e2e.test.ts", "**/*.live.test.ts", "**/*test-harness.ts", @@ -246,6 +246,17 @@ describe("oxlint config", () => { expect(override.excludeFiles).toContain("ui/src/i18n/locales/**"); expect(override.excludeFiles).toContain("src/wizard/i18n/locales/**"); } + for (const override of scopedBudgets.slice(0, 3)) { + expect(override.excludeFiles).toContain("**/*.{test,spec,suite}.*"); + } + expect(scopedBudgets[3]?.files).toEqual( + expect.arrayContaining([ + "src/**/*.{test,spec,suite}.*", + "ui/src/**/*.{test,spec,suite}.*", + "packages/**/*.{test,spec,suite}.*", + "extensions/**/*.{test,spec,suite}.*", + ]), + ); expect(exactExceptions).toEqual([ { files: ["extensions/copilot/src/event-bridge.ts"], From 35d764076e09a8d7f9c4e2c3495eb213415e69db Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:19:17 -0500 Subject: [PATCH 008/165] fix(audit): ignore inherited execution identity evidence (#122418) * fix(audit): require owned admission data * docs(agents): preserve owned admission invariant --- AGENTS.md | 2 +- src/audit/audit-event-writer.test.ts | 192 ++++++-- .../execution-identity-admission.test.ts | 455 ++++++++++++++++++ src/audit/execution-identity-admission.ts | 245 +++++----- 4 files changed, 753 insertions(+), 141 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6780fca92ed..ce6f603f0d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m - Execution identity is opt-in diagnostic provenance, never authorization or enforcement. Unknown facts stay unknown; record ingress or invoker facts only at their authoritative producer. Never infer identity from session keys, `runId`, or routing metadata. - Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence. - Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent. -- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. +- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. - Raw identity references are transient worker-message data. Never persist, export, inspect, or log them. Public Plugin SDK ingress must strip private recovery/admission authority, including JavaScript extra and inherited properties. - Default or disabled collection creates and propagates no identity token and does not create optional storage. Existing-storage maintenance may continue. Reads enforce expiry before projection; missing or expired evidence never proves no run occurred. - `audit.run.inspect` intentionally uses `operator.read` within one trusted Gateway domain. Reader isolation requires separate domains. Ask before changing this scope, default-off behavior, retained fields, 30-day cutoff, maintenance/row bounds, or schema/protocol contract. diff --git a/src/audit/audit-event-writer.test.ts b/src/audit/audit-event-writer.test.ts index b811c80313ed..586476ee05b5 100644 --- a/src/audit/audit-event-writer.test.ts +++ b/src/audit/audit-event-writer.test.ts @@ -19,6 +19,11 @@ import { processExecutionIdentityAdmissionWork, } from "./execution-identity-context.js"; +function defineObjectPrototypeProperties(descriptors: PropertyDescriptorMap): void { + // oxlint-disable-next-line no-extend-native -- Exercise hostile prototype pollution across the real worker boundary. + Object.defineProperties(Object.prototype, descriptors); +} + function captureExecutionIdentityAdmissionEnvelope( facts: ExecutionIdentityAdmissionFacts, options: { @@ -229,45 +234,151 @@ describe("audit event worker", () => { } }); - it("preserves explicit unknown invoker evidence through the worker clone boundary", async () => { + it("persists owned unknown and omits inherited evidence through the worker clone boundary", async () => { const stateDir = tempDirs.make("openclaw-audit-writer-"); const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; const errors: string[] = []; const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); const clearSink = configureExecutionIdentityAdmissionSink(writer.recordExecutionIdentity); const admittedAt = Date.now(); - - expect( - enqueueExecutionIdentityContextAtAdmission( - { - runId: "unknown-invoker-run", - agentId: "main", - ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, - runtime: { kind: "embedded" }, - invoker: { state: "unknown" }, - }, - { - enabled: true, - contextId: "unknown-invoker-context", - executionId: "unknown-invoker-execution", - now: admittedAt, - runtimeInstanceId: "private-runtime-reference", - }, - ), - ).toEqual({ - candidateContextId: "unknown-invoker-context", - candidateExecutionId: "unknown-invoker-execution", - accepted: true, - }); - clearSink(); - await writer.stop(); - - const inspected = inspectExecutionIdentityRun( - { executionId: "unknown-invoker-execution" }, - { ...database, now: admittedAt }, + const inheritedRefs = { + invoker: "raw-inherited-principal", + applicableGrants: "raw-inherited-grant", + assurance: "raw-inherited-assurance", + rawSourceRef: "raw-inherited-source", + } as const; + const prior = new Map( + Object.keys(inheritedRefs).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ]), ); + let inheritedInvokerReads = 0; + + try { + try { + defineObjectPrototypeProperties({ + invoker: { + configurable: true, + enumerable: false, + get: () => { + inheritedInvokerReads += 1; + return { + state: "present", + kind: "local-account", + rawPrincipalRef: inheritedRefs.invoker, + }; + }, + }, + applicableGrants: { + configurable: true, + enumerable: false, + value: [{ rawGrantRef: inheritedRefs.applicableGrants, state: "present" }], + }, + assurance: { + configurable: true, + enumerable: false, + value: [ + { + kind: "other", + rawEvidenceRef: inheritedRefs.assurance, + strength: "self-asserted", + }, + ], + }, + rawSourceRef: { + configurable: true, + enumerable: false, + value: inheritedRefs.rawSourceRef, + }, + }); + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "absent-invoker-run", + agentId: "main", + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + }, + runtime: { kind: "embedded" }, + }, + { + enabled: true, + contextId: "absent-invoker-context", + executionId: "absent-invoker-execution", + now: admittedAt, + runtimeInstanceId: "private-absent-runtime-reference", + }, + ), + ).toEqual({ + candidateContextId: "absent-invoker-context", + candidateExecutionId: "absent-invoker-execution", + accepted: true, + }); + } finally { + for (const [key, descriptor] of prior) { + if (descriptor) { + defineObjectPrototypeProperties({ [key]: descriptor }); + } else { + delete (Object.prototype as Record)[key]; + } + } + } + + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "unknown-invoker-run", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + runtime: { kind: "embedded" }, + invoker: { state: "unknown" }, + }, + { + enabled: true, + contextId: "unknown-invoker-context", + executionId: "unknown-invoker-execution", + now: admittedAt + 1, + runtimeInstanceId: "private-unknown-runtime-reference", + }, + ), + ).toEqual({ + candidateContextId: "unknown-invoker-context", + candidateExecutionId: "unknown-invoker-execution", + accepted: true, + }); + } finally { + clearSink(); + await writer.stop(); + } + + const absentInspection = inspectExecutionIdentityRun( + { executionId: "absent-invoker-execution" }, + { ...database, now: admittedAt + 1 }, + ); + const unknownInspection = inspectExecutionIdentityRun( + { executionId: "unknown-invoker-execution" }, + { ...database, now: admittedAt + 1 }, + ); + expect(inheritedInvokerReads).toBe(0); expect(errors).toEqual([]); - expect(inspected).toMatchObject({ + expect(absentInspection).toMatchObject({ + identity: { + state: "present", + context: { + invoker: { state: "absent" }, + ingress: { state: "present" }, + applicableGrants: [], + assurance: [{ kind: "runtime-binding", strength: "boundary-verified" }], + coverageState: "unattributed", + missingEvidence: ["invoker.principal"], + }, + }, + coverage: { state: "unattributed", missingEvidence: ["invoker.principal"] }, + }); + expect(unknownInspection).toMatchObject({ identity: { state: "present", context: { @@ -278,7 +389,24 @@ describe("audit event worker", () => { }, coverage: { state: "unknown", missingEvidence: ["invoker.principal"] }, }); - expect(JSON.stringify(inspected)).not.toContain("private-runtime-reference"); + const persisted = openOpenClawStateDatabase(database) + .db.prepare( + "SELECT context_json FROM execution_identity_contexts WHERE execution_id IN (?, ?) ORDER BY execution_id", + ) + .all("absent-invoker-execution", "unknown-invoker-execution") as Array<{ + context_json: string; + }>; + const publicAndStored = JSON.stringify({ + errors, + absentInspection, + unknownInspection, + persisted, + }); + for (const rawRef of Object.values(inheritedRefs)) { + expect(publicAndStored).not.toContain(rawRef); + } + expect(publicAndStored).not.toContain("private-absent-runtime-reference"); + expect(publicAndStored).not.toContain("private-unknown-runtime-reference"); }); it("prunes expired identity contexts before preserving exact-envelope conflicts", async () => { diff --git a/src/audit/execution-identity-admission.test.ts b/src/audit/execution-identity-admission.test.ts index 62b15df291bd..662be64231a8 100644 --- a/src/audit/execution-identity-admission.test.ts +++ b/src/audit/execution-identity-admission.test.ts @@ -5,6 +5,7 @@ import { enqueueExecutionIdentityContextAtAdmission, hasExecutionIdentityAdmissionSink, parseExecutionIdentityAdmissionEnvelope, + parseExecutionIdentityAdmissionWork, type ExecutionIdentityAdmissionEnvelope, type ExecutionIdentityAdmissionFacts, type ExecutionIdentityAdmissionWork, @@ -13,6 +14,22 @@ import { const ADMISSION_MAX_BYTES = 16 * 1024; const ADMISSION_MAX_ITEMS = 16; +function defineObjectPrototypeProperty(key: string, descriptor: PropertyDescriptor): void { + // oxlint-disable-next-line no-extend-native -- Exercise hostile prototype pollution at the admission boundary. + Object.defineProperty(Object.prototype, key, descriptor); +} + +function restoreObjectPrototypeProperty( + key: string, + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor) { + defineObjectPrototypeProperty(key, descriptor); + } else { + delete (Object.prototype as Record)[key]; + } +} + function facts(overrides: Partial = {}) { return { runId: "run-1", @@ -150,6 +167,379 @@ describe("execution identity admission envelope", () => { } }); + it("omits inherited outer evidence instead of projecting it", () => { + const inheritedRefs = { + invoker: { state: "unknown" }, + applicableGrants: [{ rawGrantRef: "inherited-grant", state: "present" }], + assurance: [ + { + kind: "other", + rawEvidenceRef: "inherited-assurance", + strength: "self-asserted", + }, + ], + } as const; + const prior = new Map( + Object.keys(inheritedRefs).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ]), + ); + let envelope: ExecutionIdentityAdmissionEnvelope; + try { + for (const [key, value] of Object.entries(inheritedRefs)) { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + value, + writable: true, + }); + } + envelope = captureEnvelope(facts(), { + contextId: "context-inherited", + executionId: "execution-inherited", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + } finally { + for (const [key, descriptor] of prior) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + + expect(Object.hasOwn(envelope!, "invoker")).toBe(false); + expect(envelope!.applicableGrants).toEqual([]); + expect(envelope!.assurance).toEqual([ + { + kind: "runtime-binding", + rawEvidenceRef: "runtime-owned", + strength: "boundary-verified", + }, + ]); + }); + + it("never reads inherited accessors while treating optional evidence as omitted", () => { + const keys = ["invoker", "applicableGrants", "assurance"] as const; + const prior = new Map( + keys.map((key) => [key, Object.getOwnPropertyDescriptor(Object.prototype, key)]), + ); + const getterReads = new Map(keys.map((key) => [key, 0])); + let envelope: ExecutionIdentityAdmissionEnvelope; + try { + for (const key of keys) { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + getterReads.set(key, getterReads.get(key)! + 1); + return key === "invoker" + ? { state: "unknown" } + : key === "applicableGrants" + ? [{ rawGrantRef: "inherited-grant", state: "present" }] + : [ + { + kind: "other", + rawEvidenceRef: "inherited-assurance", + strength: "self-asserted", + }, + ]; + }, + }); + } + envelope = captureEnvelope(facts(), { + contextId: "context-inherited-getter", + executionId: "execution-inherited-getter", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + } finally { + for (const [key, descriptor] of prior) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + + expect(Object.fromEntries(getterReads)).toEqual({ + invoker: 0, + applicableGrants: 0, + assurance: 0, + }); + expect(Object.hasOwn(envelope!, "invoker")).toBe(false); + expect(envelope!.applicableGrants).toEqual([]); + expect(envelope!.assurance).toEqual([ + { + kind: "runtime-binding", + rawEvidenceRef: "runtime-owned", + strength: "boundary-verified", + }, + ]); + }); + + it.each([ + { + name: "ingress state", + key: "state", + value: "unknown", + admissionFacts: () => facts(), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(envelope.ingress.state).toBe("present"); + }, + }, + { + name: "ingress source", + key: "rawSourceRef", + value: "inherited-source", + admissionFacts: () => facts(), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(Object.hasOwn(envelope.ingress, "rawSourceRef")).toBe(false); + }, + }, + { + name: "invoker label", + key: "displayLabel", + value: "inherited-label", + admissionFacts: () => + facts({ + invoker: { + state: "present", + kind: "local-account", + rawPrincipalRef: "owned-principal", + }, + }), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(envelope.invoker?.state).toBe("present"); + expect(Object.hasOwn(envelope.invoker!, "displayLabel")).toBe(false); + }, + }, + ])("omits inherited optional $name data", ({ key, value, admissionFacts, assertOmitted }) => { + const prior = Object.getOwnPropertyDescriptor(Object.prototype, key); + let dataEnvelope: ExecutionIdentityAdmissionEnvelope; + let getterEnvelope: ExecutionIdentityAdmissionEnvelope; + let getterReads = 0; + try { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + value, + writable: true, + }); + dataEnvelope = captureEnvelope(admissionFacts(), { + contextId: `context-${key}`, + executionId: `execution-${key}`, + now: 1, + runtimeInstanceId: "runtime-owned", + }); + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + getterReads += 1; + return value; + }, + }); + getterEnvelope = captureEnvelope(admissionFacts(), { + contextId: `context-${key}-getter`, + executionId: `execution-${key}-getter`, + now: 2, + runtimeInstanceId: "runtime-owned", + }); + } finally { + restoreObjectPrototypeProperty(key, prior); + } + expect(getterReads).toBe(0); + assertOmitted(dataEnvelope!); + assertOmitted(getterEnvelope!); + }); + + it.each([ + ["outer run id", "runId", "inherited-run", () => omitOwn(facts(), "runId")], + ["outer agent id", "agentId", "inherited-agent", () => omitOwn(facts(), "agentId")], + ["outer ingress", "ingress", facts().ingress, () => omitOwn(facts(), "ingress")], + ["outer runtime", "runtime", facts().runtime, () => omitOwn(facts(), "runtime")], + [ + "ingress kind", + "kind", + "local-cli", + () => facts({ ingress: { boundary: "agent-command.local" } as never }), + ], + [ + "ingress boundary", + "boundary", + "agent-command.local", + () => facts({ ingress: { kind: "local-cli" } as never }), + ], + ["invoker state", "state", "unknown", () => facts({ invoker: {} as never })], + [ + "invoker kind", + "kind", + "local-account", + () => facts({ invoker: { state: "present", rawPrincipalRef: "owned" } as never }), + ], + [ + "invoker principal", + "rawPrincipalRef", + "inherited-principal", + () => facts({ invoker: { state: "present", kind: "local-account" } as never }), + ], + [ + "grant reference", + "rawGrantRef", + "inherited-grant", + () => facts({ applicableGrants: [{ state: "present" } as never] }), + ], + [ + "grant state", + "state", + "present", + () => facts({ applicableGrants: [{ rawGrantRef: "owned-grant" } as never] }), + ], + [ + "assurance kind", + "kind", + "other", + () => + facts({ + assurance: [{ rawEvidenceRef: "owned-evidence", strength: "self-asserted" } as never], + }), + ], + [ + "assurance reference", + "rawEvidenceRef", + "inherited-evidence", + () => facts({ assurance: [{ kind: "other", strength: "self-asserted" } as never] }), + ], + [ + "assurance strength", + "strength", + "self-asserted", + () => facts({ assurance: [{ kind: "other", rawEvidenceRef: "owned-evidence" } as never] }), + ], + ] as const)( + "rejects inherited required $0 before allocation and enqueue", + (_name, key, inheritedValue, admissionFacts) => { + const prior = Object.getOwnPropertyDescriptor(Object.prototype, key); + let inheritedReads = 0; + let allocationReads = 0; + const sink = vi.fn(() => true); + const clear = configureExecutionIdentityAdmissionSink(sink); + try { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return inheritedValue; + }, + }); + const options = { enabled: true, runtimeInstanceId: "runtime-owned" }; + Object.defineProperty(options, "contextId", { + enumerable: true, + get: () => { + allocationReads += 1; + return "must-not-allocate"; + }, + }); + expect( + enqueueExecutionIdentityContextAtAdmission(admissionFacts() as never, options), + ).toBeUndefined(); + } finally { + clear(); + restoreObjectPrototypeProperty(key, prior); + } + expect(inheritedReads).toBe(0); + expect(allocationReads).toBe(0); + expect(sink).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + name: "outer ingress", + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts, key: "ingress" }; + }, + }, + ...["invoker", "applicableGrants", "assurance"].map((key) => ({ + name: `outer ${key}`, + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts, key }; + }, + })), + ...["kind", "boundary", "state", "rawSourceRef"].map((key) => ({ + name: `ingress ${key}`, + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts.ingress, key }; + }, + })), + ...["state", "kind", "rawPrincipalRef", "displayLabel"].map((key) => ({ + name: `invoker ${key}`, + prepare: () => { + const invoker = { + state: "present" as const, + kind: "local-account" as const, + rawPrincipalRef: "owned-principal", + displayLabel: "owned-label", + }; + const admissionFacts = facts({ invoker }); + return { admissionFacts, target: invoker, key }; + }, + })), + ...["rawGrantRef", "state"].map((key) => ({ + name: `grant ${key}`, + prepare: () => { + const grant = { rawGrantRef: "owned-grant", state: "present" as const }; + const admissionFacts = facts({ applicableGrants: [grant] }); + return { admissionFacts, target: grant, key }; + }, + })), + ...["kind", "rawEvidenceRef", "strength"].map((key) => ({ + name: `assurance ${key}`, + prepare: () => { + const assurance = { + kind: "other" as const, + rawEvidenceRef: "owned-evidence", + strength: "self-asserted" as const, + }; + const admissionFacts = facts({ assurance: [assurance] }); + return { admissionFacts, target: assurance, key }; + }, + })), + ])("rejects an own accessor at $name without reading it or allocating", ({ prepare }) => { + const { admissionFacts, target, key } = prepare(); + let accessorReads = 0; + let allocationReads = 0; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get: () => { + accessorReads += 1; + return "must-not-read"; + }, + }); + const options = { enabled: true, runtimeInstanceId: "runtime-owned" }; + Object.defineProperty(options, "contextId", { + enumerable: true, + get: () => { + allocationReads += 1; + return "must-not-allocate"; + }, + }); + const sink = vi.fn(() => true); + const clear = configureExecutionIdentityAdmissionSink(sink); + try { + expect( + enqueueExecutionIdentityContextAtAdmission(admissionFacts as never, options), + ).toBeUndefined(); + } finally { + clear(); + } + expect(accessorReads).toBe(0); + expect(allocationReads).toBe(0); + expect(sink).not.toHaveBeenCalled(); + }); + it("rejects malformed, ambiguous, oversized, and noncanonical invoker variants", () => { const present = captureEnvelope( facts({ @@ -271,6 +661,65 @@ describe("execution identity admission envelope", () => { expect(accessorReads).toBe(0); }); + it("revalidates envelopes and worker messages from owned data only", () => { + const envelope = captureEnvelope(facts(), { + contextId: "context-revalidation", + executionId: "execution-revalidation", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + const priorInvoker = Object.getOwnPropertyDescriptor(Object.prototype, "invoker"); + const priorIngress = Object.getOwnPropertyDescriptor(Object.prototype, "ingress"); + const priorKind = Object.getOwnPropertyDescriptor(Object.prototype, "kind"); + let inheritedReads = 0; + let parsed: ExecutionIdentityAdmissionEnvelope; + try { + defineObjectPrototypeProperty("invoker", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return { state: "unknown" }; + }, + }); + parsed = parseExecutionIdentityAdmissionEnvelope(envelope); + + defineObjectPrototypeProperty("ingress", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return envelope.ingress; + }, + }); + expect(() => parseExecutionIdentityAdmissionEnvelope(omitOwn(envelope, "ingress"))).toThrow( + "execution identity admission envelope violates its bounded contract", + ); + + defineObjectPrototypeProperty("kind", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return "capture"; + }, + }); + expect(() => parseExecutionIdentityAdmissionWork({ envelope } as never)).toThrow( + "execution identity admission work violates its bounded contract", + ); + } finally { + for (const [key, descriptor] of [ + ["invoker", priorInvoker], + ["ingress", priorIngress], + ["kind", priorKind], + ] as const) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + expect(inheritedReads).toBe(0); + expect(Object.hasOwn(parsed!, "invoker")).toBe(false); + }); + it("rejects invalid owned facts, excess items, and oversized encoded envelopes", () => { expect(() => captureEnvelope(facts({ runId: "" }), { @@ -394,3 +843,9 @@ describe("execution identity admission envelope", () => { expect(JSON.stringify(work.mock.calls)).not.toContain("raw-private-reference"); }); }); + +function omitOwn(value: T, key: K): Omit { + const copy = { ...value }; + delete copy[key]; + return copy; +} diff --git a/src/audit/execution-identity-admission.ts b/src/audit/execution-identity-admission.ts index 1f437ccd5b95..910f8980752a 100644 --- a/src/audit/execution-identity-admission.ts +++ b/src/audit/execution-identity-admission.ts @@ -25,6 +25,58 @@ const evidenceState = () => const closedObject = [0]>(properties: T) => Type.Object(properties, { additionalProperties: false }); +const ingressKind = () => + Type.Union([ + Type.Literal("local-cli"), + Type.Literal("gateway-client"), + Type.Literal("channel"), + Type.Literal("api"), + Type.Literal("schedule"), + Type.Literal("webhook"), + Type.Literal("task"), + Type.Literal("subagent"), + Type.Literal("acp"), + Type.Literal("worker"), + Type.Literal("plugin"), + Type.Literal("recovery"), + Type.Literal("system"), + ]); +const runtimeKind = () => + Type.Union([ + Type.Literal("gateway"), + Type.Literal("embedded"), + Type.Literal("worker"), + Type.Literal("plugin-harness"), + Type.Literal("acp"), + ]); +const admissionGrant = () => closedObject({ rawGrantRef: rawRef(), state: evidenceState() }); +const admissionGrants = () => + Type.Array(admissionGrant(), { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }); +const admissionAssurance = () => + Type.Array( + closedObject({ + kind: Type.Union([ + Type.Literal("durable-profile"), + Type.Literal("trusted-proxy"), + Type.Literal("tailscale-whois"), + Type.Literal("device-proof"), + Type.Literal("channel-admission"), + Type.Literal("local-process"), + Type.Literal("spawn-lineage"), + Type.Literal("worker-admission"), + Type.Literal("runtime-binding"), + Type.Literal("other"), + ]), + rawEvidenceRef: rawRef(), + strength: Type.Union([ + Type.Literal("self-asserted"), + Type.Literal("boundary-verified"), + Type.Literal("cryptographic"), + ]), + }), + { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }, + ); + const ExecutionIdentityAdmissionInvokerSchema = Type.Union([ closedObject({ state: Type.Literal("present"), @@ -53,61 +105,32 @@ const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({ runtimeInstanceId: rawRef(), agentId: boundedRef(), ingress: closedObject({ - kind: Type.Union([ - Type.Literal("local-cli"), - Type.Literal("gateway-client"), - Type.Literal("channel"), - Type.Literal("api"), - Type.Literal("schedule"), - Type.Literal("webhook"), - Type.Literal("task"), - Type.Literal("subagent"), - Type.Literal("acp"), - Type.Literal("worker"), - Type.Literal("plugin"), - Type.Literal("recovery"), - Type.Literal("system"), - ]), + kind: ingressKind(), boundary: boundedRef(), state: evidenceState(), rawSourceRef: Type.Optional(rawRef()), }), runtime: closedObject({ - kind: Type.Union([ - Type.Literal("gateway"), - Type.Literal("embedded"), - Type.Literal("worker"), - Type.Literal("plugin-harness"), - Type.Literal("acp"), - ]), + kind: runtimeKind(), }), invoker: Type.Optional(ExecutionIdentityAdmissionInvokerSchema), - applicableGrants: Type.Array(closedObject({ rawGrantRef: rawRef(), state: evidenceState() }), { - maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS, + applicableGrants: admissionGrants(), + assurance: admissionAssurance(), +}); + +const ExecutionIdentityAdmissionFactsSchema = closedObject({ + runId: boundedRef(), + agentId: boundedRef(), + ingress: closedObject({ + kind: ingressKind(), + boundary: boundedRef(), + state: Type.Optional(evidenceState()), + rawSourceRef: Type.Optional(rawRef()), }), - assurance: Type.Array( - closedObject({ - kind: Type.Union([ - Type.Literal("durable-profile"), - Type.Literal("trusted-proxy"), - Type.Literal("tailscale-whois"), - Type.Literal("device-proof"), - Type.Literal("channel-admission"), - Type.Literal("local-process"), - Type.Literal("spawn-lineage"), - Type.Literal("worker-admission"), - Type.Literal("runtime-binding"), - Type.Literal("other"), - ]), - rawEvidenceRef: rawRef(), - strength: Type.Union([ - Type.Literal("self-asserted"), - Type.Literal("boundary-verified"), - Type.Literal("cryptographic"), - ]), - }), - { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }, - ), + runtime: closedObject({ kind: runtimeKind() }), + invoker: Type.Optional(ExecutionIdentityAdmissionInvokerSchema), + applicableGrants: Type.Optional(admissionGrants()), + assurance: Type.Optional(admissionAssurance()), }); const ExecutionIdentityAdmissionTokenSchema = closedObject({ @@ -166,9 +189,11 @@ function freezeEnvelope(value: T, seen = new WeakSet()): T { return Object.freeze(value); } -function assertPlainCloneData(value: unknown, ancestors = new WeakSet()): void { +// Snapshot descriptors before schema or projection: TypeBox accepts inherited +// keys, which would otherwise turn prototype data into diagnostic provenance. +function copyOwnedData(value: T, ancestors = new WeakSet()): T { if (value === null || ["string", "number", "boolean"].includes(typeof value)) { - return; + return value; } if (typeof value !== "object" || isProxy(value)) { throw new Error("execution identity admission data must be clone-safe plain data"); @@ -180,10 +205,15 @@ function assertPlainCloneData(value: unknown, ancestors = new WeakSet()) try { const prototype = Object.getPrototypeOf(value); const keys = Reflect.ownKeys(value); + const array = Array.isArray(value); if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); if ( prototype !== Array.prototype || - keys.length !== value.length + 1 || + !lengthDescriptor || + !("value" in lengthDescriptor) || + typeof lengthDescriptor.value !== "number" || + keys.length !== lengthDescriptor.value + 1 || keys.at(-1) !== "length" ) { throw new Error("execution identity admission data must be clone-safe plain data"); @@ -191,51 +221,63 @@ function assertPlainCloneData(value: unknown, ancestors = new WeakSet()) } else if (prototype !== Object.prototype && prototype !== null) { throw new Error("execution identity admission data must be clone-safe plain data"); } + const copy: unknown[] | Record = array ? [] : Object.create(null); for (const [index, key] of keys.entries()) { - if (key === "length" && Array.isArray(value)) { + if (key === "length" && array) { continue; } - if (typeof key !== "string" || (Array.isArray(value) && key !== String(index))) { + if (typeof key !== "string" || (array && key !== String(index))) { throw new Error("execution identity admission data must be clone-safe plain data"); } const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) { throw new Error("execution identity admission data must be clone-safe plain data"); } - assertPlainCloneData(descriptor.value, ancestors); + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: copyOwnedData(descriptor.value, ancestors), + writable: true, + }); } + return copy as T; } finally { ancestors.delete(value); } } -function validateEnvelope(value: unknown): asserts value is ExecutionIdentityAdmissionEnvelope { - assertPlainCloneData(value); +function validateEnvelope(value: unknown): ExecutionIdentityAdmissionEnvelope { + const owned = copyOwnedData(value); if ( - !Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, value) || - !Number.isSafeInteger(value.createdAt) + !Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, owned) || + !Number.isSafeInteger(owned.createdAt) ) { throw new Error("execution identity admission envelope violates its bounded contract"); } - const encoded = JSON.stringify(value); + const encoded = JSON.stringify(owned); if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_ADMISSION_MAX_BYTES) { throw new Error("execution identity admission envelope exceeds 16 KiB"); } + return owned; } -function validateRawInvoker(value: unknown): void { - if (value !== undefined && !Value.Check(ExecutionIdentityAdmissionInvokerSchema, value)) { - throw new Error("execution identity admission invoker violates its bounded contract"); +function validateFacts(value: unknown): ExecutionIdentityAdmissionFacts { + const owned = copyOwnedData(value); + if (!Value.Check(ExecutionIdentityAdmissionFactsSchema, owned)) { + throw new Error("execution identity admission facts violate their bounded contract"); } + return owned; } -function validateToken(value: unknown): asserts value is ExecutionIdentityAdmissionToken { +function validateToken(value: unknown): ExecutionIdentityAdmissionToken { + const owned = copyOwnedData(value); if ( - !Value.Check(ExecutionIdentityAdmissionTokenSchema, value) || - !Number.isSafeInteger(value.createdAt) + !Value.Check(ExecutionIdentityAdmissionTokenSchema, owned) || + !Number.isSafeInteger(owned.createdAt) ) { throw new Error("execution identity admission token violates its bounded contract"); } + return owned; } /** Allocate the immutable correlation owned by one outer admitted turn. */ @@ -250,15 +292,13 @@ export function createExecutionIdentityAdmissionToken( runId, createdAt: options.now ?? Date.now(), }; - validateToken(token); - return freezeEnvelope(token); + return freezeEnvelope(validateToken(token)); } export function parseExecutionIdentityAdmissionToken( value: unknown, ): ExecutionIdentityAdmissionToken { - validateToken(value); - return freezeEnvelope({ ...value }); + return freezeEnvelope(validateToken(value)); } function redactDisplayLabel(value: string): string { @@ -274,22 +314,12 @@ function redactDisplayLabel(value: string): string { function captureExecutionIdentityAdmissionEnvelope( facts: ExecutionIdentityAdmissionFacts, options: { - contextId?: string; - executionId?: string; - now?: number; runtimeInstanceId?: string; - token?: ExecutionIdentityAdmissionToken; - } = {}, + token: ExecutionIdentityAdmissionToken; + }, ): ExecutionIdentityAdmissionEnvelope { - const token = - options.token ?? - createExecutionIdentityAdmissionToken(facts.runId, { - contextId: options.contextId, - executionId: options.executionId, - now: options.now, - }); - validateToken(token); - if (token.runId !== facts.runId) { + const ownedToken = validateToken(options.token); + if (ownedToken.runId !== facts.runId) { throw new Error("execution identity admission token disagrees with the admitted run"); } const runtimeInstanceId = options.runtimeInstanceId ?? PROCESS_RUNTIME_INSTANCE_ID; @@ -302,10 +332,10 @@ function captureExecutionIdentityAdmissionEnvelope( ]; const envelope = { envelopeVersion: 1 as const, - contextId: token.contextId, - executionId: token.executionId, - runId: token.runId, - createdAt: token.createdAt, + contextId: ownedToken.contextId, + executionId: ownedToken.executionId, + runId: ownedToken.runId, + createdAt: ownedToken.createdAt, runtimeInstanceId, agentId: facts.agentId, ingress: { ...facts.ingress, state: facts.ingress.state ?? "present" }, @@ -337,24 +367,23 @@ function captureExecutionIdentityAdmissionEnvelope( strength: item.strength, })), }; - validateEnvelope(envelope); - return freezeEnvelope(envelope); + return freezeEnvelope(validateEnvelope(envelope)); } /** Revalidate a structured-cloned worker message before any persistence work. */ export function parseExecutionIdentityAdmissionEnvelope( value: unknown, ): ExecutionIdentityAdmissionEnvelope { - validateEnvelope(value); - const parsed = captureExecutionIdentityAdmissionEnvelope(value, { - token: createExecutionIdentityAdmissionToken(value.runId, { - contextId: value.contextId, - executionId: value.executionId, - now: value.createdAt, + const envelope = validateEnvelope(value); + const parsed = captureExecutionIdentityAdmissionEnvelope(envelope, { + token: createExecutionIdentityAdmissionToken(envelope.runId, { + contextId: envelope.contextId, + executionId: envelope.executionId, + now: envelope.createdAt, }), - runtimeInstanceId: value.runtimeInstanceId, + runtimeInstanceId: envelope.runtimeInstanceId, }); - if (JSON.stringify(parsed) !== JSON.stringify(value)) { + if (JSON.stringify(parsed) !== JSON.stringify(envelope)) { throw new Error("execution identity admission envelope is not canonical"); } return parsed; @@ -364,10 +393,11 @@ export function parseExecutionIdentityAdmissionEnvelope( export function parseExecutionIdentityAdmissionWork( value: unknown, ): ExecutionIdentityAdmissionWork { - if (!value || typeof value !== "object") { + const owned = copyOwnedData(value); + if (!owned || typeof owned !== "object") { throw new Error("execution identity admission work violates its bounded contract"); } - const work = value as { kind?: unknown; envelope?: unknown; token?: unknown }; + const work = owned as { kind?: unknown; envelope?: unknown; token?: unknown }; if (work.kind === "capture") { return freezeEnvelope({ kind: "capture" as const, @@ -424,21 +454,20 @@ export function enqueueExecutionIdentityContextAtAdmission( return undefined; } try { - assertPlainCloneData(facts); - validateRawInvoker(facts.invoker); - const token = + const ownedFacts = validateFacts(facts); + const token = validateToken( options.token ?? - createExecutionIdentityAdmissionToken(facts.runId, { - contextId: options.contextId, - executionId: options.executionId, - now: options.now, - }); - validateToken(token); + createExecutionIdentityAdmissionToken(ownedFacts.runId, { + contextId: options.contextId, + executionId: options.executionId, + now: options.now, + }), + ); const work: ExecutionIdentityAdmissionWork = options.retryOnly ? { kind: "retry-reference", token } : { kind: "capture", - envelope: captureExecutionIdentityAdmissionEnvelope(facts, { + envelope: captureExecutionIdentityAdmissionEnvelope(ownedFacts, { token, runtimeInstanceId: options.runtimeInstanceId, }), From a89b88ec0ea756c48e2d28f246ac0f325e420c51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:20:00 -0700 Subject: [PATCH 009/165] docs(qqbot): point install docs at the Tencent package (#122417) The externalization left the docs inventory seed and the channel page advertising the retired @openclaw/qqbot npm package; the catalog already resolves qqbot to @tencent-connect/openclaw-qqbot. Regenerates the plugin reference and inventory pages from the corrected seed. --- docs/channels/qqbot.md | 2 +- docs/plugins/plugin-inventory.md | 2 +- docs/plugins/reference/qqbot.md | 4 ++-- scripts/lib/official-external-channel-seed.json | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 6e2bc76a212d..a495a5731826 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -19,7 +19,7 @@ Status: official downloadable plugin. ## Install ```bash -openclaw plugins install @openclaw/qqbot +openclaw plugins install @tencent-connect/openclaw-qqbot ``` ## Setup diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 5636b08207da..7372af6298c7 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -298,7 +298,7 @@ Each entry lists the package, distribution route, and description. - **[qianfan](/plugins/reference/qianfan)** (`@openclaw/qianfan-provider`) - npm; ClawHub: `clawhub:@openclaw/qianfan-provider`. Adds Qianfan model provider support to OpenClaw. -- **[qqbot](/plugins/reference/qqbot)** (`@openclaw/qqbot`) - npm; ClawHub. OpenClaw QQ Bot channel plugin for group and direct-message workflows. +- **[qqbot](/plugins/reference/qqbot)** (`@tencent-connect/openclaw-qqbot`) - npm. OpenClaw QQ Bot channel plugin for group and direct-message workflows. - **[qwen](/plugins/reference/qwen)** (`@openclaw/qwen-provider`) - npm; ClawHub: `clawhub:@openclaw/qwen-provider`. Adds Qwen, Qwen Cloud, Model Studio, DashScope, Qwen Token Plan, Bailian Token Plan model provider support to OpenClaw. diff --git a/docs/plugins/reference/qqbot.md b/docs/plugins/reference/qqbot.md index 82468f74fb94..20ed68bd9600 100644 --- a/docs/plugins/reference/qqbot.md +++ b/docs/plugins/reference/qqbot.md @@ -11,8 +11,8 @@ OpenClaw QQ Bot channel plugin for group and direct-message workflows. ## Distribution -- Package: `@openclaw/qqbot` -- Install route: npm; ClawHub +- Package: `@tencent-connect/openclaw-qqbot` +- Install route: npm ## Surface diff --git a/scripts/lib/official-external-channel-seed.json b/scripts/lib/official-external-channel-seed.json index e9a50fcab04f..c75a4c9996f3 100644 --- a/scripts/lib/official-external-channel-seed.json +++ b/scripts/lib/official-external-channel-seed.json @@ -123,15 +123,15 @@ "docsSource": "official", "docsInventory": { "package": { - "name": "@openclaw/qqbot", - "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", + "name": "@tencent-connect/openclaw-qqbot", + "description": "OpenClaw QQ Bot channel plugin by the Tencent Connect team.", "openclaw": { "install": { - "npmSpec": "@openclaw/qqbot", + "npmSpec": "@tencent-connect/openclaw-qqbot", "defaultChoice": "npm" }, "release": { - "publishToClawHub": true, + "publishToClawHub": false, "publishToNpm": true } } From 722e20b67581092868f022f17b295ea1377a2400 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:22:42 -0700 Subject: [PATCH 010/165] test(agents): prune final Responses duplicates (#122424) * test(agents): prune final Responses duplicates * fix(tooling): honor inclusive compat removal dates --- config/max-lines-baseline.txt | 1 - scripts/plugin-boundary-report.ts | 16 +- .../openai-transport-stream.streaming.test.ts | 328 ------------------ test/scripts/plugin-boundary-report.test.ts | 13 + 4 files changed, 26 insertions(+), 332 deletions(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6e128a973367..9a81775995b3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -397,7 +397,6 @@ src/agents/model-selection.test.ts src/agents/models.profiles.live.test.ts src/agents/openai-transport-stream.base.test.ts src/agents/openai-transport-stream.replay-and-tools.test.ts -src/agents/openai-transport-stream.streaming.test.ts src/agents/openclaw-tools.media-factory-plan.test.ts src/agents/openclaw-tools.session-status.test.ts src/agents/openclaw-tools.sessions.test.ts diff --git a/scripts/plugin-boundary-report.ts b/scripts/plugin-boundary-report.ts index 5c3e8ae5e045..55f69d7ed46a 100644 --- a/scripts/plugin-boundary-report.ts +++ b/scripts/plugin-boundary-report.ts @@ -403,6 +403,18 @@ function collectReferenceFiles(files: readonly WorkspaceTextFile[], tokens: read }; } +export function isPluginCompatEligibleForRemoval( + removeAfter: string | undefined, + today = new Date(), +): boolean { + if (!removeAfter) { + return false; + } + const firstRemovalInstant = new Date(`${removeAfter}T00:00:00Z`); + firstRemovalInstant.setUTCDate(firstRemovalInstant.getUTCDate() + 1); + return firstRemovalInstant <= today; +} + function collectCompatDebt( files: readonly WorkspaceTextFile[], today = new Date(), @@ -416,9 +428,7 @@ function collectCompatDebt( options.includeReferenceFiles === false ? { codeReferenceFiles: [], docReferenceFiles: [] } : collectReferenceFiles(files, tokens); - const eligibleForRemoval = record.removeAfter - ? new Date(`${record.removeAfter}T00:00:00Z`) <= today - : false; + const eligibleForRemoval = isPluginCompatEligibleForRemoval(record.removeAfter, today); return { code: record.code, owner: record.owner, diff --git a/src/agents/openai-transport-stream.streaming.test.ts b/src/agents/openai-transport-stream.streaming.test.ts index 231412da0bce..1859ca3985e9 100644 --- a/src/agents/openai-transport-stream.streaming.test.ts +++ b/src/agents/openai-transport-stream.streaming.test.ts @@ -777,333 +777,6 @@ describe("openai transport stream", () => { expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(2); }); - it("rejects a completed Responses tool call whose function name changed", async () => { - const model = createAzureResponsesModel(); - const output = createResponsesAssistantOutput(model); - - await expect( - testing.processResponsesStream( - streamChunks([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_name_conflict", - call_id: "call_name_conflict", - name: "read", - arguments: "", - }, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_name_conflict", - call_id: "call_name_conflict", - name: "write", - arguments: "{}", - }, - }, - ]), - output, - { push: vi.fn() }, - model, - ), - ).rejects.toThrow("Responses stream changed tool-call function name from read to write"); - }); - - it("routes an omitted-index suffix by item id across parallel Responses calls", async () => { - const model = createAzureResponsesModel(); - const output = createResponsesAssistantOutput(model); - const events: Array<{ type?: string; contentIndex?: number }> = []; - - await testing.processResponsesStream( - streamChunks([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "fc_first", - delta: '{"slot":', - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_first", - delta: "0}", - }, - { - type: "response.function_call_arguments.delta", - output_index: 1, - delta: '{"slot":1}', - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.output_item.done", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { - type: "response.completed", - response: { id: "resp_omitted_suffix", status: "completed" }, - }, - ]), - output, - { push: (event) => events.push(event as (typeof events)[number]) }, - model, - ); - - expect(output.content).toMatchObject([ - { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, - { type: "toolCall", id: "call_second|fc_second", arguments: { slot: 1 } }, - ]); - expect( - events.filter((event) => event.type === "toolcall_delta").map((event) => event.contentIndex), - ).toEqual([0, 0, 1]); - }); - - it("matches omitted-index parallel completions without duplicating indexed calls", async () => { - const model = createAzureResponsesModel(); - const output = createResponsesAssistantOutput(model); - const events: Array<{ type?: string; contentIndex?: number }> = []; - - await testing.processResponsesStream( - streamChunks([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "fc_first", - delta: '{"incomplete":', - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.completed", - response: { id: "resp_omitted_completions", status: "completed" }, - }, - ]), - output, - { push: (event) => events.push(event as (typeof events)[number]) }, - model, - ); - - expect(output.content).toMatchObject([ - { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, - { type: "toolCall", id: "call_second|fc_second", arguments: { slot: 1 } }, - ]); - expect(events.filter((event) => event.type === "toolcall_start")).toHaveLength(2); - expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(2); - }); - - it("rejects omitted-index events whose identity mismatches the sole indexed call", async () => { - const model = createAzureResponsesModel(); - const output = createResponsesAssistantOutput(model); - const events: Array<{ type?: string; contentIndex?: number }> = []; - - await testing.processResponsesStream( - streamChunks([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_other", - delta: '{"wrong":true}', - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_other", - call_id: "call_other", - name: "computer", - arguments: '{"wrong":true}', - }, - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.completed", - response: { id: "resp_identity_mismatch", status: "completed" }, - }, - ]), - output, - { push: (event) => events.push(event as (typeof events)[number]) }, - model, - ); - - expect(output.content).toMatchObject([ - { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, - ]); - expect(events.filter((event) => event.type === "toolcall_start")).toHaveLength(1); - expect(events.filter((event) => event.type === "toolcall_delta")).toHaveLength(0); - expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(1); - }); - - it("keeps sequential omitted-index Responses calls unambiguous", async () => { - const model = createAzureResponsesModel(); - const output = createResponsesAssistantOutput(model); - const events: Array<{ type?: string; contentIndex?: number }> = []; - - await testing.processResponsesStream( - streamChunks([ - { - type: "response.output_item.added", - output_index: 7, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { type: "response.function_call_arguments.delta", delta: '{"slot":0}' }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.output_item.added", - output_index: 8, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { type: "response.function_call_arguments.delta", delta: '{"slot":1}' }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { - type: "response.completed", - response: { id: "resp_sequential_unindexed", status: "completed" }, - }, - ]), - output, - { push: (event) => events.push(event as (typeof events)[number]) }, - model, - ); - - expect(output.content).toMatchObject([ - { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, - { type: "toolCall", id: "call_second|fc_second", arguments: { slot: 1 } }, - ]); - expect( - events.filter((event) => event.type === "toolcall_delta").map((event) => event.contentIndex), - ).toEqual([0, 1]); - }); - it("handles Azure Responses text content and text delta events", async () => { const model = createAzureResponsesModel(); const output = createResponsesAssistantOutput(model); @@ -1166,4 +839,3 @@ describe("openai transport stream", () => { expect(output.responseId).toBe("resp_azure_text"); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts index 185d2a4ef1ed..8422a4544fc0 100644 --- a/test/scripts/plugin-boundary-report.test.ts +++ b/test/scripts/plugin-boundary-report.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { createPluginBoundaryReport, + isPluginCompatEligibleForRemoval, type PluginBoundaryReportResult, } from "../../scripts/plugin-boundary-report.js"; @@ -77,6 +78,18 @@ describe("plugin-boundary-report", () => { ); }); + it("treats removeAfter as the final compatibility day", () => { + expect( + isPluginCompatEligibleForRemoval("2026-08-12", new Date("2026-08-12T23:59:59.999Z")), + ).toBe(false); + expect( + isPluginCompatEligibleForRemoval("2026-08-12", new Date("2026-08-13T00:00:00.000Z")), + ).toBe(true); + expect(isPluginCompatEligibleForRemoval(undefined, new Date("2026-08-13T00:00:00.000Z"))).toBe( + false, + ); + }); + it("renders removal-pending blockers and reader references without changing fail gates", () => { const result = createPluginBoundaryReport(["--summary"]); From fc31a157cccb60244a320449d26f9dda728d3410 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:44:51 -0700 Subject: [PATCH 011/165] refactor(gateway): remove orphan testing aliases (#122433) --- src/gateway/call.ts | 1 - src/gateway/openresponses-http.ts | 1 - src/gateway/server-methods/agents.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 4d22c7e395cb..dd18b956761d 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1292,5 +1292,4 @@ export async function callGateway>( export function randomIdempotencyKey() { return randomUUID(); } -export { testing as __testing }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index f6039accefbe..4c03f20288f1 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -1392,5 +1392,4 @@ export async function handleOpenResponsesHttpRequest( return true; } -export { testing as __testing }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 9a91f3d1f602..0c0e9f00d66f 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -1593,5 +1593,4 @@ export const agentsHandlers: GatewayRequestHandlers = { ); }, }; -export { testing as __testing }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ From cb52ded58d8f0ef51f379ea3347ad5afee8cf53d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:45:17 -0700 Subject: [PATCH 012/165] refactor(telegram): split native commands by executor (#122419) * refactor(telegram): split native commands by executor * refactor(telegram): deduplicate DM-thread target session * chore(lint): ratchet max-lines baseline after telegram commands split * test(telegram): fix native command split checks --- config/max-lines-baseline.txt | 2 - .../src/bot-handlers.callback-router.ts | 2 +- .../src/bot-handlers.message-context.ts | 19 +- .../src/bot-native-command-builtins.test.ts | 457 +++ .../src/bot-native-command-builtins.ts | 375 +++ ... bot-native-command-dispatch.auth.test.ts} | 0 ...t-native-command-dispatch.delivery.test.ts | 421 +++ ...ot-native-command-dispatch.routing.test.ts | 432 +++ ...ot-native-command-dispatch.test-support.ts | 107 + .../src/bot-native-command-dispatch.ts | 699 +++++ ...t-native-command-executors.test-support.ts | 580 ++++ ...st.ts => bot-native-command-login.test.ts} | 411 ++- .../telegram/src/bot-native-command-login.ts | 271 ++ .../src/bot-native-command-plugins.test.ts | 692 +++++ .../src/bot-native-command-plugins.ts | 316 +++ .../bot-native-commands.delivery.runtime.ts | 3 +- .../src/bot-native-commands.runtime.ts | 5 +- .../bot-native-commands.session-meta.test.ts | 2491 ----------------- .../telegram/src/bot-native-commands.test.ts | 545 +--- .../telegram/src/bot-native-commands.ts | 1953 +------------ extensions/telegram/src/conversation-route.ts | 30 +- .../src/native-command-callback-data.test.ts | 11 + 22 files changed, 4885 insertions(+), 4937 deletions(-) create mode 100644 extensions/telegram/src/bot-native-command-builtins.test.ts create mode 100644 extensions/telegram/src/bot-native-command-builtins.ts rename extensions/telegram/src/{bot-native-commands.group-auth.test.ts => bot-native-command-dispatch.auth.test.ts} (100%) create mode 100644 extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts create mode 100644 extensions/telegram/src/bot-native-command-dispatch.routing.test.ts create mode 100644 extensions/telegram/src/bot-native-command-dispatch.test-support.ts create mode 100644 extensions/telegram/src/bot-native-command-dispatch.ts create mode 100644 extensions/telegram/src/bot-native-command-executors.test-support.ts rename extensions/telegram/src/{bot-native-commands.login.test.ts => bot-native-command-login.test.ts} (57%) create mode 100644 extensions/telegram/src/bot-native-command-login.ts create mode 100644 extensions/telegram/src/bot-native-command-plugins.test.ts create mode 100644 extensions/telegram/src/bot-native-command-plugins.ts delete mode 100644 extensions/telegram/src/bot-native-commands.session-meta.test.ts create mode 100644 extensions/telegram/src/native-command-callback-data.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9a81775995b3..27753e029aa0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -247,8 +247,6 @@ extensions/slack/src/send.ts extensions/telegram/src/action-runtime.test.ts extensions/telegram/src/action-runtime.ts extensions/telegram/src/bot-message-context.session.ts -extensions/telegram/src/bot-native-commands.session-meta.test.ts -extensions/telegram/src/bot-native-commands.ts extensions/telegram/src/bot.create-telegram-bot.test.ts extensions/telegram/src/bot.test.ts extensions/telegram/src/bot/delivery.replies.ts diff --git a/extensions/telegram/src/bot-handlers.callback-router.ts b/extensions/telegram/src/bot-handlers.callback-router.ts index cc40fc2303cf..f26c2614bb55 100644 --- a/extensions/telegram/src/bot-handlers.callback-router.ts +++ b/extensions/telegram/src/bot-handlers.callback-router.ts @@ -38,7 +38,6 @@ import type { RegisterTelegramHandlerParams, TelegramCallbackRouter, } from "./bot-handlers.types.js"; -import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js"; import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, @@ -63,6 +62,7 @@ import { } from "./model-buttons.js"; import { hasTelegramOpaqueCallbackPrefix, + parseTelegramNativeCommandCallbackData, parseTelegramOpaqueCallbackData, } from "./native-command-callback-data.js"; import { isTelegramMessageNotModifiedError } from "./network-errors.js"; diff --git a/extensions/telegram/src/bot-handlers.message-context.ts b/extensions/telegram/src/bot-handlers.message-context.ts index 88873980d04b..da0b7b5267d9 100644 --- a/extensions/telegram/src/bot-handlers.message-context.ts +++ b/extensions/telegram/src/bot-handlers.message-context.ts @@ -4,7 +4,6 @@ import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound" import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { getSessionEntry, readAmbientTranscriptWatermark, @@ -25,13 +24,12 @@ import { getTelegramTextParts, resolveTelegramPrimaryMedia, resolveTelegramForumThreadId, - shouldUseTelegramDmThreadSession, type TelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { - resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, + resolveTelegramTargetSession, } from "./conversation-route.js"; import { resolveTelegramDmHistoryLimit } from "./dm-history.js"; import { @@ -212,24 +210,15 @@ export function createTelegramMessageSessionRuntime({ senderId: params.senderId, topicAgentId: topicConfig?.agentId, }); - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + const sessionKey = resolveTelegramTargetSession({ cfg: params.runtimeCfg, route, chatId: params.chatId, isGroup: params.isGroup, senderId: params.senderId, + dmThreadId, + botHasTopicsEnabled: params.botHasTopicsEnabled, }); - const threadKeys = - shouldUseTelegramDmThreadSession({ - dmThreadId, - botHasTopicsEnabled: params.botHasTopicsEnabled, - }) && dmThreadId != null - ? resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${params.chatId}:${dmThreadId}`, - }) - : null; - const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, { agentId: route.agentId, }); diff --git a/extensions/telegram/src/bot-native-command-builtins.test.ts b/extensions/telegram/src/bot-native-command-builtins.test.ts new file mode 100644 index 000000000000..ff59db837da4 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-builtins.test.ts @@ -0,0 +1,457 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + executorTestMocks, + expectRecordFields, + expectSendMessageCall, + registerAndResolveCommandHandler, + resetSessionMetaMocks, +} from "./bot-native-command-executors.test-support.js"; +import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js"; + +const { agentRuntimeMocks, commandAuthMocks, replyMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command built-ins", () => { + beforeEach(resetSessionMetaMocks); + + it("uses the target session model when building native argument menus", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + thinkingLevel: "high", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "anthropic", model: "claude-opus-4-7" }, + "thinking menu call", + ); + expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({ + storePath: "/tmp/openclaw-sessions.json", + sessionKey: "agent:main:main", + }); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: high.\nChoose level for /think.", + requireReplyMarkup: true, + label: "thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it.each([ + { sessionRuntime: undefined, expectedRuntime: "codex" }, + { sessionRuntime: "openclaw", expectedRuntime: "openclaw" }, + ])( + "uses the effective $expectedRuntime runtime for native /think menus", + async ({ sessionRuntime, expectedRuntime }) => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "openai", + modelOverride: "gpt-5.6-luna", + modelOverrideSource: "user", + ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}), + updatedAt: 0, + }, + }); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna", + )?.[0]; + expectRecordFields( + menuCall, + { + provider: "openai", + model: "gpt-5.6-luna", + agentRuntime: expectedRuntime, + }, + "runtime-aware thinking menu call", + ); + }, + ); + + it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => { + const cfg = { + agents: { defaults: { models: { "ollama/*": {} } } }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "ollama", + modelOverride: "glm-5.2:cloud", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + const runtimeCatalog = [ + { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, + ]; + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "ollama", + )?.[0]; + const menuRecord = expectRecordFields( + menuCall, + { provider: "ollama", model: "glm-5.2:cloud" }, + "ollama thinking menu call", + ); + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); + expect(menuRecord.catalog).toEqual(runtimeCatalog); + }); + + it("loads the runtime catalog for /think when no session model override is set", async () => { + const cfg = { + agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + const runtimeCatalog = [ + { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, + ]; + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think", + )?.[0]; + const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call"); + expect(menuRecord.provider).toBeUndefined(); + expect(menuRecord.catalog).toEqual(runtimeCatalog); + }); + + it("inherits the parent session model when building DM thread native argument menus", async () => { + const cfg: OpenClawConfig = {}; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ threadId: 77 })); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "anthropic", model: "claude-opus-4-7" }, + "thread thinking menu call", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Choose level for /think.", + requireReplyMarkup: true, + label: "thread thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses the configured default model instead of temporary auto fallback overrides", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + thinkingDefault: "medium", + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "auto", + modelProvider: "anthropic", + model: "claude-opus-4-7", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "openai", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "openai", model: "gpt-5.5" }, + "default model thinking menu call", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: medium.\nChoose level for /think.", + requireReplyMarkup: true, + label: "default model thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses configured model defaults instead of runtime auth metadata for the fast menu", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { + params: { fastMode: "auto", fastAutoOnSeconds: 30 }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + modelProvider: "openai-codex", + model: "gpt-5.5", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "fast", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "fast", + )?.[0]; + expectRecordFields(menuCall, { cfg }, "fast menu call"); + expect( + commandAuthMocks.resolveCommandArgMenu.mock.calls.some( + ([params]) => + params.command.key === "fast" && + params.provider === "openai" && + params.model === "gpt-5.5", + ), + ).toBe(true); + const options = expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: + "Current fast mode: auto (30 sec) (default: model).\nOptions: on, off, auto (30 sec), default, status.", + requireReplyMarkup: true, + label: "fast menu", + }); + const replyMarkup = options.reply_markup as + | { inline_keyboard?: Array> } + | undefined; + const labels = (replyMarkup?.inline_keyboard ?? []).flatMap((row) => + row.map((button) => button.text), + ); + expect(labels).toContain("auto (30 sec)"); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses the read-only catalog for Claude CLI thinking menus", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-8" }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + agentRuntimeMocks.loadModelCatalog.mockImplementation(async (params) => { + if (!params?.readOnly) { + throw new Error("native /think must not start full model discovery"); + } + return [ + { + provider: "anthropic", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + reasoning: true, + }, + ]; + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + agentDir: expect.any(String), + readOnly: true, + }), + ); + expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: off.\nChoose level for /think.", + requireReplyMarkup: true, + label: "Claude CLI thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses target model thinking defaults before global thinking defaults", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: xhigh.\nChoose level for /think.", + requireReplyMarkup: true, + label: "target model thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses per-agent thinking defaults before target model and global thinking defaults", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + list: [ + { + id: "alpha", + model: { primary: "anthropic/claude-opus-4-7" }, + thinkingDefault: "minimal", + }, + ], + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: minimal.\nChoose level for /think.", + requireReplyMarkup: true, + label: "agent thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("does not load the session store when a native argument menu is skipped", async () => { + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg: {}, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ match: "high" })); + + expect(sessionMocks.sessionStoreEntries).not.toHaveBeenCalled(); + expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled(); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-builtins.ts b/extensions/telegram/src/bot-native-command-builtins.ts new file mode 100644 index 000000000000..b59c9321b4b9 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-builtins.ts @@ -0,0 +1,375 @@ +// Telegram plugin module implements built-in native command behavior. +import { + loadPreparedModelCatalog, + resolveAgentConfig, + resolveAgentDir, + resolveDefaultModelForAgent, + resolveThinkingDefaultWithRuntimeCatalog, +} from "openclaw/plugin-sdk/agent-runtime"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + formatCommandArgMenuTitle, + formatFastModeCurrentStatus, + parseCommandArgs, + resolveCommandArgMenu, + resolveEffectiveAgentRuntime, + resolveFastModeState, + resolveStoredModelOverride, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth-native"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { + getSessionEntry, + resolveStorePath, + type SessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + dispatchTelegramBuiltinTurn, + prepareTelegramCommandDispatch, + type TelegramCommandExecutorParams, +} from "./bot-native-command-dispatch.js"; +import { buildInlineKeyboard } from "./inline-keyboard.js"; +import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; + +const loadTelegramLoginCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-login.js"), +); + +type TelegramCommandMenuModelContext = { + provider?: string; + model?: string; + agentRuntime?: string; + thinkingLevel?: string; + fastMode?: SessionEntry["fastMode"]; +}; + +function buildTelegramCommandMenuModelContext(params: { + provider: string; + model: string; + thinkingLevel?: string; + fastMode?: SessionEntry["fastMode"]; +}): TelegramCommandMenuModelContext { + return { + provider: params.provider, + model: params.model, + ...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}), + ...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}), + }; +} + +function resolveTelegramCommandMenuModelContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): TelegramCommandMenuModelContext { + if (!params.sessionKey.trim()) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel); + const fastMode = entry?.fastMode; + let context: TelegramCommandMenuModelContext; + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + context = buildTelegramCommandMenuModelContext({ + provider: defaultModel.provider, + model: defaultModel.model, + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }); + } else { + const override = resolveStoredModelOverride({ + sessionEntry: entry, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + if (override?.model) { + context = buildTelegramCommandMenuModelContext({ + provider: override.provider || defaultModel.provider, + model: override.model, + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }); + } else { + const provider = + normalizeOptionalString(entry?.providerOverride) ?? + normalizeOptionalString(entry?.modelProvider); + const model = + normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); + context = { + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }; + } + } + return { + ...context, + agentRuntime: resolveEffectiveAgentRuntime({ + cfg: params.cfg, + provider: context.provider ?? defaultModel.provider, + modelId: context.model ?? defaultModel.model, + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionEntry: entry, + }), + }; + } catch { + return {}; + } +} + +function resolveTelegramFastCommandModelContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): { provider?: string; model?: string } { + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const fallback = () => ({ provider: defaultModel.provider, model: defaultModel.model }); + if (!params.sessionKey.trim()) { + return fallback(); + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + return fallback(); + } + const override = resolveStoredModelOverride({ + sessionEntry: entry, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + return { + provider: override?.provider ?? defaultModel.provider, + model: override?.model ?? defaultModel.model, + }; + } catch { + return fallback(); + } +} + +function resolveTelegramFastCommandState(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}) { + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const fallback = () => + resolveFastModeState({ + cfg: params.cfg, + provider: defaultModel.provider, + model: defaultModel.model, + agentId: params.agentId, + }); + if (!params.sessionKey.trim()) { + return fallback(); + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + const modelContext = resolveTelegramFastCommandModelContext(params); + return resolveFastModeState({ + cfg: params.cfg, + provider: modelContext.provider ?? defaultModel.provider, + model: modelContext.model ?? defaultModel.model, + agentId: params.agentId, + sessionEntry: + entry?.fastMode !== undefined + ? { + fastMode: entry.fastMode, + } + : undefined, + }); + } catch { + return fallback(); + } +} + +async function resolveTelegramThinkMenuCurrentLevel(params: { + cfg: OpenClawConfig; + agentId: string; + provider?: string; + model?: string; + agentRuntime?: string; + thinkingLevel?: string; + catalog: Awaited>; +}): Promise { + const explicit = normalizeOptionalString(params.thinkingLevel); + if (explicit) { + return explicit; + } + const agentThinkingDefault = normalizeOptionalString( + resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault, + ); + if (agentThinkingDefault) { + return agentThinkingDefault; + } + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + return await resolveThinkingDefaultWithRuntimeCatalog({ + cfg: params.cfg, + provider: params.provider ?? defaultModel.provider, + model: params.model ?? defaultModel.model, + agentRuntime: params.agentRuntime, + loadRuntimeCatalog: async () => params.catalog, + }); +} + +function formatTelegramCommandArgMenuTitle(params: { + command: NonNullable>; + menu: NonNullable>; + currentThinkingLevel?: string; + currentFastModeStatus?: string; +}): string { + const title = formatCommandArgMenuTitle({ command: params.command, menu: params.menu }); + if (params.command.key === "think" && params.currentThinkingLevel) { + return `Current thinking level: ${params.currentThinkingLevel}.\n${title}`; + } + if (params.command.key === "fast" && params.currentFastModeStatus) { + const options = params.menu.choices + .map((choice) => choice.label.trim()) + .filter(Boolean) + .join(", "); + return options + ? `${params.currentFastModeStatus}\nOptions: ${options}.` + : params.currentFastModeStatus; + } + return title; +} + +export async function executeTelegramBuiltinCommand( + params: TelegramCommandExecutorParams & { commandName: string }, +): Promise { + const dispatch = await prepareTelegramCommandDispatch({ ...params, requireAuth: true }); + if (!dispatch) { + return false; + } + const commandDefinition = findCommandByNativeName(params.commandName, "telegram"); + const commandArgs = commandDefinition + ? parseCommandArgs(commandDefinition, params.rawText) + : params.rawText + ? ({ raw: params.rawText } satisfies CommandArgs) + : undefined; + const prompt = commandDefinition + ? buildCommandTextFromArgs(commandDefinition, commandArgs) + : params.rawText + ? `/${params.commandName} ${params.rawText}` + : `/${params.commandName}`; + if (commandDefinition?.key === "login") { + const { executeTelegramLoginCommand } = await loadTelegramLoginCommandExecutor(); + return await executeTelegramLoginCommand({ dispatch, commandArgs }); + } + + const menuNeedsModelContext = + commandDefinition?.argsMenu && + !(commandArgs?.raw && !commandArgs.values) && + commandDefinition.args?.some( + (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, + ); + const sessionKeyForMenu = + commandDefinition && menuNeedsModelContext ? dispatch.targetSessionKey : ""; + const fastCommandState = + commandDefinition?.key === "fast" && menuNeedsModelContext + ? resolveTelegramFastCommandState({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + }) + : undefined; + const fastMenuModelContext = + commandDefinition?.key === "fast" && menuNeedsModelContext + ? resolveTelegramFastCommandModelContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + }) + : undefined; + const menuModelContext = + commandDefinition && menuNeedsModelContext + ? (fastMenuModelContext ?? + resolveTelegramCommandMenuModelContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + })) + : {}; + // Native /think must not wait on provider discovery; persisted rows retain its metadata. + const menuModelCatalog = + commandDefinition?.key === "think" && menuNeedsModelContext + ? await loadPreparedModelCatalog({ + config: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + agentDir: resolveAgentDir(dispatch.runtimeCfg, dispatch.route.agentId), + readOnly: true, + }) + : undefined; + const menu = commandDefinition + ? resolveCommandArgMenu({ + command: commandDefinition, + args: commandArgs, + cfg: dispatch.runtimeCfg, + ...menuModelContext, + ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), + }) + : null; + if (menu && commandDefinition) { + const title = formatTelegramCommandArgMenuTitle({ + command: commandDefinition, + menu, + currentThinkingLevel: + commandDefinition.key === "think" + ? await resolveTelegramThinkMenuCurrentLevel({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + ...menuModelContext, + catalog: menuModelCatalog ?? [], + }) + : undefined, + currentFastModeStatus: + commandDefinition.key === "fast" + ? formatFastModeCurrentStatus({ + ...(fastCommandState ?? + resolveTelegramFastCommandState({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + })), + }) + : undefined, + }); + const rows: Array> = []; + for (let index = 0; index < menu.choices.length; index += 2) { + rows.push( + menu.choices.slice(index, index + 2).map((choice) => ({ + text: choice.label, + callback_data: buildTelegramNativeCommandCallbackData( + buildCommandTextFromArgs(commandDefinition, { + values: { [menu.arg.name]: choice.value }, + }), + ), + })), + ); + } + const replyMarkup = buildInlineKeyboard(rows); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage(dispatch.chatId, title, { + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + ...dispatch.threadParams, + }), + }); + return false; + } + return await dispatchTelegramBuiltinTurn({ dispatch, prompt, commandArgs }); +} diff --git a/extensions/telegram/src/bot-native-commands.group-auth.test.ts b/extensions/telegram/src/bot-native-command-dispatch.auth.test.ts similarity index 100% rename from extensions/telegram/src/bot-native-commands.group-auth.test.ts rename to extensions/telegram/src/bot-native-command-dispatch.auth.test.ts diff --git a/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts b/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts new file mode 100644 index 000000000000..47eebf8d0b90 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts @@ -0,0 +1,421 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createChannelPartialDeliveryError, + createDeferred, + dispatchReplyResult, + dispatchChannelInboundTurnMock, + executorTestMocks, + firstMockArg, + registerAndResolveStatusHandler, + requireRecord, + requireValue, + resetSessionMetaMocks, +} from "./bot-native-command-executors.test-support.js"; +import type { DispatchReplyWithBufferedBlockDispatcherParams } from "./bot-native-command-executors.test-support.js"; +import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js"; + +type DeliverRepliesParams = Parameters[0]; + +const { deliveryMocks, replyMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command dispatch delivery", () => { + beforeEach(resetSessionMetaMocks); + + it("awaits routed session metadata persistence before command dispatch", async () => { + const deferred = createDeferred(); + sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise); + + const cfg: OpenClawConfig = {}; + const { handler } = registerAndResolveStatusHandler({ cfg }); + const runPromise = handler(createTelegramPrivateCommandContext()); + + await vi.waitFor(() => { + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + deferred.resolve(); + await runPromise; + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + + const dispatcherOptions = requireRecord( + requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch reply params", + ).dispatcherOptions, + "dispatcher options", + ); + expect(dispatcherOptions.beforeDeliver).toBeTypeOf("function"); + }); + + it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(createTelegramPrivateCommandContext()); + + const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as + | DeliverRepliesParams + | undefined; + const deliveredPayload = deliveredCall?.replies?.[0]; + if (!deliveredPayload) { + throw new Error("expected approval reply payload to be delivered"); + } + expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); + expect(deliveredPayload?.["channelData"]).toBeUndefined(); + }); + + it("suppresses local structured exec approval replies for native commands", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + { kind: "tool" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the empty fallback when reply-payload hooks cancel a native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the empty fallback for a message-tool-only native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the native fallback when message-tool-only delivery also fails", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("emits the fallback when a non-final suppression precedes a final failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled tool reply" }, + { kind: "tool" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("emits the fallback when a suppressed block reply precedes a final failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled block reply" }, + { kind: "block" }, + { + visibleReplySent: false, + suppression: { reason: "empty_after_reply_payload_sending_hook" }, + }, + ); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + }); + + it("emits the fallback when a final failure precedes a later suppressed final", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + await plan.delivery.onDelivered?.( + { text: "cancelled final reply" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + }); + + it("preserves a suppressed final after a non-final delivery failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.(new Error("Telegram tool delivery failed"), { + kind: "tool", + }); + await plan.delivery.onDelivered?.( + { text: "cancelled final reply" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the fallback after a partially delivered final", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.( + createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), { + visibleReplySent: true, + }), + { kind: "final" }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the empty fallback for a true non-silent metadata-only native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("sends native command error replies silently when silentErrorReplies is enabled", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + silentErrorReplies: true, + }, + }, + }, + telegramCfg: { silentErrorReplies: true }, + }); + await handler(createTelegramPrivateCommandContext()); + + const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as + | DeliverRepliesParams + | undefined; + const deliveryParams = requireValue(deliveredCall, "silent error delivery params"); + expect(deliveryParams.silent).toBe(true); + expect(deliveryParams.replies).toHaveLength(1); + expect(deliveryParams.replies[0]?.isError).toBe(true); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts new file mode 100644 index 000000000000..30146d6946bd --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts @@ -0,0 +1,432 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createConfiguredAcpTopicBinding, + createConfiguredBindingRoute, +} from "./bot-native-command-dispatch.test-support.js"; +import { + activePluginRegistry, + dispatchChannelInboundTurnMock, + executorTestMocks, + expectRecordFields, + expectSendMessageCall, + expectUnauthorizedNewCommandBlocked, + firstMockArg, + registerAndResolveCommandHandler, + registerAndResolveStatusHandler, + requireRecord, + resetSessionMetaMocks, + runWithTelegramUpdateProcessingFrame, +} from "./bot-native-command-executors.test-support.js"; +import { + createTelegramGroupCommandContext, + createTelegramPrivateCommandContext, + createTelegramTopicCommandContext, +} from "./bot-native-commands.fixture-test-support.js"; + +const { persistentBindingMocks, replyMocks, sessionBindingMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command dispatch routing", () => { + beforeEach(resetSessionMetaMocks); + + it("calls recordSessionMetaFromInbound after a native slash command", async () => { + const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" })); + activePluginRegistry.commands.push({ + pluginId: "shadow-plugin", + source: "test", + command: { + name: "status", + description: "Shadow status", + channels: ["telegram"], + requireAuth: false, + handler: shadowHandler, + }, + }); + const cfg: OpenClawConfig = {}; + const { handler } = registerAndResolveStatusHandler({ cfg }); + await handler(createTelegramPrivateCommandContext()); + + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + expect(shadowHandler).not.toHaveBeenCalled(); + const turnPlan = dispatchChannelInboundTurnMock.mock.calls[0]?.[0]; + expect(turnPlan?.replyOptions?.[Symbol.for("openclaw.pluginCommandDispatch") as never]).toEqual( + { kind: "non-plugin" }, + ); + const call = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }] + > + )[0]?.[0]; + expect(call?.ctx?.OriginatingChannel).toBe("telegram"); + expect(call?.ctx?.Provider).toBe("telegram"); + expect(call?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); + expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); + }); + + it("leaves native-command outcomes to the update middleware owner", async () => { + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await handler(createTelegramPrivateCommandContext()); + }); + + expect(result).toBeUndefined(); + }); + + 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" } }; + const { handler } = registerAndResolveStatusHandler({ cfg: startupCfg, runtimeCfg }); + + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch call", + ); + expect(dispatchCall.cfg).toBe(runtimeCfg); + }); + + it.each([ + { blockStreamingEnabled: false, expectedDisableBlockStreaming: true }, + { blockStreamingEnabled: true, expectedDisableBlockStreaming: false }, + ])( + "uses nested streaming.block.enabled=$blockStreamingEnabled for native command dispatch", + async ({ blockStreamingEnabled, expectedDisableBlockStreaming }) => { + const cfg = { + channels: { + telegram: { + streaming: { block: { enabled: blockStreamingEnabled } }, + }, + }, + } satisfies OpenClawConfig; + const { handler } = registerAndResolveStatusHandler({ cfg }); + + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch call", + ); + expect(dispatchCall.replyOptions).toMatchObject({ + disableBlockStreaming: expectedDisableBlockStreaming, + }); + }, + ); + + it("routes Telegram native commands through configured ACP topic bindings", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe(boundSessionKey); + }); + + it("routes Telegram native commands through topic-specific agent sessions", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: "zu" }, + }), + }); + await handler(createTelegramTopicCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe( + "agent:zu:telegram:group:-1001234567890:topic:42", + ); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string; ctx?: { From?: string; ChatType?: string } }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:zu:telegram:group:-1001234567890:topic:42"); + expect(sessionMetaCall?.ctx?.From).toBe("telegram:group:-1001234567890:topic:42"); + expect(sessionMetaCall?.ctx?.ChatType).toBe("group"); + }); + + it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => { + const { handler, sendMessage } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); + + it("authorizes paired Telegram DMs without marking them as owners", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [ + { + ctx?: { + CommandAuthorized?: boolean; + }; + }, + ] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true); + expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom"); + }); + + it("routes Telegram native commands through bound topic sessions", async () => { + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "default:-1001234567890:topic:42", + targetSessionKey: "agent:codex-acp:session-1", + }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + }); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1"); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-1"); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith( + "default:-1001234567890:topic:42", + undefined, + ); + }); + + it("routes Telegram native commands through bound top-level group sessions", async () => { + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "default:-1001234567890", + targetSessionKey: "agent:codex-acp:session-group", + }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramGroupCommandContext()); + + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890", + }); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string; OriginatingTo?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-group"); + expect(dispatchCall?.ctx?.OriginatingTo).toBe("telegram:-1001234567890"); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-group"); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("default:-1001234567890", undefined); + }); + + it.each(["new", "reset"] as const)( + "preserves the topic-qualified origin target for native /%s in forum topics", + async (commandName) => { + const { handler } = registerAndResolveCommandHandler({ + commandName, + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [ + { + ctx?: { + CommandTargetSessionKey?: string; + MessageThreadId?: number; + OriginatingTo?: string; + }; + }, + ] + > + )[0]?.[0]; + expectRecordFields( + dispatchCall?.ctx, + { + CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + MessageThreadId: 42, + OriginatingTo: "telegram:-1001234567890:topic:42", + }, + "topic dispatch context", + ); + }, + ); + + it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ + ok: false, + error: "gateway unavailable", + }); + + const { handler, sendMessage } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expectSendMessageCall({ + sendMessage, + chatId: -1001234567890, + text: "Configured ACP binding is unavailable right now. Please try again.", + optionFields: { message_thread_id: 42 }, + label: "unavailable ACP binding", + }); + }); + + it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "new", + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); + + it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "new", + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-dispatch.test-support.ts b/extensions/telegram/src/bot-native-command-dispatch.test-support.ts new file mode 100644 index 000000000000..09b758694fcb --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.test-support.ts @@ -0,0 +1,107 @@ +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; + +export function createConfiguredAcpTopicBinding(boundSessionKey: string) { + return { + spec: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:telegram:default:-1001234567890:topic:42", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + status: "active", + boundAt: 0, + }, + } as const; +} + +export function createConfiguredBindingRoute( + route: ResolvedAgentRoute, + binding: ReturnType | null, +) { + return { + bindingResolution: binding + ? { + conversation: binding.record.conversation, + compiledBinding: { + channel: "telegram" as const, + binding: { + type: "acp" as const, + agentId: binding.spec.agentId, + match: { + channel: "telegram", + accountId: binding.spec.accountId, + peer: { + kind: "group" as const, + id: binding.spec.conversationId, + }, + }, + acp: { + mode: binding.spec.mode, + }, + }, + bindingConversationId: binding.spec.conversationId, + target: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + agentId: binding.spec.agentId, + provider: { + compileConfiguredBinding: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + } + : null, + ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), + route, + }; +} diff --git a/extensions/telegram/src/bot-native-command-dispatch.ts b/extensions/telegram/src/bot-native-command-dispatch.ts new file mode 100644 index 000000000000..c614b9171729 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.ts @@ -0,0 +1,699 @@ +// Telegram plugin module implements native command admission and dispatch behavior. +import type { Bot, Context } from "grammy"; +import { + isChannelPartialDeliveryError, + type ChannelInboundTurnPlan, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; +import type { + ChannelGroupPolicy, + OpenClawConfig, + TelegramAccountConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; +import { + PLUGIN_COMMAND_DISPATCH, + type PluginCommandCatalogDecision, +} from "openclaw/plugin-sdk/plugin-command-runtime"; +import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramResolvedGroupConfig } from "./bot-handlers.types.js"; +import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; +import { + defaultTelegramNativeCommandDeps, + type TelegramNativeCommandDeps, +} from "./bot-native-command-deps.runtime.js"; +import type { TelegramBotOptions } from "./bot.types.js"; +import { + buildSenderName, + buildTelegramGroupFrom, + buildTelegramRoutingTarget, + buildTelegramThreadParams, + extractTelegramForumFlag, + isTelegramCommandsAllowFromConfigured, + resolveTelegramBotHasTopicsEnabled, + resolveTelegramCommandAuthorization, + resolveTelegramForumFlag, + resolveTelegramGroupAllowFromContext, + resolveTelegramMessageThreadSpec, + resolveTelegramThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramGetChat } from "./bot/types.js"; +import { + resolveTelegramConversationRoute, + resolveTelegramTargetSession, +} from "./conversation-route.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; +import { + resolveTelegramDirectToolPolicy, + resolveTelegramGroupPromptSettings, +} from "./group-config-helpers.js"; +import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; +import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; +const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({ + kind: "non-plugin", +}) satisfies PluginCommandCatalogDecision; + +const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule( + () => import("./bot-native-commands.delivery.runtime.js"), +); +const loadTelegramNativeCommandRuntime = createLazyRuntimeModule( + () => import("./bot-native-commands.runtime.js"), +); + +type TelegramNativeCommandRuntime = Awaited>; +type TelegramNativeCommandDeliveryRuntime = Awaited< + ReturnType +>; +type DeliveryBaseOptions = Omit< + Parameters[0], + "replies" | "silent" +>; + +export type TelegramCommandExecutorParams = { + botUser: Context["me"]; + msg: NonNullable; + rawText: string; + bot: Bot; + runtime: RuntimeEnv; + accountId: string; + mediaMaxBytes?: number; + resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId: number | undefined, + cfg: OpenClawConfig, + ) => TelegramResolvedGroupConfig; + telegramDeps?: TelegramNativeCommandDeps; + opts: Pick< + TelegramBotOptions, + "token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + >; +}; + +type TelegramCommandAuthResult = NonNullable< + Awaited> +>; + +export type TelegramCommandDispatch = TelegramCommandExecutorParams & + TelegramCommandAuthResult & { + telegramDeps: TelegramNativeCommandDeps; + runtimeCfg: OpenClawConfig; + runtimeTelegramCfg: TelegramAccountConfig; + turnSettings: ReturnType; + threadSpec: ReturnType; + threadParams: ReturnType; + route: ReturnType["route"]; + mediaLocalRoots: readonly string[] | undefined; + targetSessionKey: string; + nativeCommandRuntime: TelegramNativeCommandRuntime; + buildDeliveryBaseOptions: (params?: { + sessionKeyForInternalHooks?: string; + policySessionKey?: string; + }) => DeliveryBaseOptions; + loadDeliveryRuntime: () => Promise; + }; + +async function resolveTelegramNativeCommandThreadContext(params: { + msg: NonNullable; + bot: Bot; +}) { + const { msg, bot } = params; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const getChat = + typeof bot.api.getChat === "function" + ? (bot.api.getChat.bind(bot.api) as TelegramGetChat) + : undefined; + const isForum = + msg.chat.is_direct_messages === true + ? false + : await resolveTelegramForumFlag({ + chatId, + chatType: msg.chat.type, + isGroup, + isForum: extractTelegramForumFlag(msg.chat), + isTopicMessage: msg.is_topic_message, + getChat, + }); + const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); + return { + chatId, + isGroup, + isForum, + threadSpec, + threadParams: buildTelegramThreadParams(threadSpec), + }; +} + +async function resolveTelegramCommandAuth(params: { + msg: NonNullable; + bot: Bot; + cfg: OpenClawConfig; + accountId: string; + telegramCfg: TelegramAccountConfig; + readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; + allowFrom?: Array; + groupAllowFrom?: Array; + resolveGroupPolicy: TelegramCommandExecutorParams["resolveGroupPolicy"]; + resolveTelegramGroupConfig: TelegramCommandExecutorParams["resolveTelegramGroupConfig"]; + requireAuth: boolean; +}) { + const { msg, bot, cfg, accountId, telegramCfg, requireAuth } = params; + const { chatId, isGroup, isForum, threadSpec, threadParams } = + await resolveTelegramNativeCommandThreadContext({ msg, bot }); + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg); + const preContextCommandsAllowFromAccess = commandsAllowFromConfigured + ? resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + senderId, + senderUsername, + }) + : null; + const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + cfg, + chatId, + accountId, + dmPolicy: telegramCfg.dmPolicy, + allowFrom: params.allowFrom, + senderId, + isGroup, + threadSpec, + groupAllowFrom: params.groupAllowFrom, + skipPairingStoreRead: Boolean(preContextCommandsAllowFromAccess?.isAuthorizedSender), + readChannelAllowFromStore: params.readChannelAllowFromStore, + resolveTelegramGroupConfig: params.resolveTelegramGroupConfig, + }); + const { + resolvedThreadId, + dmThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + } = groupAllowContext; + const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ + isGroup, + groupConfig, + dmPolicy: telegramCfg.dmPolicy, + }); + const requireTopic = + !isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined; + if (!isGroup && requireTopic === true && dmThreadId == null) { + logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); + return null; + } + const commandsAllowFromAccess = commandsAllowFromConfigured + ? resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + senderId, + senderUsername, + }) + : null; + const ownerAccess = resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + senderId, + senderUsername, + }); + const sendAuthMessage = async (text: string) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}), + }); + return null; + }; + const rejectNotAuthorized = async () => + await sendAuthMessage("You are not authorized to use this command."); + + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: requireAuth, + requireSenderForAllowOverride: true, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + logVerbose(`Blocked telegram command in group ${chatId} (group disabled)`); + return null; + } + if (baseAccess.reason === "topic-disabled") { + logVerbose( + `Blocked telegram command in topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return null; + } + return await rejectNotAuthorized(); + } + + const policyAccess = evaluateTelegramGroupPolicyAccess({ + isGroup, + chatId, + cfg, + telegramCfg, + topicConfig, + groupConfig, + effectiveGroupAllow, + senderId, + senderUsername, + resolveGroupPolicy: params.resolveGroupPolicy, + enforcePolicy: true, + enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, + allowEmptyAllowlistEntries: true, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: true, + }); + if (!policyAccess.allowed) { + if (policyAccess.reason === "group-policy-disabled") { + logVerbose("Blocked telegram command (groupPolicy: disabled)"); + return null; + } + if ( + policyAccess.reason === "group-policy-allowlist-no-sender" || + policyAccess.reason === "group-policy-allowlist-unauthorized" + ) { + return await rejectNotAuthorized(); + } + if (policyAccess.reason === "group-chat-not-allowed") { + logVerbose(`Blocked telegram command in group ${chatId} (group not allowed)`); + return null; + } + } + + const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ + cfg, + allowFrom: groupAllowOverride ?? params.allowFrom, + accountId, + senderId, + }); + const dmAllow = normalizeDmAllowFromWithStore({ + allowFrom: expandedDmAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, + dmPolicy: effectiveDmPolicy, + }); + const commandAuthorized = commandsAllowFromConfigured + ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) + : ( + await resolveTelegramCommandIngressAuthorization({ + accountId, + cfg, + dmPolicy: effectiveDmPolicy, + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow: dmAllow, + effectiveGroupAllow, + ownerAccess, + eventKind: "native-command", + }) + ).authorized; + if (requireAuth && !commandAuthorized) { + return await rejectNotAuthorized(); + } + return { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + senderIsOwner: ownerAccess.senderIsOwner, + }; +} + +export async function prepareTelegramCommandDispatch( + params: TelegramCommandExecutorParams & { requireAuth: boolean }, +): Promise { + const telegramDeps = params.telegramDeps ?? defaultTelegramNativeCommandDeps; + const runtimeCfg = telegramDeps.getRuntimeConfig(); + const runtimeTelegramCfg = resolveTelegramAccount({ + cfg: runtimeCfg, + accountId: params.accountId, + }).config; + const turnSettings = resolveTelegramMessageTurnSettings({ + accountId: params.accountId, + cfg: runtimeCfg, + telegramCfg: runtimeTelegramCfg, + opts: params.opts, + }); + const auth = await resolveTelegramCommandAuth({ + msg: params.msg, + bot: params.bot, + cfg: runtimeCfg, + accountId: params.accountId, + telegramCfg: runtimeTelegramCfg, + readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, + allowFrom: turnSettings.allowFrom, + groupAllowFrom: turnSettings.groupAllowFrom, + resolveGroupPolicy: params.resolveGroupPolicy, + resolveTelegramGroupConfig: params.resolveTelegramGroupConfig, + requireAuth: params.requireAuth, + }); + if (!auth) { + return null; + } + const threadSpec = resolveTelegramMessageThreadSpec(params.msg, auth.isForum); + const { route, bindingMode } = resolveTelegramConversationRoute({ + cfg: runtimeCfg, + accountId: params.accountId, + chatId: auth.chatId, + isGroup: auth.isGroup, + resolvedThreadId: auth.resolvedThreadId, + replyThreadId: threadSpec.id, + senderId: auth.senderId, + topicAgentId: auth.topicConfig?.agentId, + }); + const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); + if (bindingMode.kind === "configured") { + const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({ + cfg: runtimeCfg, + bindingResolution: bindingMode.binding, + }); + if (!ensured.ok) { + logVerbose( + `telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`, + ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: params.runtime, + fn: () => + params.bot.api.sendMessage( + auth.chatId, + "Configured ACP binding is unavailable right now. Please try again.", + buildTelegramThreadParams(threadSpec) ?? {}, + ), + }); + return null; + } + } + const mediaLocalRoots = nativeCommandRuntime.getAgentScopedMediaLocalRoots( + runtimeCfg, + route.agentId, + ); + const tableMode = resolveMarkdownTableMode({ + cfg: runtimeCfg, + channel: "telegram", + accountId: route.accountId, + supportsBlockTables: true, + }); + const chunkMode = nativeCommandRuntime.resolveChunkMode(runtimeCfg, "telegram", route.accountId); + const targetSessionKey = resolveTelegramTargetSession({ + cfg: runtimeCfg, + route, + chatId: auth.chatId, + isGroup: auth.isGroup, + senderId: auth.senderId, + dmThreadId: threadSpec.scope === "dm" ? threadSpec.id : undefined, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(params.botUser), + }); + const buildDeliveryBaseOptions = (keys?: { + sessionKeyForInternalHooks?: string; + policySessionKey?: string; + }): DeliveryBaseOptions => ({ + cfg: runtimeCfg, + chatId: String(auth.chatId), + accountId: route.accountId, + sessionKeyForInternalHooks: keys?.sessionKeyForInternalHooks, + policySessionKey: keys?.policySessionKey, + mirrorIsGroup: auth.isGroup, + mirrorGroupId: auth.isGroup ? String(auth.chatId) : undefined, + token: params.opts.token, + runtime: params.runtime, + bot: params.bot, + mediaLocalRoots, + mediaMaxBytes: params.mediaMaxBytes, + replyToMode: turnSettings.replyToMode, + textLimit: turnSettings.textLimit, + thread: threadSpec, + tableMode, + chunkMode, + linkPreview: runtimeTelegramCfg.linkPreview, + richMessages: runtimeTelegramCfg.richMessages, + }); + return { + ...params, + telegramDeps, + runtimeCfg, + runtimeTelegramCfg, + turnSettings, + ...auth, + threadSpec, + threadParams: buildTelegramThreadParams(threadSpec), + route, + mediaLocalRoots, + targetSessionKey, + nativeCommandRuntime, + buildDeliveryBaseOptions, + loadDeliveryRuntime: loadTelegramNativeCommandDeliveryRuntime, + }; +} + +export async function dispatchTelegramBuiltinTurn(params: { + dispatch: TelegramCommandDispatch; + prompt: string; + commandArgs?: import("openclaw/plugin-sdk/command-auth-native").CommandArgs; +}): Promise { + const { dispatch } = params; + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig: dispatch.groupConfig, + topicConfig: dispatch.topicConfig, + }); + const { sessionKey: commandSessionKey, commandTargetSessionKey } = + resolveNativeCommandSessionTargets({ + agentId: dispatch.route.agentId, + sessionPrefix: "telegram:slash", + userId: String(dispatch.senderId || dispatch.chatId), + targetSessionKey: dispatch.targetSessionKey, + }); + let topicName: string | undefined; + if (dispatch.isForum && dispatch.resolvedThreadId != null) { + try { + const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, { + agentId: dispatch.route.accountId, + }); + topicName = await getTopicName( + dispatch.chatId, + dispatch.resolvedThreadId, + resolveTopicNameCacheScope(storePath), + ); + } catch { + // best-effort: topic name is supplementary metadata + } + } + const conversationLabel = dispatch.isGroup + ? dispatch.msg.chat.title + ? `${dispatch.msg.chat.title} id:${dispatch.chatId}` + : `group:${dispatch.chatId}` + : (buildSenderName(dispatch.msg) ?? String(dispatch.senderId || dispatch.chatId)); + const ctxPayload = dispatch.nativeCommandRuntime.finalizeInboundContext({ + Body: params.prompt, + BodyForAgent: params.prompt, + RawBody: params.prompt, + CommandBody: params.prompt, + CommandArgs: params.commandArgs, + From: dispatch.isGroup + ? buildTelegramGroupFrom(dispatch.chatId, dispatch.resolvedThreadId) + : `telegram:${dispatch.chatId}`, + To: `slash:${dispatch.senderId || dispatch.chatId}`, + ChatType: dispatch.isGroup ? "group" : "direct", + ConversationToolPolicy: dispatch.isGroup + ? undefined + : resolveTelegramDirectToolPolicy({ + directConfig: dispatch.groupConfig, + senderId: dispatch.senderId, + senderName: buildSenderName(dispatch.msg), + senderUsername: dispatch.senderUsername, + }), + ConversationLabel: conversationLabel, + GroupSubject: dispatch.isGroup ? (dispatch.msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: + dispatch.isGroup || (!dispatch.isGroup && dispatch.groupConfig) + ? groupSystemPrompt + : undefined, + SenderName: buildSenderName(dispatch.msg), + SenderId: dispatch.senderId || undefined, + SenderUsername: dispatch.senderUsername || undefined, + Surface: "telegram", + Provider: "telegram", + MessageSid: String(dispatch.msg.message_id), + Timestamp: dispatch.msg.date ? dispatch.msg.date * 1000 : undefined, + WasMentioned: true, + CommandAuthorized: dispatch.commandAuthorized, + CommandTurn: { + kind: "native" as const, + source: "native" as const, + authorized: dispatch.commandAuthorized, + body: params.prompt, + }, + CommandSource: "native" as const, + SessionKey: commandSessionKey, + AccountId: dispatch.route.accountId, + CommandTargetSessionKey: commandTargetSessionKey, + MessageThreadId: dispatch.threadSpec.id, + IsForum: dispatch.isForum, + TopicName: dispatch.isForum && topicName ? topicName : undefined, + OriginatingChannel: "telegram" as const, + OriginatingTo: buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec), + }); + const deliveryState = { delivered: false, skippedNonSilent: 0, failedNonSilent: 0 }; + let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined; + let recordSessionMetaTask: Promise | undefined; + const deliveryBaseOptions = dispatch.buildDeliveryBaseOptions({ + sessionKeyForInternalHooks: commandSessionKey, + policySessionKey: commandTargetSessionKey, + }); + const { deliverReplies } = await dispatch.loadDeliveryRuntime(); + const turnPlan: ChannelInboundTurnPlan<"provider_message_sending"> = { + cfg: dispatch.runtimeCfg, + channel: "telegram", + accountId: dispatch.route.accountId, + route: { agentId: dispatch.route.agentId, sessionKey: commandSessionKey }, + ctxPayload, + record: { + sessionKey: commandTargetSessionKey, + trackSessionMetaTask: (task) => { + recordSessionMetaTask = task; + }, + onRecordError: (error) => + dispatch.runtime.error?.( + danger(`telegram slash: failed updating session meta: ${String(error)}`), + ), + }, + afterRecord: async () => { + await recordSessionMetaTask; + }, + replyPipeline: {}, + dispatcherOptions: { + beforeDeliver: async (payload) => payload, + onSkip: (_payload, info) => { + if (info.reason !== "silent") { + deliveryState.skippedNonSilent += 1; + } + }, + }, + delivery: { + deliverWithProviderMessageSending: async (payload, info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return { visibleReplySent: false, suppression: { reason: "no_visible_result" } }; + } + const targetedPayload = payload.replyToId + ? payload + : { ...payload, replyToId: String(dispatch.msg.message_id) }; + const result = await deliverReplies({ + replies: [ + info.bindPendingFinalDelivery + ? info.bindPendingFinalDelivery(targetedPayload) + : targetedPayload, + ], + ...deliveryBaseOptions, + silent: + dispatch.runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, + onPlatformSendDispatch: info.onPlatformSendDispatch, + }); + if (result.delivered) { + deliveryState.delivered = true; + } + return result.delivered + ? { visibleReplySent: true } + : { visibleReplySent: false, suppression: { reason: "no_visible_result" as const } }; + }, + onDelivered: (_payload, info, result) => { + const reason = result?.suppression?.reason; + if (info.kind === "final" && result?.visibleReplySent) { + finalReplyOutcome = "accepted"; + } + if ( + info.kind === "final" && + finalReplyOutcome !== "failed" && + (reason === "cancelled_by_reply_payload_sending_hook" || + reason === "empty_after_reply_payload_sending_hook") + ) { + finalReplyOutcome = "suppressed"; + } + }, + onError: (error, info) => { + deliveryState.failedNonSilent += 1; + const partialDelivery = isChannelPartialDeliveryError(error); + if (partialDelivery) { + deliveryState.delivered = true; + logVerbose("telegram slash reply partially delivered before failure"); + } + if (info.kind === "final") { + finalReplyOutcome = partialDelivery ? "accepted" : "failed"; + } + dispatch.runtime.error?.( + danger(`telegram slash ${info.kind} reply failed: ${String(error)}`), + ); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming: (() => { + const enabled = resolveChannelStreamingBlockEnabled(dispatch.runtimeTelegramCfg); + return typeof enabled === "boolean" ? !enabled : undefined; + })(), + [PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH, + }, + }; + const turnResult = await ( + dispatch.telegramDeps.dispatchChannelInboundTurn ?? + defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn + )(turnPlan); + if ( + !deliveryState.delivered && + finalReplyOutcome !== "suppressed" && + (deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) && + (!turnResult.dispatched || + turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" || + deliveryState.failedNonSilent > 0) + ) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + ...deliveryBaseOptions, + }); + } + return false; +} diff --git a/extensions/telegram/src/bot-native-command-executors.test-support.ts b/extensions/telegram/src/bot-native-command-executors.test-support.ts new file mode 100644 index 000000000000..8a3b5a9722f6 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-executors.test-support.ts @@ -0,0 +1,580 @@ +export { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; +import { + createEmptyPluginRegistry, + withPluginRuntimeRegistryScope, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +export { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +// Telegram tests cover bot native commands.session meta plugin behavior. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; +import { expect, vi } from "vitest"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; +import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; +import { createConfiguredBindingRoute } from "./bot-native-command-dispatch.test-support.js"; +import { + createNativeCommandTestParams, + createTelegramPrivateCommandContext, + type NativeCommandTestParams, +} from "./bot-native-commands.fixture-test-support.js"; +export { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; + +// Shared executor test harness; each importing suite resets the state before use. + +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher; +export type DispatchReplyWithBufferedBlockDispatcherParams = + Parameters[0]; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DispatchChannelInboundTurnFn = + typeof import("openclaw/plugin-sdk/channel-inbound").dispatchChannelInboundTurn; +type ResolveCommandArgMenuFn = + typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; +type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; +type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; +type ResolveDefaultModelForAgentFn = + typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent; + +export const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], +}; + +const persistentBindingMocks = vi.hoisted(() => ({ + resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, + route, + })), + ensureConfiguredBindingRouteReady: vi.fn(async () => ({ + ok: true, + })), +})); +const sessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + sessionStoreEntries: vi.fn(), + recordSessionMetaFromInbound: vi.fn(), + resolveStorePath: vi.fn(), + updateSessionStoreEntry: vi.fn(), +})); +const commandAuthMocks = vi.hoisted(() => ({ + resolveCommandArgMenu: vi.fn(), +})); +const agentRuntimeMocks = vi.hoisted(() => ({ + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), + resolveDefaultModelForAgent: vi.fn(), +})); +const pluginRuntimeMocks = vi.hoisted(() => ({ + executePluginCommand: vi.fn(async (_params?: unknown) => ({ text: "ok" })), +})); +const replyMocks = vi.hoisted(() => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); +export const dispatchChannelInboundTurnMock = vi.fn(async (plan) => { + const recordTask = sessionMocks.recordSessionMetaFromInbound({ + storePath: sessionMocks.resolveStorePath(plan.cfg.session?.store, { + agentId: plan.route.agentId, + }), + sessionKey: plan.record?.sessionKey ?? plan.ctxPayload.SessionKey ?? plan.route.sessionKey, + ctx: plan.ctxPayload, + }); + const trackedRecordTask = Promise.resolve(recordTask).catch((error: unknown) => + plan.record?.onRecordError?.(error), + ); + plan.record?.trackSessionMetaTask?.(trackedRecordTask); + await plan.afterRecord?.(); + const deliver = async ( + payload: Parameters< + DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] + >[0], + info: Parameters< + DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] + >[1], + ) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + const result = + "deliverWithProviderMessageSending" in plan.delivery + ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) + : await plan.delivery.deliver(payload, info); + await plan.delivery.onDelivered?.(payload, info, result); + return result; + }; + const dispatchResult = await replyMocks.dispatchReplyWithBufferedBlockDispatcher({ + ctx: plan.ctxPayload, + cfg: plan.cfg, + dispatcherOptions: { + ...plan.dispatcherOptions, + deliver, + onError: plan.delivery.onError, + }, + replyOptions: plan.replyOptions, + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult, + }; +}); +const sessionBindingMocks = vi.hoisted(() => ({ + resolveByConversation: vi.fn< + (ref: unknown) => { bindingId: string; targetSessionKey: string } | null + >(() => null), + touch: vi.fn(), +})); +const conversationStoreMocks = vi.hoisted(() => ({ + readChannelAllowFromStore: vi.fn(async () => []), + upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })), +})); + +export const executorTestMocks = { + agentRuntimeMocks, + commandAuthMocks, + conversationStoreMocks, + deliveryMocks, + persistentBindingMocks, + pluginRuntimeMocks, + replyMocks, + sessionBindingMocks, + sessionMocks, +}; + +vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/conversation-runtime", + ); + return { + ...actual, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute: ( + params: Parameters[0], + ) => { + const conversation = + "conversation" in params + ? params.conversation + : { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; + const bindingRecord = sessionBindingMocks.resolveByConversation(conversation); + const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); + if (!bindingRecord || !boundSessionKey) { + return { bindingRecord: null, route: params.route }; + } + sessionBindingMocks.touch(bindingRecord.bindingId, undefined); + return { + bindingRecord, + boundSessionKey, + boundAgentId: params.route.agentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", + matchedBy: "binding.channel", + }, + }; + }, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, + upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), + }; +}); +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: sessionMocks.getSessionEntry, + sessionStoreEntries: sessionMocks.sessionStoreEntries, + resolveStorePath: sessionMocks.resolveStorePath, + updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry, + }; +}); +vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/command-auth-native", + ); + commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu); + return { + ...actual, + resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, + }; +}); +vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-runtime", + ); + agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation( + actual.resolveDefaultModelForAgent, + ); + return { + ...actual, + loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog, + resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, + }; +}); +vi.mock("./bot-native-commands.runtime.js", () => { + return { + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + getAgentScopedMediaLocalRoots, + getSessionEntry: sessionMocks.getSessionEntry, + resolveChunkMode, + resolveThreadSessionKeys, + dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< + TelegramNativeCommandDeps["dispatchChannelInboundTurn"] + >, + }; +}); +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); +vi.mock("./bot/delivery.replies.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); + +export let activePluginRegistry: ReturnType; + +type TelegramCommandHandler = (ctx: unknown) => Promise; +type TelegramPluginCommandSpecs = Array<{ + name: string; + description: string; + acceptsArgs?: boolean; +}>; +type TelegramLoginFlow = NonNullable; + +export function registerAndResolveStatusHandler(params: { + cfg: OpenClawConfig; + runtimeCfg?: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + cfg, + runtimeCfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName: "status", + cfg, + runtimeCfg, + allowFrom: allowFrom ?? ["*"], + groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + }); +} + +function registerAndResolveCommandHandlerBase(params: { + commandName: string; + cfg: OpenClawConfig; + runtimeCfg?: OpenClawConfig; + allowFrom: string[]; + groupAllowFrom: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + runtimeCfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + } = params; + const commandHandlers = new Map(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const baseRuntimeCfg = runtimeCfg ?? cfg; + const commandRuntimeCfg = baseRuntimeCfg; + const telegramDeps: TelegramNativeCommandDeps = { + getRuntimeConfig: vi.fn(() => commandRuntimeCfg), + readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), + dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< + TelegramNativeCommandDeps["dispatchChannelInboundTurn"] + >, + listSkillCommandsForAgents: vi.fn(() => []), + syncTelegramMenuCommands: vi.fn(), + sendMessageTelegram: vi.fn(async (_to, text) => { + await sendMessage(100, text, {}); + return { messageId: "999", chatId: "100" }; + }), + ...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}), + }; + withPluginRuntimeRegistryScope(activePluginRegistry, () => { + for (const spec of pluginCommandSpecs ?? []) { + expect( + registerPluginCommand(`test-${spec.name}`, { + ...spec, + requireAuth: true, + handler: pluginRuntimeMocks.executePluginCommand, + }), + ).toEqual({ ok: true }); + } + registerTelegramNativeCommands({ + ...createNativeCommandTestParams({ + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage, + }, + command: vi.fn((name: string, cb: TelegramCommandHandler) => { + commandHandlers.set(name, cb); + }), + } as unknown as NativeCommandTestParams["bot"], + cfg, + allowFrom, + groupAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + telegramDeps, + }), + }); + }); + + const handler = commandHandlers.get(commandName); + if (!handler) { + throw new Error(`expected ${commandName} command handler to be registered`); + } + return { handler, sendMessage }; +} + +export function registerAndResolveCommandHandler(params: { + commandName: string; + cfg: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName, + cfg, + allowFrom: allowFrom ?? [], + groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + }); +} + +export function requireValue(value: T | null | undefined, label: string): T { + if (value == null) { + throw new Error(`expected ${label}`); + } + return value; +} + +export const requireRecord = createRequireRecord("record", "expected-label-object"); + +export function firstMockArg( + mockFn: ReturnType, + label: string, + callIndex = 0, +): unknown { + const call = mockFn.mock.calls.at(callIndex); + if (!call) { + throw new Error(`expected ${label} call ${callIndex}`); + } + return call.at(0); +} + +export function expectRecordFields( + value: unknown, + expected: Record, + label: string, +): Record { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key], `${label}.${key}`).toEqual(expectedValue); + } + return record; +} + +export function expectSendMessageCall(params: { + sendMessage: ReturnType; + callIndex?: number; + chatId: unknown; + text?: string; + textIncludes?: string; + optionFields?: Record; + requireReplyMarkup?: boolean; + label: string; +}): Record { + const call = requireValue( + params.sendMessage.mock.calls[params.callIndex ?? 0], + `${params.label} sendMessage call`, + ); + expect(call[0]).toBe(params.chatId); + if (params.text !== undefined) { + expect(call[1]).toBe(params.text); + } + if (params.textIncludes !== undefined) { + expect(String(call[1])).toContain(params.textIncludes); + } + const options = params.optionFields + ? expectRecordFields(call[2], params.optionFields, `${params.label} sendMessage options`) + : requireRecord(call[2], `${params.label} sendMessage options`); + if (params.requireReplyMarkup) { + requireRecord(options.reply_markup, `${params.label} reply markup`); + } + return options; +} + +export function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); + expectSendMessageCall({ + sendMessage, + chatId: -1001234567890, + text: "You are not authorized to use this command.", + optionFields: { message_thread_id: 42 }, + label: "unauthorized /new", + }); +} + +export function resetSessionMetaMocks() { + persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => { + if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) { + return null; + } + const arg = command.args?.[0]; + if (!arg) { + return null; + } + if (command.key === "think") { + return { + arg, + choices: ["low", "medium", "high"].map((value) => ({ label: value, value })), + }; + } + if (command.key === "fast") { + const choices = ["on", "off", "auto (30 sec)", "default", "status"]; + return { + arg, + choices: choices.map((value) => ({ label: value, value })), + }; + } + return null; + }); + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); + sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); + sessionMocks.sessionStoreEntries.mockClear().mockReturnValue({}); + sessionMocks.getSessionEntry.mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + sessionMocks.sessionStoreEntries(storePath)[sessionKey], + ); + sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => { + const current = sessionMocks.sessionStoreEntries(params.storePath)[params.sessionKey]; + if (!current) { + return null; + } + const patch = await params.update({ ...current }); + return patch ? { ...current, ...patch } : current; + }); + sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); + sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); + pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); + activePluginRegistry = createEmptyPluginRegistry(); + replyMocks.dispatchReplyWithBufferedBlockDispatcher + .mockClear() + .mockResolvedValue(dispatchReplyResult); + dispatchChannelInboundTurnMock.mockClear(); + sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); + deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); +} + +activePluginRegistry = createEmptyPluginRegistry(); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); +resetSessionMetaMocks(); +const warmStatusHandler = registerAndResolveStatusHandler({ cfg: {} }); +await warmStatusHandler.handler(createTelegramPrivateCommandContext()); diff --git a/extensions/telegram/src/bot-native-commands.login.test.ts b/extensions/telegram/src/bot-native-command-login.test.ts similarity index 57% rename from extensions/telegram/src/bot-native-commands.login.test.ts rename to extensions/telegram/src/bot-native-command-login.test.ts index a1ae41c28a1c..d7b7f06bf941 100644 --- a/extensions/telegram/src/bot-native-commands.login.test.ts +++ b/extensions/telegram/src/bot-native-command-login.test.ts @@ -7,7 +7,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; import { createTelegramGroupCommandContext } from "./bot-native-commands.fixture-test-support.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { @@ -19,12 +21,18 @@ import { import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; +const loginSessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + loadSessionStore: vi.fn(), + resolveStorePath: vi.fn(), + updateSessionStoreEntry: vi.fn(), +})); + vi.mock("./bot-native-commands.runtime.js", () => ({ ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true })), finalizeInboundContext: vi.fn((ctx: unknown) => ctx), getAgentScopedMediaLocalRoots: vi.fn(() => []), - getSessionEntry: vi.fn(() => undefined), - recordInboundSessionMetaSafe: vi.fn(async () => undefined), + getSessionEntry: loginSessionMocks.getSessionEntry, resolveChunkMode: vi.fn(() => "length"), resolveThreadSessionKeys: vi.fn( ({ @@ -39,26 +47,33 @@ vi.mock("./bot-native-commands.runtime.js", () => ({ }), ), })); -vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({ - formatSqliteSessionFileMarker: vi.fn(() => "sqlite:test"), - getSessionEntry: vi.fn(() => undefined), - resolveStorePath: vi.fn(() => "/tmp/openclaw-login-test.sqlite"), - updateSessionStoreEntry: vi.fn(async () => undefined), -})); +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: loginSessionMocks.getSessionEntry, + resolveStorePath: loginSessionMocks.resolveStorePath, + updateSessionStoreEntry: loginSessionMocks.updateSessionStoreEntry, + }; +}); type LoginFlowMock = ReturnType; +type TelegramLoginFlow = NonNullable; let loginAccountIndex = 0; function registerLoginCommand(params: { cfg: OpenClawConfig; loginFlow: LoginFlowMock; + accountId?: string; allowFrom?: string[]; abortSignal?: AbortSignal; runtime?: RuntimeEnv; }) { const botHarness = createCommandBot(); - const accountId = `login-test-${++loginAccountIndex}`; + const accountId = params.accountId ?? `login-test-${++loginAccountIndex}`; const nativeParams = createNativeCommandTestParams(params.cfg, { accountId, bot: botHarness.bot, @@ -106,6 +121,22 @@ describe("registerTelegramNativeCommands /login", () => { beforeEach(() => { resetTelegramForumFlagCacheForTest(); resetNativeCommandMenuMocks(); + loginSessionMocks.loadSessionStore.mockReset().mockReturnValue({}); + loginSessionMocks.getSessionEntry + .mockReset() + .mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + loginSessionMocks.loadSessionStore(storePath)[sessionKey], + ); + loginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + loginSessionMocks.updateSessionStoreEntry.mockReset().mockImplementation(async (params) => { + const current = loginSessionMocks.loadSessionStore(params.storePath)[params.sessionKey]; + if (!current) { + return null; + } + const patch = await params.update({ ...current }); + return patch ? { ...current, ...patch } : current; + }); }); it("handles /login codex by sending the device code before login completes", async () => { @@ -532,4 +563,366 @@ describe("registerTelegramNativeCommands /login", () => { ); expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("moves the target session to the profile returned by Telegram /login codex", async () => { + const finishLogin = createDeferred(); + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }, + }); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "ABCD-EFGH", + expiresInMinutes: 15, + message: "URL: https://auth.openai.com/codex/device", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [ + { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, + ], + }; + }); + + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + expect(loginSessionMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); + finishLogin.resolve(); + + expect(runModelsAuthLoginFlow).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + method: "device-code", + agent: "main", + }), + ); + expect( + (runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId, + ).toBeUndefined(); + await vi.waitFor(() => + expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + storePath: "/tmp/openclaw-sessions.json", + requireWriteSuccess: true, + skipMaintenance: true, + update: expect.any(Function), + }), + ); + const patchUpdate = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: Record) => Record; + } + )?.update?.({ + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }); + expect(patchUpdate).toEqual({ + authProfileOverride: "openai:new-owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + {}, + ), + ); + }); + + it("moves a session created while Telegram login is pending to the returned profile", async () => { + const finishLogin = createDeferred(); + let sessionStore: Record = {}; + loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "NEW-SESSION", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [ + { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, + ], + }; + }); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + sessionStore = { + "agent:main:main": { + sessionId: "sess-created-during-login", + updatedAt: 2, + }, + }; + finishLogin.resolve(); + + await vi.waitFor(() => + expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledTimes(1), + ); + const update = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: SessionEntry) => Partial | null; + } + )?.update; + expect( + update?.({ + sessionId: "sess-created-during-login", + updatedAt: 2, + }), + ).toEqual({ + authProfileOverride: "openai:new-owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + {}, + ), + ); + }); + + it("preserves a later user-selected profile on a session created during Telegram login", async () => { + const finishLogin = createDeferred(); + let sessionStore: Record = {}; + loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "LATER-USER-SELECTION", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:login-profile", provider: "openai", mode: "oauth" }], + }; + }); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + sessionStore = { + "agent:main:main": { + authProfileOverride: "openai:later-user-profile", + authProfileOverrideSource: "user", + sessionId: "sess-created-during-login", + updatedAt: 2, + }, + }; + finishLogin.resolve(); + + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ), + ); + expect(sessionStore["agent:main:main"]?.authProfileOverride).toBe("openai:later-user-profile"); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("marks a same-profile Telegram login as user-selected", async () => { + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + sessionId: "sess-main", + updatedAt: 1, + }, + }); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + const update = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: Record) => Record; + } + )?.update; + expect(update).toBeTypeOf("function"); + expect( + update?.({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + sessionId: "sess-main", + updatedAt: 1, + }), + ).toEqual({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + expect( + update?.({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + sessionId: "sess-main", + updatedAt: 2, + }), + ).toBeNull(); + }); + + it("reports partial success when Telegram cannot persist the returned profile", async () => { + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:old-owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }, + }); + loginSessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed")); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("reports partial success when Telegram login returns no OpenAI profile", async () => { + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("revalidates an unchanged Telegram profile after device login", async () => { + const previousEntry = { + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + sessionId: "sess-main", + updatedAt: 1, + }; + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": previousEntry, + }); + loginSessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => { + const concurrentEntry = { + ...previousEntry, + authProfileOverride: "openai:concurrent-owner@example.com", + updatedAt: 2, + }; + const patch = await params.update({ ...concurrentEntry }); + return patch ? { ...concurrentEntry, ...patch } : concurrentEntry; + }); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-command-login.ts b/extensions/telegram/src/bot-native-command-login.ts new file mode 100644 index 000000000000..69c17747f45d --- /dev/null +++ b/extensions/telegram/src/bot-native-command-login.ts @@ -0,0 +1,271 @@ +// Telegram plugin module implements native Codex login behavior. +import type { CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { + resolveStorePath, + updateSessionStoreEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { defaultTelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; +import type { TelegramCommandDispatch } from "./bot-native-command-dispatch.js"; +import { buildTelegramRoutingTarget } from "./bot/helpers.js"; + +const activeTelegramCodexLoginFlows = codexChannelLoginRuntime.createFlowRegistry(); + +type TelegramLoginDeviceCode = { + title: string; + code: string; + expiresInMinutes?: number; + message?: string; +}; + +// Telegram's inline-code entity provides the tap-to-copy affordance needed for +// short-lived device codes; plain text and literal backticks do not. +function formatTelegramLoginDeviceCode(params: TelegramLoginDeviceCode): string { + return [ + `${escapeHtml(params.title)}`, + "", + ...(params.message ? [escapeHtml(params.message)] : []), + `Code: ${escapeHtml(params.code)}`, + ...(params.expiresInMinutes + ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] + : []), + ].join("\n"); +} + +function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string { + const providerValue = commandArgs?.values?.provider; + return typeof providerValue === "string" && providerValue.trim() + ? providerValue + : (commandArgs?.raw ?? "codex"); +} + +function buildTelegramCodexLoginFlowKey(params: { + dispatch: TelegramCommandDispatch; + provider: string; +}): string { + const { dispatch } = params; + const threadKey = + dispatch.threadSpec.id == null + ? dispatch.threadSpec.scope + : `${dispatch.threadSpec.scope}:${dispatch.threadSpec.id}`; + return [ + "telegram", + dispatch.route.accountId, + String(dispatch.chatId), + threadKey, + dispatch.route.agentId, + params.provider, + ].join(":"); +} + +export async function executeTelegramLoginCommand(params: { + dispatch: TelegramCommandDispatch; + commandArgs?: CommandArgs; +}): Promise { + const { dispatch } = params; + const sendLoginMessage = async (text: string) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => dispatch.bot.api.sendMessage(dispatch.chatId, text, dispatch.threadParams ?? {}), + }); + }; + const sendLoginDeviceCode = async (deviceCode: TelegramLoginDeviceCode) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage(dispatch.chatId, formatTelegramLoginDeviceCode(deviceCode), { + ...dispatch.threadParams, + parse_mode: "HTML", + }), + }); + }; + const sendLoginResultMessage = async (text: string) => { + await dispatch.telegramDeps.sendMessageTelegram( + buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec), + text, + { + cfg: dispatch.runtimeCfg, + token: dispatch.opts.token, + accountId: dispatch.route.accountId, + }, + ); + }; + if ( + !dispatch.senderIsOwner || + !codexChannelLoginRuntime.hasConfiguredCommandOwnerAllowlist(dispatch.runtimeCfg) + ) { + await sendLoginMessage("Only a configured OpenClaw owner can start Codex login from Telegram."); + return false; + } + if (dispatch.isGroup) { + await sendLoginMessage( + "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", + ); + return true; + } + const loginProvider = codexChannelLoginRuntime.resolveProvider( + resolveTelegramCodexLoginProviderInput(params.commandArgs), + ); + if (!loginProvider) { + await sendLoginMessage("Unsupported login provider. Use `/login codex`."); + return false; + } + const flowKey = buildTelegramCodexLoginFlowKey({ dispatch, provider: loginProvider }); + const reservation = codexChannelLoginRuntime.reserveFlow({ + flows: activeTelegramCodexLoginFlows, + flowKey, + }); + if (reservation.status === "active") { + await sendLoginMessage( + "A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.", + ); + return true; + } + const flowSignal = dispatch.opts.accountAbortSignal + ? AbortSignal.any([reservation.record.signal, dispatch.opts.accountAbortSignal]) + : reservation.record.signal; + const deviceCodeDelivered = createDeferred(); + let deviceCodeWasDelivered = false; + // Device-code delivery releases Telegram's serialized chat lane. The + // reservation and account signal still own polling through completion. + const completion = (async () => { + const sessionSwitchFailedMessage = + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually."; + let terminalMessage: string; + const loginFlow = + dispatch.telegramDeps.runModelsAuthLoginFlow ?? + defaultTelegramNativeCommandDeps.runModelsAuthLoginFlow; + try { + if (!loginFlow) { + throw new Error("Codex login flow is unavailable."); + } + const targetSessionEntryAtStart = dispatch.nativeCommandRuntime.getSessionEntry({ + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({ + runLoginFlow: loginFlow, + provider: loginProvider, + agentId: dispatch.route.agentId, + config: dispatch.runtimeCfg, + runtime: dispatch.runtime, + signal: flowSignal, + sendMessage: sendLoginMessage, + sendDeviceCode: async (deviceCode) => { + flowSignal.throwIfAborted(); + await sendLoginDeviceCode(deviceCode); + flowSignal.throwIfAborted(); + deviceCodeWasDelivered = true; + deviceCodeDelivered.resolve(); + }, + unsupportedPromptMessage: "Telegram /login supports only fixed Codex device-code auth.", + }); + flowSignal.throwIfAborted(); + const nextProfileId = loginResult.profiles.find( + (profile) => profile.provider === loginProvider, + )?.profileId; + terminalMessage = "Codex login complete. Try your request again now."; + if (!nextProfileId) { + terminalMessage = sessionSwitchFailedMessage; + } else { + const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, { + agentId: dispatch.route.agentId, + }); + let entryObserved = false; + let adoptionAllowed = false; + try { + const persisted = await updateSessionStoreEntry({ + sessionKey: dispatch.targetSessionKey, + storePath, + requireWriteSuccess: true, + skipMaintenance: true, + update: (entry) => { + entryObserved = true; + const source = + entry.authProfileOverrideSource ?? + (typeof entry.authProfileOverrideCompactionCount === "number" + ? "auto" + : entry.authProfileOverride + ? "user" + : undefined); + if ( + flowSignal.aborted || + (targetSessionEntryAtStart + ? entry.sessionId !== targetSessionEntryAtStart.sessionId || + entry.authProfileOverride !== targetSessionEntryAtStart.authProfileOverride || + entry.authProfileOverrideSource !== + targetSessionEntryAtStart.authProfileOverrideSource || + entry.authProfileOverrideCompactionCount !== + targetSessionEntryAtStart.authProfileOverrideCompactionCount + : source === "user" && entry.authProfileOverride !== nextProfileId) + ) { + return null; + } + adoptionAllowed = true; + return entry.authProfileOverride !== nextProfileId || + entry.authProfileOverrideSource !== "user" || + entry.authProfileOverrideCompactionCount !== undefined + ? { + authProfileOverride: nextProfileId, + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + } + : null; + }, + }); + flowSignal.throwIfAborted(); + if ( + entryObserved && + (!adoptionAllowed || + !persisted || + persisted.authProfileOverride !== nextProfileId || + persisted.authProfileOverrideSource !== "user" || + persisted.authProfileOverrideCompactionCount !== undefined) + ) { + terminalMessage = sessionSwitchFailedMessage; + } + } catch (error) { + flowSignal.throwIfAborted(); + dispatch.runtime.error?.( + danger( + `telegram /login codex completed but failed to update session auth profile: ${String( + error, + )}`, + ), + ); + terminalMessage = sessionSwitchFailedMessage; + } + } + } catch (error) { + if (flowSignal.aborted) { + return; + } + dispatch.runtime.error?.(danger(`telegram /login codex failed: ${String(error)}`)); + terminalMessage = "Codex login did not complete. Send `/login codex` to request a new code."; + } + if (flowSignal.aborted) { + return; + } + try { + await sendLoginResultMessage(terminalMessage); + } catch (error) { + dispatch.runtime.error?.( + danger(`telegram /login codex result notification failed: ${String(error)}`), + ); + } + })().finally(() => { + codexChannelLoginRuntime.releaseFlow({ + flows: activeTelegramCodexLoginFlows, + flowKey, + record: reservation.record, + }); + }); + await Promise.race([deviceCodeDelivered.promise, completion]); + return deviceCodeWasDelivered; +} diff --git a/extensions/telegram/src/bot-native-command-plugins.test.ts b/extensions/telegram/src/bot-native-command-plugins.test.ts new file mode 100644 index 000000000000..acf7a1cb2b76 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-plugins.test.ts @@ -0,0 +1,692 @@ +import { + createEmptyPluginRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/channel-test-helpers"; +// Telegram tests cover bot native commands plugin behavior. +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelegramTopicCommandContext } from "./bot-native-commands.fixture-test-support.js"; +import { + createCommandBot, + createNativeCommandTestParams, + createPrivateCommandContext, + deliverReplies, + editMessageTelegram, + emitTelegramMessageSentHooks, + resetNativeCommandMenuMocks, +} from "./bot-native-commands.menu-test-support.js"; +import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; + +const pluginSessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + resolveStorePath: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: pluginSessionMocks.getSessionEntry, + resolveStorePath: pluginSessionMocks.resolveStorePath, + }; +}); +type CommandBotHarness = ReturnType; +type PlugCommandHarnessParams = { + botHarness?: CommandBotHarness; + cfg?: OpenClawConfig; + command?: Record; + acceptsArgs?: boolean; + args?: string; + result?: Record; + registerOverrides?: Partial[0]>; +}; + +const pluginCommandHandler = vi.fn(async (_ctx: Record) => ({ text: "ok" })); + +function registerTestPluginCommand(params: { + name: string; + description: string; + acceptsArgs?: boolean; + command?: Record; + result?: Record; +}) { + expect( + registerPluginCommand(`test-${params.name}`, { + name: params.name, + description: params.description, + acceptsArgs: params.acceptsArgs, + requireAuth: false, + ...params.command, + handler: async (ctx) => { + const handlerResult = await pluginCommandHandler(ctx as unknown as Record); + return params.result ?? handlerResult; + }, + }), + ).toEqual({ ok: true }); +} + +function primePlugCommand(params: PlugCommandHarnessParams = {}) { + registerTestPluginCommand({ + name: "plug", + description: "Plugin command", + acceptsArgs: params.acceptsArgs ?? true, + command: params.command, + result: params.result, + }); +} + +function registerPlugCommand(params: PlugCommandHarnessParams = {}) { + const botHarness = params.botHarness ?? createCommandBot(); + primePlugCommand(params); + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(params.cfg ?? {}, { + bot: botHarness.bot, + }), + ...params.registerOverrides, + }); + const handler = botHarness.commandHandlers.get("plug"); + if (!handler) { + throw new Error("expected plug command handler to be registered"); + } + return { + ...botHarness, + handler, + }; +} + +function firstCall(mock: { mock: { calls: Array> } }) { + const call = mock.mock.calls.at(0); + if (!call) { + throw new Error("expected first mock call"); + } + return call; +} + +function firstCallArg(mock: { mock: { calls: Array> } }, argIndex = 0) { + const arg = firstCall(mock)[argIndex]; + if (!arg || typeof arg !== "object") { + throw new Error(`expected first mock call arg ${argIndex}`); + } + return arg as Record; +} + +function firstDeliverRepliesParams() { + return firstCallArg(deliverReplies as unknown as { mock: { calls: Array> } }); +} + +function firstExecutePluginCommandParams() { + return firstCallArg( + pluginCommandHandler as unknown as { + mock: { calls: Array> }; + }, + ); +} + +function replyAt(params: Record, index = 0) { + const replies = params.replies as Array> | undefined; + const reply = replies?.[index]; + if (!reply) { + throw new Error(`expected reply ${index}`); + } + return reply; +} + +resetPluginRuntimeStateForTest(); +setActivePluginRegistry(createEmptyPluginRegistry()); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); +registerTelegramNativeCommands(createNativeCommandTestParams({})); + +describe("registerTelegramNativeCommands", () => { + beforeEach(() => { + resetTelegramForumFlagCacheForTest(); + resetNativeCommandMenuMocks(); + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); + clearPluginCommands(); + pluginCommandHandler.mockReset().mockResolvedValue({ text: "ok" }); + pluginSessionMocks.getSessionEntry.mockReset().mockReturnValue(undefined); + pluginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + }); + + it("passes agent-scoped media roots for plugin command replies with media", async () => { + const mediaMaxBytes = 50 * 1024 * 1024; + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }], + }; + + const { handler, sendMessage } = registerPlugCommand({ + cfg, + result: { + text: "with media", + mediaUrl: "/tmp/workspace-work/render.png", + }, + registerOverrides: { + mediaMaxBytes, + } as Partial[0]>, + }); + + await handler(createPrivateCommandContext()); + + const deliverParams = firstDeliverRepliesParams(); + expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes); + const mediaLocalRoots = deliverParams.mediaLocalRoots as Array | undefined; + expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe( + true, + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); + + it("delivers presentation-only tables returned by plugin commands", async () => { + const presentation = { + title: "FY25 outlook", + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + ], + }; + const { handler } = registerPlugCommand({ result: { presentation } }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation }); + expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined(); + }); + + it("delivers Telegram button-only plugin command replies", async () => { + const buttons = [[{ text: "Retry", callback_data: "retry" }]]; + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { buttons } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + channelData: { telegram: { buttons } }, + }); + }); + + it("targets reaction-only plugin replies at the invoking command message", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } }, + }); + + await handler(createPrivateCommandContext({ messageId: 321 })); + + const deliveryParams = firstDeliverRepliesParams(); + expect(replyAt(deliveryParams)).toEqual({ + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }); + expect(deliveryParams.replyToMode).toBe("all"); + }); + + it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { plugin: { traceId: "trace-1" } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + text: "No response generated. Please try again.", + }); + }); + + it("replies to unmatched plugin commands in the originating forum topic", async () => { + const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false }); + + await handler({ + match: "unexpected", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + message_thread_id: 77, + from: { id: 200, username: "bob" }, + }, + }); + + const sendMessageCall = firstCall(sendMessage); + expect(sendMessageCall[0]).toBe(-1001234567890); + expect(sendMessageCall[1]).toBe("Command not found."); + expect( + (sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id, + ).toBe(77); + }); + + it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { + telegram: + "Running this command now...\n\nI'll edit this message with the final result when it's ready.", + }, + }, + result: { + text: "Command completed successfully", + }, + }); + + await handler( + createPrivateCommandContext({ + match: "now", + }), + ); + + const sendMessageCall = firstCall(sendMessage); + expect(sendMessageCall[0]).toBe(100); + expect(String(sendMessageCall[1])).toContain("Running this command now"); + expect(sendMessageCall[2]).toBeUndefined(); + const editCall = firstCall( + editMessageTelegram as unknown as { mock: { calls: Array> } }, + ); + expect(editCall[0]).toBe(100); + expect(editCall[1]).toBe(999); + expect(String(editCall[2])).toContain("Command completed successfully"); + expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default"); + expect(deleteMessage).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + const hookParams = firstCallArg( + emitTelegramMessageSentHooks as unknown as { mock: { calls: Array> } }, + ); + expect(hookParams.chatId).toBe("100"); + expect(hookParams.content).toBe("Command completed successfully"); + expect(hookParams.messageId).toBe(999); + expect(hookParams.success).toBe(true); + }); + + it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Choose an option", + channelData: { + telegram: { + buttons: [[{ text: "Approve", callback_data: "approve" }]], + }, + }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + const editCall = firstCall( + editMessageTelegram as unknown as { mock: { calls: Array> } }, + ); + expect(editCall[0]).toBe(100); + expect(editCall[1]).toBe(999); + expect(editCall[2]).toBe("Choose an option"); + expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([ + [{ text: "Approve", callback_data: "approve" }], + ]); + expect(deleteMessage).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Command completed successfully", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now", messageId: 321 })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + const deliveryParams = firstDeliverRepliesParams(); + expect(deliveryParams.replyToMode).toBe("all"); + expect(replyAt(deliveryParams)).toEqual({ + text: "Command completed successfully", + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }); + }); + + it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "rich output", + mediaUrl: "/tmp/render.png", + }, + }); + + await handler( + createPrivateCommandContext({ + match: "now", + }), + ); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png"); + }); + + it("falls back to a normal reply when a progress result has presentation controls", async () => { + const presentation = { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }], + }, + ], + }; + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Approval required", + presentation, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ + text: "Approval required", + presentation, + }); + }); + + it("cleans up the progress placeholder before falling back after an edit failure", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Command completed successfully", + }, + }); + editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found")); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams()).text).toBe("Command completed successfully"); + }); + + it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { + const { handler } = registerPlugCommand({ + cfg: { + channels: { + telegram: { + silentErrorReplies: true, + }, + }, + }, + result: { + text: "plugin failed", + isError: true, + }, + registerOverrides: { + telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, + }, + }); + + await handler(createPrivateCommandContext()); + + const deliverParams = firstDeliverRepliesParams(); + expect(deliverParams.silent).toBe(true); + expect(replyAt(deliverParams).isError).toBe(true); + }); + + it("uses rich messages for plugin command replies when enabled", async () => { + const { handler } = registerPlugCommand({ + cfg: { + channels: { + telegram: { + richMessages: true, + }, + }, + }, + registerOverrides: { + telegramCfg: { richMessages: true } as TelegramAccountConfig, + }, + }); + + await handler(createPrivateCommandContext()); + + expect(firstDeliverRepliesParams().richMessages).toBe(true); + }); + + it("forwards topic-scoped binding context to Telegram plugin commands", async () => { + const { handler } = registerPlugCommand(); + + await handler({ + match: "", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + message_thread_id: 77, + from: { id: 200, username: "bob" }, + }, + }); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.channel).toBe("telegram"); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77"); + expect(commandParams.to).toBe("telegram:-1001234567890"); + expect(commandParams.messageThreadId).toBe(77); + }); + + it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => { + const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true })); + const { handler } = registerPlugCommand({ + botHarness: createCommandBot({ api: { getChat } }), + }); + + await handler({ + match: "", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + }, + from: { id: 200, username: "bob" }, + }, + }); + + expect(getChat).toHaveBeenCalledWith(-1001234567890); + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1"); + expect(commandParams.to).toBe("telegram:-1001234567890"); + expect(commandParams.messageThreadId).toBe(1); + }); + + it("forwards direct-message binding context to Telegram plugin commands", async () => { + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ chatId: 100, userId: 200 })); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.channel).toBe("telegram"); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:100"); + expect(commandParams.to).toBe("telegram:100"); + expect(commandParams.messageThreadId).toBeUndefined(); + }); + + it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => { + const { handler } = registerPlugCommand({ + result: { suppressReply: true }, + }); + + await handler(createPrivateCommandContext()); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + + it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => { + const { handler } = registerPlugCommand(); + + await handler({ + ...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }), + me: { has_topics_enabled: true }, + }); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77"); + const deliveryParams = firstDeliverRepliesParams(); + expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77"); + }); + + it("passes persisted topic session identity to plugin commands", async () => { + pluginSessionMocks.getSessionEntry.mockReturnValue({ + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-topic", + updatedAt: 1, + }); + const { handler } = registerPlugCommand({ + cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, + }); + + await handler( + createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }), + ); + + expect(firstExecutePluginCommandParams()).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + sessionId: "sess-topic", + messageThreadId: 42, + }), + ); + }); + + it.each([ + { + name: "creates a SQLite marker when the entry has no file", + entry: { sessionId: "sess-main", updatedAt: 1 } satisfies SessionEntry, + }, + { + name: "keeps the canonical SQLite marker", + entry: { + sessionId: "sess-main", + sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json", + updatedAt: 1, + } satisfies SessionEntry, + }, + { + name: "replaces a stale legacy transcript path", + entry: { + sessionId: "sess-main", + sessionFile: "sess-main.jsonl", + updatedAt: 1, + } satisfies SessionEntry, + }, + ])("$name", async ({ entry }) => { + pluginSessionMocks.getSessionEntry.mockReturnValue(entry); + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ match: "status" })); + + expect(firstExecutePluginCommandParams()).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:main", + sessionId: "sess-main", + sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json", + }), + ); + }); + + it("sends an empty-response fallback when a plugin command returns undefined", async () => { + pluginCommandHandler.mockResolvedValueOnce(undefined as never); + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ match: "status" })); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + text: "No response generated. Please try again.", + }); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-plugins.ts b/extensions/telegram/src/bot-native-command-plugins.ts new file mode 100644 index 000000000000..592f741d98e2 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-plugins.ts @@ -0,0 +1,316 @@ +// Telegram plugin module implements native plugin command behavior. +import { randomUUID } from "node:crypto"; +import type { Bot, Context } from "grammy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { PluginCommandNativeCandidate } from "openclaw/plugin-sdk/plugin-command-runtime"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { + formatSqliteSessionFileMarker, + getSessionEntry, + resolveStorePath, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + prepareTelegramCommandDispatch, + type TelegramCommandExecutorParams, +} from "./bot-native-command-dispatch.js"; +import { + buildTelegramRoutingTarget, + buildTelegramGroupFrom, + buildTelegramThreadParams, + extractTelegramForumFlag, + resolveTelegramForumFlag, + resolveTelegramMessageThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramGetChat } from "./bot/types.js"; +import type { TelegramInlineButtons } from "./button-types.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { buildInlineKeyboard } from "./inline-keyboard.js"; +import { recordSentMessage } from "./sent-message-cache.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + +type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult; +type TelegramNativeReplyChannelData = { + buttons?: TelegramInlineButtons; + pin?: boolean; + reaction?: { emoji?: unknown }; +}; + +function resolveTelegramNativeReplyChannelData( + result: TelegramNativeReplyPayload, +): TelegramNativeReplyChannelData | undefined { + return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined; +} + +function normalizeTelegramNativeReplyPayload( + result: TelegramNativeReplyPayload | null | undefined, +): TelegramNativeReplyPayload { + return result && typeof result === "object" ? result : {}; +} + +function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean { + const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji; + return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0; +} + +function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { + const { channelData: _channelData, ...portableContent } = result; + if (hasOutboundReplyContent(portableContent, { trimText: true })) { + return true; + } + const telegramData = resolveTelegramNativeReplyChannelData(result); + return Boolean( + buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result), + ); +} + +function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { + const telegramData = resolveTelegramNativeReplyChannelData(result); + return Boolean( + typeof result.text === "string" && + result.text.trim() && + !result.mediaUrl && + (!result.mediaUrls || result.mediaUrls.length === 0) && + !result.presentation && + !result.interactive && + !result.btw && + !hasTelegramNativeReplyReaction(result) && + telegramData?.pin !== true, + ); +} + +async function cleanupTelegramProgressPlaceholder(params: { + bot: Bot; + chatId: number; + progressMessageId?: number; + runtime: TelegramCommandExecutorParams["runtime"]; +}): Promise { + if (params.progressMessageId == null) { + return; + } + try { + await withTelegramApiErrorLogging({ + operation: "deleteMessage", + runtime: params.runtime, + fn: () => params.bot.api.deleteMessage(params.chatId, params.progressMessageId!), + }); + } catch { + // Best-effort cleanup before fallback or suppression exits. + } +} + +async function resolveTelegramPluginThreadParams(params: { + msg: NonNullable; + bot: Bot; +}) { + const isGroup = params.msg.chat.type === "group" || params.msg.chat.type === "supergroup"; + const getChat = + typeof params.bot.api.getChat === "function" + ? (params.bot.api.getChat.bind(params.bot.api) as TelegramGetChat) + : undefined; + const isForum = + params.msg.chat.is_direct_messages === true + ? false + : await resolveTelegramForumFlag({ + chatId: params.msg.chat.id, + chatType: params.msg.chat.type, + isGroup, + isForum: extractTelegramForumFlag(params.msg.chat), + isTopicMessage: params.msg.is_topic_message, + getChat, + }); + return buildTelegramThreadParams(resolveTelegramMessageThreadSpec(params.msg, isForum)); +} + +async function resolveTelegramCommandTranscriptContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): Promise<{ sessionId?: string; sessionFile?: string; authProfileId?: string }> { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ agentId: params.agentId, sessionKey, storePath }); + const sessionId = entry?.sessionId?.trim() || randomUUID(); + const sessionFile = formatSqliteSessionFileMarker({ + agentId: params.agentId, + sessionId, + storePath, + }); + const authProfileId = normalizeOptionalString(entry?.authProfileOverride); + return { sessionId, sessionFile, ...(authProfileId ? { authProfileId } : {}) }; + } catch { + return {}; + } +} + +export async function executeTelegramPluginCommand( + params: TelegramCommandExecutorParams & { + commandName: string; + candidate: PluginCommandNativeCandidate; + }, +): Promise { + const commandBody = `/${params.commandName}${params.rawText ? ` ${params.rawText}` : ""}`; + const pluginCommandDispatch = params.candidate.prepareDispatch(params.rawText); + if (pluginCommandDispatch.kind === "non-plugin") { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: params.runtime, + fn: async () => + await params.bot.api.sendMessage( + params.msg.chat.id, + "Command not found.", + (await resolveTelegramPluginThreadParams(params)) ?? {}, + ), + }); + return; + } + const dispatch = await prepareTelegramCommandDispatch({ + ...params, + requireAuth: params.candidate.requireAuth, + }); + if (!dispatch) { + return; + } + const targetSessionEntry = dispatch.nativeCommandRuntime.getSessionEntry({ + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const from = dispatch.isGroup + ? buildTelegramGroupFrom(dispatch.chatId, dispatch.threadSpec.id) + : `telegram:${dispatch.chatId}`; + const to = + dispatch.threadSpec.scope === "direct-messages" + ? buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec) + : `telegram:${dispatch.chatId}`; + const { deliverReplies, emitTelegramMessageSentHooks } = await dispatch.loadDeliveryRuntime(); + let progressMessageId: number | undefined; + if (params.candidate.progressMessage) { + try { + const sent = await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage( + dispatch.chatId, + params.candidate.progressMessage!, + buildTelegramThreadParams(dispatch.threadSpec), + ), + }); + const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id; + if (typeof maybeMessageId === "number") { + progressMessageId = maybeMessageId; + } + } catch { + // Fall back to the normal final reply path if the placeholder send fails. + } + } + const transcriptContext = await resolveTelegramCommandTranscriptContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const result = normalizeTelegramNativeReplyPayload( + await pluginCommandDispatch.execute({ + senderId: dispatch.senderId, + channel: "telegram", + isAuthorizedSender: dispatch.commandAuthorized, + senderIsOwner: dispatch.senderIsOwner, + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + sessionId: transcriptContext.sessionId, + sessionFile: transcriptContext.sessionFile, + authProfileId: transcriptContext.authProfileId ?? targetSessionEntry?.authProfileOverride, + commandBody, + config: dispatch.runtimeCfg, + from, + to, + accountId: dispatch.accountId, + messageThreadId: dispatch.threadSpec.id, + }), + ); + const suppressReply = + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + payload: result, + }) || result.suppressReply === true; + if (suppressReply) { + await cleanupTelegramProgressPlaceholder({ + bot: dispatch.bot, + chatId: dispatch.chatId, + progressMessageId, + runtime: dispatch.runtime, + }); + return; + } + const hasReaction = hasTelegramNativeReplyReaction(result); + const deliverableResult: TelegramNativeReplyPayload = hasRenderableTelegramNativeReplyPayload( + result, + ) + ? hasReaction && !normalizeOptionalString(result.replyToId) + ? { ...result, replyToId: String(dispatch.msg.message_id) } + : result + : { text: EMPTY_RESPONSE_FALLBACK }; + const progressResultText = + typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 + ? deliverableResult.text + : null; + const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult); + if ( + progressMessageId != null && + dispatch.telegramDeps.editMessageTelegram && + progressResultText && + isEditableTelegramProgressResult(deliverableResult) + ) { + try { + await dispatch.telegramDeps.editMessageTelegram( + dispatch.chatId, + progressMessageId, + progressResultText, + { + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + textMode: "markdown", + linkPreview: dispatch.runtimeTelegramCfg.linkPreview, + buttons: telegramResultData?.buttons, + }, + ); + recordSentMessage(dispatch.chatId, progressMessageId, dispatch.runtimeCfg); + emitTelegramMessageSentHooks({ + sessionKeyForInternalHooks: dispatch.targetSessionKey, + chatId: String(dispatch.chatId), + accountId: dispatch.route.accountId, + content: progressResultText, + success: true, + messageId: progressMessageId, + isGroup: dispatch.isGroup, + groupId: dispatch.isGroup ? String(dispatch.chatId) : undefined, + }); + return; + } catch { + // Fall through to cleanup + normal delivered reply if editing fails. + } + } + await cleanupTelegramProgressPlaceholder({ + bot: dispatch.bot, + chatId: dispatch.chatId, + progressMessageId, + runtime: dispatch.runtime, + }); + await deliverReplies({ + replies: [deliverableResult], + ...dispatch.buildDeliveryBaseOptions({ + sessionKeyForInternalHooks: dispatch.targetSessionKey, + policySessionKey: dispatch.targetSessionKey, + }), + ...(hasReaction ? { replyToMode: "all" as const } : {}), + silent: + dispatch.runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, + }); +} diff --git a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts index 1e98bc24ec91..fd81399c8569 100644 --- a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts +++ b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts @@ -1,5 +1,4 @@ // Telegram plugin module implements bot native commandselivery behavior. -import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js"; -export { createChannelMessageReplyPipeline, deliverReplies, emitTelegramMessageSentHooks }; +export { deliverReplies, emitTelegramMessageSentHooks }; diff --git a/extensions/telegram/src/bot-native-commands.runtime.ts b/extensions/telegram/src/bot-native-commands.runtime.ts index a42867b2565d..6eef942b8771 100644 --- a/extensions/telegram/src/bot-native-commands.runtime.ts +++ b/extensions/telegram/src/bot-native-commands.runtime.ts @@ -1,8 +1,5 @@ // Telegram plugin module implements bot native commands behavior. -export { - ensureConfiguredBindingRouteReady, - recordInboundSessionMetaSafe, -} from "openclaw/plugin-sdk/conversation-runtime"; +export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; export { finalizeInboundContext, diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts deleted file mode 100644 index 0f351d70b58c..000000000000 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ /dev/null @@ -1,2491 +0,0 @@ -import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; -import { - createEmptyPluginRegistry, - withPluginRuntimeRegistryScope, -} from "openclaw/plugin-sdk/channel-test-helpers"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; -import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; -import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; -import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; -import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -// Telegram tests cover bot native commands.session meta plugin behavior. -import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; -import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; -import { - createTelegramGroupCommandContext, - createNativeCommandTestParams, - createTelegramPrivateCommandContext, - createTelegramTopicCommandContext, - type NativeCommandTestParams, -} from "./bot-native-commands.fixture-test-support.js"; -import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; - -// All mocks scoped to this file only — does not affect bot-native-commands.test.ts - -type ResolveConfiguredBindingRouteFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; -type EnsureConfiguredBindingRouteReadyFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; -type DispatchReplyWithBufferedBlockDispatcherFn = - typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher; -type DispatchReplyWithBufferedBlockDispatcherParams = - Parameters[0]; -type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< - ReturnType ->; -type DispatchChannelInboundTurnFn = - typeof import("openclaw/plugin-sdk/channel-inbound").dispatchChannelInboundTurn; -type ResolveCommandArgMenuFn = - typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; -type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; -type DeliverRepliesParams = Parameters[0]; -type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; -type ResolveDefaultModelForAgentFn = - typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent; - -const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { - queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], -}; - -const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ - bindingResolution: null, - route, - })), - ensureConfiguredBindingRouteReady: vi.fn(async () => ({ - ok: true, - })), -})); -const sessionMocks = vi.hoisted(() => ({ - getSessionEntry: vi.fn(), - loadSessionStore: vi.fn(), - recordSessionMetaFromInbound: vi.fn(), - resolveStorePath: vi.fn(), - updateSessionStoreEntry: vi.fn(), -})); -const commandAuthMocks = vi.hoisted(() => ({ - resolveCommandArgMenu: vi.fn(), -})); -const agentRuntimeMocks = vi.hoisted(() => ({ - loadModelCatalog: vi.fn(async () => [ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - reasoning: true, - }, - ]), - resolveDefaultModelForAgent: vi.fn(), -})); -const pluginRuntimeMocks = vi.hoisted(() => ({ - executePluginCommand: vi.fn(async (_params?: unknown) => ({ text: "ok" })), -})); -const replyMocks = vi.hoisted(() => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn( - async () => dispatchReplyResult, - ), -})); -const deliveryMocks = vi.hoisted(() => ({ - deliverReplies: vi.fn(async () => ({ delivered: true })), -})); -const dispatchChannelInboundTurnMock = vi.fn(async (plan) => { - const recordTask = sessionMocks.recordSessionMetaFromInbound({ - storePath: sessionMocks.resolveStorePath(plan.cfg.session?.store, { - agentId: plan.route.agentId, - }), - sessionKey: plan.record?.sessionKey ?? plan.ctxPayload.SessionKey ?? plan.route.sessionKey, - ctx: plan.ctxPayload, - }); - const trackedRecordTask = Promise.resolve(recordTask).catch((error: unknown) => - plan.record?.onRecordError?.(error), - ); - plan.record?.trackSessionMetaTask?.(trackedRecordTask); - await plan.afterRecord?.(); - const deliver = async ( - payload: Parameters< - DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] - >[0], - info: Parameters< - DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] - >[1], - ) => { - const providerInfo = { - ...info, - onPlatformSendDispatch: async () => undefined, - }; - const result = - "deliverWithProviderMessageSending" in plan.delivery - ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) - : await plan.delivery.deliver(payload, info); - await plan.delivery.onDelivered?.(payload, info, result); - return result; - }; - const dispatchResult = await replyMocks.dispatchReplyWithBufferedBlockDispatcher({ - ctx: plan.ctxPayload, - cfg: plan.cfg, - dispatcherOptions: { - ...plan.dispatcherOptions, - deliver, - onError: plan.delivery.onError, - }, - replyOptions: plan.replyOptions, - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult, - }; -}); -const sessionBindingMocks = vi.hoisted(() => ({ - resolveByConversation: vi.fn< - (ref: unknown) => { bindingId: string; targetSessionKey: string } | null - >(() => null), - touch: vi.fn(), -})); -const conversationStoreMocks = vi.hoisted(() => ({ - readChannelAllowFromStore: vi.fn(async () => []), - upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })), -})); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/conversation-runtime", - ); - return { - ...actual, - resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, - resolveRuntimeConversationBindingRoute: ( - params: Parameters[0], - ) => { - const conversation = - "conversation" in params - ? params.conversation - : { - channel: params.channel, - accountId: params.accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - }; - const bindingRecord = sessionBindingMocks.resolveByConversation(conversation); - const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); - if (!bindingRecord || !boundSessionKey) { - return { bindingRecord: null, route: params.route }; - } - sessionBindingMocks.touch(bindingRecord.bindingId, undefined); - return { - bindingRecord, - boundSessionKey, - boundAgentId: params.route.agentId, - route: { - ...params.route, - sessionKey: boundSessionKey, - lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", - matchedBy: "binding.channel", - }, - }; - }, - ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - recordInboundSessionMetaSafe: vi.fn( - async (params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - ctx: unknown; - onError?: (error: unknown) => void; - }) => { - const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { - agentId: params.agentId, - }); - try { - await sessionMocks.recordSessionMetaFromInbound({ - storePath, - sessionKey: params.sessionKey, - ctx: params.ctx, - }); - } catch (error) { - params.onError?.(error); - } - }, - ), - readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, - upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, - getSessionBindingService: () => ({ - bind: vi.fn(), - getCapabilities: vi.fn(), - listBySession: vi.fn(), - resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), - touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), - unbind: vi.fn(), - }), - }; -}); -vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/session-store-runtime", - ); - return { - ...actual, - getSessionEntry: sessionMocks.getSessionEntry, - loadSessionStore: sessionMocks.loadSessionStore, - resolveStorePath: sessionMocks.resolveStorePath, - updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry, - }; -}); -vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/command-auth-native", - ); - commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu); - return { - ...actual, - resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, - }; -}); -vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/agent-runtime", - ); - agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation( - actual.resolveDefaultModelForAgent, - ); - return { - ...actual, - loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog, - resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, - }; -}); -vi.mock("./bot-native-commands.runtime.js", () => { - return { - ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), - getAgentScopedMediaLocalRoots, - getSessionEntry: sessionMocks.getSessionEntry, - recordInboundSessionMetaSafe: vi.fn( - async (params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - ctx: unknown; - onError?: (error: unknown) => void; - }) => { - const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { - agentId: params.agentId, - }); - try { - await sessionMocks.recordSessionMetaFromInbound({ - storePath, - sessionKey: params.sessionKey, - ctx: params.ctx, - }); - } catch (error) { - params.onError?.(error); - } - }, - ), - resolveChunkMode, - resolveThreadSessionKeys, - dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< - TelegramNativeCommandDeps["dispatchChannelInboundTurn"] - >, - }; -}); -vi.mock("./bot/delivery.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); -vi.mock("./bot/delivery.replies.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); - -let activePluginRegistry: ReturnType; - -type TelegramCommandHandler = (ctx: unknown) => Promise; -type TelegramPluginCommandSpecs = Array<{ - name: string; - description: string; - acceptsArgs?: boolean; -}>; -type TelegramLoginFlow = NonNullable; - -function registerAndResolveStatusHandler(params: { - cfg: OpenClawConfig; - runtimeCfg?: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - cfg, - runtimeCfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - } = params; - return registerAndResolveCommandHandlerBase({ - commandName: "status", - cfg, - runtimeCfg, - allowFrom: allowFrom ?? ["*"], - groupAllowFrom: groupAllowFrom ?? [], - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - }); -} - -function registerAndResolveCommandHandlerBase(params: { - commandName: string; - cfg: OpenClawConfig; - runtimeCfg?: OpenClawConfig; - allowFrom: string[]; - groupAllowFrom: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; - pluginCommandSpecs?: TelegramPluginCommandSpecs; - runModelsAuthLoginFlow?: TelegramLoginFlow; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - commandName, - cfg, - runtimeCfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - } = params; - const commandHandlers = new Map(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - const baseRuntimeCfg = runtimeCfg ?? cfg; - const commandRuntimeCfg = baseRuntimeCfg; - const telegramDeps: TelegramNativeCommandDeps = { - getRuntimeConfig: vi.fn(() => commandRuntimeCfg), - readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), - dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< - TelegramNativeCommandDeps["dispatchChannelInboundTurn"] - >, - listSkillCommandsForAgents: vi.fn(() => []), - syncTelegramMenuCommands: vi.fn(), - sendMessageTelegram: vi.fn(async (_to, text) => { - await sendMessage(100, text, {}); - return { messageId: "999", chatId: "100" }; - }), - ...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}), - }; - withPluginRuntimeRegistryScope(activePluginRegistry, () => { - for (const spec of pluginCommandSpecs ?? []) { - expect( - registerPluginCommand(`test-${spec.name}`, { - ...spec, - requireAuth: true, - handler: pluginRuntimeMocks.executePluginCommand, - }), - ).toEqual({ ok: true }); - } - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: TelegramCommandHandler) => { - commandHandlers.set(name, cb); - }), - } as unknown as NativeCommandTestParams["bot"], - cfg, - allowFrom, - groupAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - telegramDeps, - }), - }); - }); - - const handler = commandHandlers.get(commandName); - if (!handler) { - throw new Error(`expected ${commandName} command handler to be registered`); - } - return { handler, sendMessage }; -} - -function registerAndResolveCommandHandler(params: { - commandName: string; - cfg: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; - pluginCommandSpecs?: TelegramPluginCommandSpecs; - runModelsAuthLoginFlow?: TelegramLoginFlow; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - commandName, - cfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - } = params; - return registerAndResolveCommandHandlerBase({ - commandName, - cfg, - allowFrom: allowFrom ?? [], - groupAllowFrom: groupAllowFrom ?? [], - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - }); -} - -function createConfiguredAcpTopicBinding(boundSessionKey: string) { - return { - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - } as const; -} - -function createConfiguredBindingRoute( - route: ResolvedAgentRoute, - binding: ReturnType | null, -) { - return { - bindingResolution: binding - ? { - conversation: binding.record.conversation, - compiledBinding: { - channel: "telegram" as const, - binding: { - type: "acp" as const, - agentId: binding.spec.agentId, - match: { - channel: "telegram", - accountId: binding.spec.accountId, - peer: { - kind: "group" as const, - id: binding.spec.conversationId, - }, - }, - acp: { - mode: binding.spec.mode, - }, - }, - bindingConversationId: binding.spec.conversationId, - target: { - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }, - agentId: binding.spec.agentId, - provider: { - compileConfiguredBinding: () => ({ - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }), - matchInboundConversation: () => ({ - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }), - }, - targetFactory: { - driverId: "acp" as const, - materialize: () => ({ - record: binding.record, - statefulTarget: { - kind: "stateful" as const, - driverId: "acp" as const, - sessionKey: binding.record.targetSessionKey, - agentId: binding.spec.agentId, - }, - }), - }, - }, - match: { - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }, - record: binding.record, - statefulTarget: { - kind: "stateful" as const, - driverId: "acp" as const, - sessionKey: binding.record.targetSessionKey, - agentId: binding.spec.agentId, - }, - } - : null, - ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), - route, - }; -} - -function requireValue(value: T | null | undefined, label: string): T { - if (value == null) { - throw new Error(`expected ${label}`); - } - return value; -} - -const requireRecord = createRequireRecord("record", "expected-label-object"); - -function firstMockArg(mockFn: ReturnType, label: string, callIndex = 0): unknown { - const call = mockFn.mock.calls.at(callIndex); - if (!call) { - throw new Error(`expected ${label} call ${callIndex}`); - } - return call.at(0); -} - -function expectRecordFields( - value: unknown, - expected: Record, - label: string, -): Record { - const record = requireRecord(value, label); - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key], `${label}.${key}`).toEqual(expectedValue); - } - return record; -} - -function expectSendMessageCall(params: { - sendMessage: ReturnType; - callIndex?: number; - chatId: unknown; - text?: string; - textIncludes?: string; - optionFields?: Record; - requireReplyMarkup?: boolean; - label: string; -}): Record { - const call = requireValue( - params.sendMessage.mock.calls[params.callIndex ?? 0], - `${params.label} sendMessage call`, - ); - expect(call[0]).toBe(params.chatId); - if (params.text !== undefined) { - expect(call[1]).toBe(params.text); - } - if (params.textIncludes !== undefined) { - expect(String(call[1])).toContain(params.textIncludes); - } - const options = params.optionFields - ? expectRecordFields(call[2], params.optionFields, `${params.label} sendMessage options`) - : requireRecord(call[2], `${params.label} sendMessage options`); - if (params.requireReplyMarkup) { - requireRecord(options.reply_markup, `${params.label} reply markup`); - } - return options; -} - -function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); - expectSendMessageCall({ - sendMessage, - chatId: -1001234567890, - text: "You are not authorized to use this command.", - optionFields: { message_thread_id: 42 }, - label: "unauthorized /new", - }); -} - -function resetSessionMetaMocks() { - persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute(route, null), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => { - if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) { - return null; - } - const arg = command.args?.[0]; - if (!arg) { - return null; - } - if (command.key === "think") { - return { - arg, - choices: ["low", "medium", "high"].map((value) => ({ label: value, value })), - }; - } - if (command.key === "fast") { - const choices = ["on", "off", "auto (30 sec)", "default", "status"]; - return { - arg, - choices: choices.map((value) => ({ label: value, value })), - }; - } - return null; - }); - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - reasoning: true, - }, - ]); - sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); - sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); - sessionMocks.getSessionEntry.mockImplementation( - ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => - sessionMocks.loadSessionStore(storePath)[sessionKey], - ); - sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => { - const current = sessionMocks.loadSessionStore(params.storePath)[params.sessionKey]; - if (!current) { - return null; - } - const patch = await params.update({ ...current }); - return patch ? { ...current, ...patch } : current; - }); - sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); - sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); - pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); - activePluginRegistry = createEmptyPluginRegistry(); - replyMocks.dispatchReplyWithBufferedBlockDispatcher - .mockClear() - .mockResolvedValue(dispatchReplyResult); - dispatchChannelInboundTurnMock.mockClear(); - sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); - sessionBindingMocks.touch.mockReset(); - deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); -} - -activePluginRegistry = createEmptyPluginRegistry(); -const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); -await import("./bot-native-commands.runtime.js"); -agentRuntimeMocks.resolveDefaultModelForAgent({ cfg: {}, agentId: "main" }); -resetSessionMetaMocks(); -const warmStatusHandler = registerAndResolveStatusHandler({ cfg: {} }); -await warmStatusHandler.handler(createTelegramPrivateCommandContext()); - -describe("registerTelegramNativeCommands — session metadata", () => { - beforeEach(resetSessionMetaMocks); - - it("calls recordSessionMetaFromInbound after a native slash command", async () => { - const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" })); - activePluginRegistry.commands.push({ - pluginId: "shadow-plugin", - source: "test", - command: { - name: "status", - description: "Shadow status", - channels: ["telegram"], - requireAuth: false, - handler: shadowHandler, - }, - }); - const cfg: OpenClawConfig = {}; - const { handler } = registerAndResolveStatusHandler({ cfg }); - await handler(createTelegramPrivateCommandContext()); - - expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); - expect(shadowHandler).not.toHaveBeenCalled(); - const turnPlan = dispatchChannelInboundTurnMock.mock.calls[0]?.[0]; - expect(turnPlan?.replyOptions?.[Symbol.for("openclaw.pluginCommandDispatch") as never]).toEqual( - { kind: "non-plugin" }, - ); - const call = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }] - > - )[0]?.[0]; - expect(call?.ctx?.OriginatingChannel).toBe("telegram"); - expect(call?.ctx?.Provider).toBe("telegram"); - expect(call?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); - expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); - }); - - it("leaves native-command outcomes to the update middleware owner", async () => { - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - const { result } = await runWithTelegramUpdateProcessingFrame(async () => { - await handler(createTelegramPrivateCommandContext()); - }); - - expect(result).toBeUndefined(); - }); - - 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" } }; - const { handler } = registerAndResolveStatusHandler({ cfg: startupCfg, runtimeCfg }); - - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch call", - ); - expect(dispatchCall.cfg).toBe(runtimeCfg); - }); - - it.each([ - { blockStreamingEnabled: false, expectedDisableBlockStreaming: true }, - { blockStreamingEnabled: true, expectedDisableBlockStreaming: false }, - ])( - "uses nested streaming.block.enabled=$blockStreamingEnabled for native command dispatch", - async ({ blockStreamingEnabled, expectedDisableBlockStreaming }) => { - const cfg = { - channels: { - telegram: { - streaming: { block: { enabled: blockStreamingEnabled } }, - }, - }, - } satisfies OpenClawConfig; - const { handler } = registerAndResolveStatusHandler({ cfg }); - - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch call", - ); - expect(dispatchCall.replyOptions).toMatchObject({ - disableBlockStreaming: expectedDisableBlockStreaming, - }); - }, - ); - - it("uses the target session model when building native argument menus", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - thinkingLevel: "high", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "anthropic", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "anthropic", model: "claude-opus-4-7" }, - "thinking menu call", - ); - expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({ - storePath: "/tmp/openclaw-sessions.json", - sessionKey: "agent:main:main", - }); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: high.\nChoose level for /think.", - requireReplyMarkup: true, - label: "thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it.each([ - { sessionRuntime: undefined, expectedRuntime: "codex" }, - { sessionRuntime: "openclaw", expectedRuntime: "openclaw" }, - ])( - "uses the effective $expectedRuntime runtime for native /think menus", - async ({ sessionRuntime, expectedRuntime }) => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "openai", - modelOverride: "gpt-5.6-luna", - modelOverrideSource: "user", - ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}), - updatedAt: 0, - }, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna", - )?.[0]; - expectRecordFields( - menuCall, - { - provider: "openai", - model: "gpt-5.6-luna", - agentRuntime: expectedRuntime, - }, - "runtime-aware thinking menu call", - ); - }, - ); - - it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => { - const cfg = { - agents: { defaults: { models: { "ollama/*": {} } } }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "ollama", - modelOverride: "glm-5.2:cloud", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - const runtimeCatalog = [ - { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, - ]; - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "ollama", - )?.[0]; - const menuRecord = expectRecordFields( - menuCall, - { provider: "ollama", model: "glm-5.2:cloud" }, - "ollama thinking menu call", - ); - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); - expect(menuRecord.catalog).toEqual(runtimeCatalog); - }); - - it("loads the runtime catalog for /think when no session model override is set", async () => { - const cfg = { - agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - const runtimeCatalog = [ - { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, - ]; - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think", - )?.[0]; - const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call"); - expect(menuRecord.provider).toBeUndefined(); - expect(menuRecord.catalog).toEqual(runtimeCatalog); - }); - - it("inherits the parent session model when building DM thread native argument menus", async () => { - const cfg: OpenClawConfig = {}; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext({ threadId: 77 })); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "anthropic", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "anthropic", model: "claude-opus-4-7" }, - "thread thinking menu call", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Choose level for /think.", - requireReplyMarkup: true, - label: "thread thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses the configured default model instead of temporary auto fallback overrides", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - thinkingDefault: "medium", - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "auto", - modelProvider: "anthropic", - model: "claude-opus-4-7", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "openai", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "openai", model: "gpt-5.5" }, - "default model thinking menu call", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: medium.\nChoose level for /think.", - requireReplyMarkup: true, - label: "default model thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses configured model defaults instead of runtime auth metadata for the fast menu", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - models: { - "openai/gpt-5.5": { - params: { fastMode: "auto", fastAutoOnSeconds: 30 }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - modelProvider: "openai-codex", - model: "gpt-5.5", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "fast", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "fast", - )?.[0]; - expectRecordFields(menuCall, { cfg }, "fast menu call"); - expect( - commandAuthMocks.resolveCommandArgMenu.mock.calls.some( - ([params]) => - params.command.key === "fast" && - params.provider === "openai" && - params.model === "gpt-5.5", - ), - ).toBe(true); - const options = expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: - "Current fast mode: auto (30 sec) (default: model).\nOptions: on, off, auto (30 sec), default, status.", - requireReplyMarkup: true, - label: "fast menu", - }); - const replyMarkup = options.reply_markup as - | { inline_keyboard?: Array> } - | undefined; - const labels = (replyMarkup?.inline_keyboard ?? []).flatMap((row) => - row.map((button) => button.text), - ); - expect(labels).toContain("auto (30 sec)"); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses the read-only catalog for Claude CLI thinking menus", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-8" }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - agentRuntimeMocks.loadModelCatalog.mockImplementation(async (params) => { - if (!params?.readOnly) { - throw new Error("native /think must not start full model discovery"); - } - return [ - { - provider: "anthropic", - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - reasoning: true, - }, - ]; - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - agentDir: expect.any(String), - readOnly: true, - }), - ); - expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( - "workspaceDir", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: off.\nChoose level for /think.", - requireReplyMarkup: true, - label: "Claude CLI thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses target model thinking defaults before global thinking defaults", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: xhigh.\nChoose level for /think.", - requireReplyMarkup: true, - label: "target model thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses per-agent thinking defaults before target model and global thinking defaults", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - list: [ - { - id: "alpha", - model: { primary: "anthropic/claude-opus-4-7" }, - thinkingDefault: "minimal", - }, - ], - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: minimal.\nChoose level for /think.", - requireReplyMarkup: true, - label: "agent thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("does not load the session store when a native argument menu is skipped", async () => { - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg: {}, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext({ match: "high" })); - - expect(sessionMocks.loadSessionStore).not.toHaveBeenCalled(); - expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled(); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - }); - - it("awaits routed session metadata persistence before command dispatch", async () => { - const deferred = createDeferred(); - sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise); - - const cfg: OpenClawConfig = {}; - const { handler } = registerAndResolveStatusHandler({ cfg }); - const runPromise = handler(createTelegramPrivateCommandContext()); - - await vi.waitFor(() => { - expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - - deferred.resolve(); - await runPromise; - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - - const dispatcherOptions = requireRecord( - requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch reply params", - ).dispatcherOptions, - "dispatcher options", - ); - expect(dispatcherOptions.beforeDeliver).toBeTypeOf("function"); - }); - - it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver( - { - text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", - }, - { kind: "final" }, - ); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - await handler(createTelegramPrivateCommandContext()); - - const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined; - const deliveredPayload = deliveredCall?.replies?.[0]; - if (!deliveredPayload) { - throw new Error("expected approval reply payload to be delivered"); - } - expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); - expect(deliveredPayload?.["channelData"]).toBeUndefined(); - }); - - it("suppresses local structured exec approval replies for native commands", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver( - { - text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", - channelData: { - execApproval: { - approvalId: "7f423fdc-1111-2222-3333-444444444444", - approvalSlug: "7f423fdc", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }, - { kind: "tool" }, - ); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the empty fallback when reply-payload hooks cancel a native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the empty fallback for a message-tool-only native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - sourceReplyDeliveryMode: "message_tool_only", - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("retains the native fallback when message-tool-only delivery also fails", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - sourceReplyDeliveryMode: "message_tool_only", - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("emits the fallback when a non-final suppression precedes a final failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled tool reply" }, - { kind: "tool" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("emits the fallback when a suppressed block reply precedes a final failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled block reply" }, - { kind: "block" }, - { - visibleReplySent: false, - suppression: { reason: "empty_after_reply_payload_sending_hook" }, - }, - ); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - }); - - it("emits the fallback when a final failure precedes a later suppressed final", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - await plan.delivery.onDelivered?.( - { text: "cancelled final reply" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - }); - - it("preserves a suppressed final after a non-final delivery failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.(new Error("Telegram tool delivery failed"), { - kind: "tool", - }); - await plan.delivery.onDelivered?.( - { text: "cancelled final reply" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the fallback after a partially delivered final", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.( - createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), { - visibleReplySent: true, - }), - { kind: "final" }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("retains the empty fallback for a true non-silent metadata-only native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("sends native command error replies silently when silentErrorReplies is enabled", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - silentErrorReplies: true, - }, - }, - }, - telegramCfg: { silentErrorReplies: true }, - }); - await handler(createTelegramPrivateCommandContext()); - - const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined; - const deliveryParams = requireValue(deliveredCall, "silent error delivery params"); - expect(deliveryParams.silent).toBe(true); - expect(deliveryParams.replies).toHaveLength(1); - expect(deliveryParams.replies[0]?.isError).toBe(true); - }); - - it("routes Telegram native commands through configured ACP topic bindings", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe(boundSessionKey); - }); - - it("routes Telegram native commands through topic-specific agent sessions", async () => { - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "zu" }, - }), - }); - await handler(createTelegramTopicCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe( - "agent:zu:telegram:group:-1001234567890:topic:42", - ); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string; ctx?: { From?: string; ChatType?: string } }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:zu:telegram:group:-1001234567890:topic:42"); - expect(sessionMetaCall?.ctx?.From).toBe("telegram:group:-1001234567890:topic:42"); - expect(sessionMetaCall?.ctx?.ChatType).toBe("group"); - }); - - it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => { - const { handler, sendMessage } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("authorizes paired Telegram DMs without marking them as owners", async () => { - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: ["200"], - }); - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [ - { - ctx?: { - CommandAuthorized?: boolean; - }; - }, - ] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true); - expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom"); - }); - - it("routes Telegram native commands through bound topic sessions", async () => { - sessionBindingMocks.resolveByConversation.mockReturnValue({ - bindingId: "default:-1001234567890:topic:42", - targetSessionKey: "agent:codex-acp:session-1", - }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - }); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1"); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-1"); - expect(sessionBindingMocks.touch).toHaveBeenCalledWith( - "default:-1001234567890:topic:42", - undefined, - ); - }); - - it("routes Telegram native commands through bound top-level group sessions", async () => { - sessionBindingMocks.resolveByConversation.mockReturnValue({ - bindingId: "default:-1001234567890", - targetSessionKey: "agent:codex-acp:session-group", - }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramGroupCommandContext()); - - expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890", - }); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string; OriginatingTo?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-group"); - expect(dispatchCall?.ctx?.OriginatingTo).toBe("telegram:-1001234567890"); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-group"); - expect(sessionBindingMocks.touch).toHaveBeenCalledWith("default:-1001234567890", undefined); - }); - - it.each(["new", "reset"] as const)( - "preserves the topic-qualified origin target for native /%s in forum topics", - async (commandName) => { - const { handler } = registerAndResolveCommandHandler({ - commandName, - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [ - { - ctx?: { - CommandTargetSessionKey?: string; - MessageThreadId?: number; - OriginatingTo?: string; - }; - }, - ] - > - )[0]?.[0]; - expectRecordFields( - dispatchCall?.ctx, - { - CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42", - MessageThreadId: 42, - OriginatingTo: "telegram:-1001234567890:topic:42", - }, - "topic dispatch context", - ); - }, - ); - - it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ - ok: false, - error: "gateway unavailable", - }); - - const { handler, sendMessage } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expectSendMessageCall({ - sendMessage, - chatId: -1001234567890, - text: "Configured ACP binding is unavailable right now. Please try again.", - optionFields: { message_thread_id: 42 }, - label: "unavailable ACP binding", - }); - }); - - it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "new", - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute(route, null), - ); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "new", - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("passes persisted topic session identity to plugin commands", async () => { - sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); - sessionMocks.getSessionEntry.mockReturnValue({ - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-topic", - updatedAt: 1, - }); - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:telegram:group:-1001234567890:topic:42": { - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-topic", - updatedAt: 1, - }, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - groupAllowFrom: ["-1001234567890"], - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler( - createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }), - ); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", - sessionId: "sess-topic", - messageThreadId: 42, - }, - "plugin command params", - ); - }); - - it("moves the target session to the profile returned by Telegram /login codex", async () => { - const finishLogin = createDeferred(); - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }, - }); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "ABCD-EFGH", - expiresInMinutes: 15, - message: "URL: https://auth.openai.com/codex/device", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [ - { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, - ], - }; - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - expect(sessionMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); - finishLogin.resolve(); - - expect(runModelsAuthLoginFlow).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - method: "device-code", - agent: "main", - }), - ); - expect( - (runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId, - ).toBeUndefined(); - await vi.waitFor(() => - expect(sessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({ - sessionKey: "agent:main:main", - storePath: "/tmp/openclaw-sessions.json", - requireWriteSuccess: true, - skipMaintenance: true, - update: expect.any(Function), - }), - ); - const patchUpdate = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: Record) => Record; - } - )?.update?.({ - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }); - expect(patchUpdate).toEqual({ - authProfileOverride: "openai:new-owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - {}, - ), - ); - }); - - it("moves a session created while Telegram login is pending to the returned profile", async () => { - const finishLogin = createDeferred(); - let sessionStore: Record = {}; - sessionMocks.loadSessionStore.mockImplementation(() => sessionStore); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "NEW-SESSION", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [ - { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, - ], - }; - }); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - sessionStore = { - "agent:main:main": { - sessionId: "sess-created-during-login", - updatedAt: 2, - }, - }; - finishLogin.resolve(); - - await vi.waitFor(() => expect(sessionMocks.updateSessionStoreEntry).toHaveBeenCalledTimes(1)); - const update = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: SessionEntry) => Partial | null; - } - )?.update; - expect( - update?.({ - sessionId: "sess-created-during-login", - updatedAt: 2, - }), - ).toEqual({ - authProfileOverride: "openai:new-owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - {}, - ), - ); - }); - - it("preserves a later user-selected profile on a session created during Telegram login", async () => { - const finishLogin = createDeferred(); - let sessionStore: Record = {}; - sessionMocks.loadSessionStore.mockImplementation(() => sessionStore); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "LATER-USER-SELECTION", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:login-profile", provider: "openai", mode: "oauth" }], - }; - }); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - sessionStore = { - "agent:main:main": { - authProfileOverride: "openai:later-user-profile", - authProfileOverrideSource: "user", - sessionId: "sess-created-during-login", - updatedAt: 2, - }, - }; - finishLogin.resolve(); - - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ), - ); - expect(sessionStore["agent:main:main"]?.authProfileOverride).toBe("openai:later-user-profile"); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("marks a same-profile Telegram login as user-selected", async () => { - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 2, - sessionId: "sess-main", - updatedAt: 1, - }, - }); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - const update = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: Record) => Record; - } - )?.update; - expect(update).toBeTypeOf("function"); - expect( - update?.({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 2, - sessionId: "sess-main", - updatedAt: 1, - }), - ).toEqual({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - expect( - update?.({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - sessionId: "sess-main", - updatedAt: 2, - }), - ).toBeNull(); - }); - - it("reports partial success when Telegram cannot persist the returned profile", async () => { - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:old-owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }, - }); - sessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed")); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("reports partial success when Telegram login returns no OpenAI profile", async () => { - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("revalidates an unchanged Telegram profile after device login", async () => { - const previousEntry = { - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - sessionId: "sess-main", - updatedAt: 1, - }; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": previousEntry, - }); - sessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => { - const concurrentEntry = { - ...previousEntry, - authProfileOverride: "openai:concurrent-owner@example.com", - updatedAt: 2, - }; - const patch = await params.update({ ...concurrentEntry }); - return patch ? { ...concurrentEntry, ...patch } : concurrentEntry; - }); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("passes session identity to plugin commands when the entry has no file", async () => { - sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions/sessions.json", - }, - "plugin command params", - ); - }); - - it("passes SQLite transcript markers to plugin commands without path resolution", async () => { - const storePath = "/tmp/openclaw-sessions/sessions.json"; - const marker = `sqlite:main:sess-main:${storePath}`; - sessionMocks.resolveStorePath.mockReturnValue(storePath); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - sessionFile: marker, - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: marker, - }, - "plugin command params", - ); - }); - - it("replaces stale legacy transcript paths for plugin commands", async () => { - const storePath = "/tmp/openclaw-sessions/sessions.json"; - const marker = `sqlite:main:sess-main:${storePath}`; - sessionMocks.resolveStorePath.mockReturnValue(storePath); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - sessionFile: "sess-main.jsonl", - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: marker, - }, - "plugin command params", - ); - }); - - it("sends an empty-response fallback when a plugin command returns undefined", async () => { - pluginRuntimeMocks.executePluginCommand.mockResolvedValue(undefined as never); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - const deliveryCall = requireValue( - firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined, - "empty response delivery params", - ); - expect(deliveryCall.replies).toEqual([{ text: "No response generated. Please try again." }]); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 89bd3d06ce28..09e997f3331e 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -13,9 +13,6 @@ import { createCommandBot, createNativeCommandTestParams, createPrivateCommandContext, - deliverReplies, - editMessageTelegram, - emitTelegramMessageSentHooks, listSkillCommandsForAgents, resetNativeCommandMenuMocks, waitForRegisteredCommands, @@ -23,19 +20,9 @@ import { import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; -type CommandBotHarness = ReturnType; type TelegramInlineKeyboardReplyMarkup = { inline_keyboard?: Array>; }; -type PlugCommandHarnessParams = { - botHarness?: CommandBotHarness; - cfg?: OpenClawConfig; - command?: Record; - acceptsArgs?: boolean; - args?: string; - result?: Record; - registerOverrides?: Partial[0]>; -}; const pluginCommandHandler = vi.fn(async (_ctx: Record) => ({ text: "ok" })); @@ -62,35 +49,6 @@ function registerTestPluginCommand(params: { ).toEqual({ ok: true }); } -function primePlugCommand(params: PlugCommandHarnessParams = {}) { - registerTestPluginCommand({ - name: "plug", - description: "Plugin command", - acceptsArgs: params.acceptsArgs ?? true, - command: params.command, - result: params.result, - }); -} - -function registerPlugCommand(params: PlugCommandHarnessParams = {}) { - const botHarness = params.botHarness ?? createCommandBot(); - primePlugCommand(params); - registerTelegramNativeCommands({ - ...createNativeCommandTestParams(params.cfg ?? {}, { - bot: botHarness.bot, - }), - ...params.registerOverrides, - }); - const handler = botHarness.commandHandlers.get("plug"); - if (!handler) { - throw new Error("expected plug command handler to be registered"); - } - return { - ...botHarness, - handler, - }; -} - function collectCallbackData(replyMarkup: TelegramInlineKeyboardReplyMarkup | undefined): string[] { const callbackData: string[] = []; for (const row of replyMarkup?.inline_keyboard ?? []) { @@ -111,39 +69,9 @@ function firstCall(mock: { mock: { calls: Array> } }) { return call; } -function firstCallArg(mock: { mock: { calls: Array> } }, argIndex = 0) { - const arg = firstCall(mock)[argIndex]; - if (!arg || typeof arg !== "object") { - throw new Error(`expected first mock call arg ${argIndex}`); - } - return arg as Record; -} - -function firstDeliverRepliesParams() { - return firstCallArg(deliverReplies as unknown as { mock: { calls: Array> } }); -} - -function firstExecutePluginCommandParams() { - return firstCallArg( - pluginCommandHandler as unknown as { - mock: { calls: Array> }; - }, - ); -} - -function replyAt(params: Record, index = 0) { - const replies = params.replies as Array> | undefined; - const reply = replies?.[index]; - if (!reply) { - throw new Error(`expected reply ${index}`); - } - return reply; -} - resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); -const { registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } = - await import("./bot-native-commands.js"); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); registerTelegramNativeCommands(createNativeCommandTestParams({})); describe("registerTelegramNativeCommands", () => { @@ -439,476 +367,5 @@ describe("registerTelegramNativeCommands", () => { "tgcmd:/fast status", ]); expect(labels).toEqual(["on", "off", "auto (30 sec)", "default", "status"]); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); - }); - - it("passes agent-scoped media roots for plugin command replies with media", async () => { - const mediaMaxBytes = 50 * 1024 * 1024; - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "main", default: true }, { id: "work" }], - }, - bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }], - }; - - const { handler, sendMessage } = registerPlugCommand({ - cfg, - result: { - text: "with media", - mediaUrl: "/tmp/workspace-work/render.png", - }, - registerOverrides: { - mediaMaxBytes, - } as Partial[0]>, - }); - - await handler(createPrivateCommandContext()); - - const deliverParams = firstDeliverRepliesParams(); - expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes); - const mediaLocalRoots = deliverParams.mediaLocalRoots as Array | undefined; - expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe( - true, - ); - expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); - }); - - it("delivers presentation-only tables returned by plugin commands", async () => { - const presentation = { - title: "FY25 outlook", - blocks: [ - { - type: "table", - caption: "Pipeline", - headers: ["Account", "Stage"], - rows: [["Acme", "Won"]], - }, - ], - }; - const { handler } = registerPlugCommand({ result: { presentation } }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation }); - expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined(); - }); - - it("delivers Telegram button-only plugin command replies", async () => { - const buttons = [[{ text: "Retry", callback_data: "retry" }]]; - const { handler } = registerPlugCommand({ - result: { channelData: { telegram: { buttons } } }, - }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toEqual({ - channelData: { telegram: { buttons } }, - }); - }); - - it("targets reaction-only plugin replies at the invoking command message", async () => { - const { handler } = registerPlugCommand({ - result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } }, - }); - - await handler(createPrivateCommandContext({ messageId: 321 })); - - const deliveryParams = firstDeliverRepliesParams(); - expect(replyAt(deliveryParams)).toEqual({ - replyToId: "321", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }); - expect(deliveryParams.replyToMode).toBe("all"); - }); - - it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => { - const { handler } = registerPlugCommand({ - result: { channelData: { plugin: { traceId: "trace-1" } } }, - }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toEqual({ - text: "No response generated. Please try again.", - }); - }); - - it("replies to unmatched plugin commands in the originating forum topic", async () => { - const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false }); - - await handler({ - match: "unexpected", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - message_thread_id: 77, - from: { id: 200, username: "bob" }, - }, - }); - - const sendMessageCall = firstCall(sendMessage); - expect(sendMessageCall[0]).toBe(-1001234567890); - expect(sendMessageCall[1]).toBe("Command not found."); - expect( - (sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id, - ).toBe(77); - }); - - it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { - telegram: - "Running this command now...\n\nI'll edit this message with the final result when it's ready.", - }, - }, - result: { - text: "Command completed successfully", - }, - }); - - await handler( - createPrivateCommandContext({ - match: "now", - }), - ); - - const sendMessageCall = firstCall(sendMessage); - expect(sendMessageCall[0]).toBe(100); - expect(String(sendMessageCall[1])).toContain("Running this command now"); - expect(sendMessageCall[2]).toBeUndefined(); - const editCall = firstCall( - editMessageTelegram as unknown as { mock: { calls: Array> } }, - ); - expect(editCall[0]).toBe(100); - expect(editCall[1]).toBe(999); - expect(String(editCall[2])).toContain("Command completed successfully"); - expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default"); - expect(deleteMessage).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - const hookParams = firstCallArg( - emitTelegramMessageSentHooks as unknown as { mock: { calls: Array> } }, - ); - expect(hookParams.chatId).toBe("100"); - expect(hookParams.content).toBe("Command completed successfully"); - expect(hookParams.messageId).toBe(999); - expect(hookParams.success).toBe(true); - }); - - it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Choose an option", - channelData: { - telegram: { - buttons: [[{ text: "Approve", callback_data: "approve" }]], - }, - }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - const editCall = firstCall( - editMessageTelegram as unknown as { mock: { calls: Array> } }, - ); - expect(editCall[0]).toBe(100); - expect(editCall[1]).toBe(999); - expect(editCall[2]).toBe("Choose an option"); - expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([ - [{ text: "Approve", callback_data: "approve" }], - ]); - expect(deleteMessage).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Command completed successfully", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now", messageId: 321 })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - const deliveryParams = firstDeliverRepliesParams(); - expect(deliveryParams.replyToMode).toBe("all"); - expect(replyAt(deliveryParams)).toEqual({ - text: "Command completed successfully", - replyToId: "321", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }); - }); - - it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "rich output", - mediaUrl: "/tmp/render.png", - }, - }); - - await handler( - createPrivateCommandContext({ - match: "now", - }), - ); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png"); - }); - - it("falls back to a normal reply when a progress result has presentation controls", async () => { - const presentation = { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }], - }, - ], - }; - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Approval required", - presentation, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ - text: "Approval required", - presentation, - }); - }); - - it("cleans up the progress placeholder before falling back after an edit failure", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Command completed successfully", - }, - }); - editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found")); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams()).text).toBe("Command completed successfully"); - }); - - it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", - channelData: { - execApproval: { - approvalId: "7f423fdc-1111-2222-3333-444444444444", - approvalSlug: "7f423fdc", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }, - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { - const { handler } = registerPlugCommand({ - cfg: { - channels: { - telegram: { - silentErrorReplies: true, - }, - }, - }, - result: { - text: "plugin failed", - isError: true, - }, - registerOverrides: { - telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, - }, - }); - - await handler(createPrivateCommandContext()); - - const deliverParams = firstDeliverRepliesParams(); - expect(deliverParams.silent).toBe(true); - expect(replyAt(deliverParams).isError).toBe(true); - }); - - it("uses rich messages for plugin command replies when enabled", async () => { - const { handler } = registerPlugCommand({ - cfg: { - channels: { - telegram: { - richMessages: true, - }, - }, - }, - registerOverrides: { - telegramCfg: { richMessages: true } as TelegramAccountConfig, - }, - }); - - await handler(createPrivateCommandContext()); - - expect(firstDeliverRepliesParams().richMessages).toBe(true); - }); - - it("forwards topic-scoped binding context to Telegram plugin commands", async () => { - const { handler } = registerPlugCommand(); - - await handler({ - match: "", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - message_thread_id: 77, - from: { id: 200, username: "bob" }, - }, - }); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.channel).toBe("telegram"); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77"); - expect(commandParams.to).toBe("telegram:-1001234567890"); - expect(commandParams.messageThreadId).toBe(77); - }); - - it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => { - const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true })); - const { handler } = registerPlugCommand({ - botHarness: createCommandBot({ api: { getChat } }), - }); - - await handler({ - match: "", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - }, - from: { id: 200, username: "bob" }, - }, - }); - - expect(getChat).toHaveBeenCalledWith(-1001234567890); - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1"); - expect(commandParams.to).toBe("telegram:-1001234567890"); - expect(commandParams.messageThreadId).toBe(1); - }); - - it("forwards direct-message binding context to Telegram plugin commands", async () => { - const { handler } = registerPlugCommand(); - - await handler(createPrivateCommandContext({ chatId: 100, userId: 200 })); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.channel).toBe("telegram"); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:100"); - expect(commandParams.to).toBe("telegram:100"); - expect(commandParams.messageThreadId).toBeUndefined(); - }); - - it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => { - const { handler } = registerPlugCommand({ - result: { suppressReply: true }, - }); - - await handler(createPrivateCommandContext()); - - expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).not.toHaveBeenCalled(); - }); - - it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => { - const { handler } = registerPlugCommand(); - - await handler({ - ...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }), - me: { has_topics_enabled: true }, - }); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77"); - const deliveryParams = firstDeliverRepliesParams(); - expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77"); }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 3f5dbfd7b861..8fbf7df8a7e3 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,74 +1,23 @@ -// Telegram plugin module implements bot native commands behavior. -import { randomUUID } from "node:crypto"; +// Telegram plugin module implements native command registration behavior. import type { Bot, Context } from "grammy"; import { - loadPreparedModelCatalog, - resolveAgentConfig, - resolveAgentDir, - resolveDefaultModelForAgent, - resolveThinkingDefaultWithRuntimeCatalog, -} from "openclaw/plugin-sdk/agent-runtime"; -import { - isChannelPartialDeliveryError, - type ChannelInboundTurnPlan, -} from "openclaw/plugin-sdk/channel-inbound"; -import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; -import { - buildCommandTextFromArgs, findCommandByNativeName, - formatFastModeCurrentStatus, - formatCommandArgMenuTitle, listNativeCommandSpecs, listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveEffectiveAgentRuntime, - resolveCommandArgMenu, - resolveFastModeState, - resolveStoredModelOverride, - type CommandArgs, } from "openclaw/plugin-sdk/command-auth-native"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-contracts"; import type { - ReplyToMode, + ChannelGroupPolicy, + OpenClawConfig, TelegramAccountConfig, - TelegramDirectConfig, - TelegramGroupConfig, - TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -import { - createPluginCommandRuntime, - PLUGIN_COMMAND_DISPATCH, - type PluginCommandCatalogDecision, -} from "openclaw/plugin-sdk/plugin-command-runtime"; -import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; -import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { createPluginCommandRuntime } from "openclaw/plugin-sdk/plugin-command-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { - formatSqliteSessionFileMarker, - getSessionEntry, - resolveStorePath, - type SessionEntry, - updateSessionStoreEntry, -} from "openclaw/plugin-sdk/session-store-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; -import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; -import { resolveTelegramAccount } from "./accounts.js"; -import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; -import type { TelegramBotDeps } from "./bot-deps.js"; +import { danger, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramNativeCommandCallbackDispatcher, TelegramResolvedGroupConfig, } from "./bot-handlers.types.js"; -import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; import { defaultTelegramNativeCommandDeps, type TelegramNativeCommandDeps, @@ -81,536 +30,21 @@ import { } from "./bot-native-command-menu.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; import type { TelegramBotOptions } from "./bot.types.js"; -import { - buildTelegramRoutingTarget, - buildTelegramThreadParams, - buildSenderName, - buildTelegramGroupFrom, - extractTelegramForumFlag, - isTelegramCommandsAllowFromConfigured, - resolveTelegramCommandAuthorization, - resolveTelegramForumFlag, - resolveTelegramGroupAllowFromContext, - resolveTelegramBotHasTopicsEnabled, - resolveTelegramMessageThreadSpec, - resolveTelegramThreadSpec, - shouldUseTelegramDmThreadSession, -} from "./bot/helpers.js"; -import type { TelegramGetChat } from "./bot/types.js"; -import type { TelegramInlineButtons } from "./button-types.js"; import { normalizeTelegramCommandName, resolveTelegramCustomCommands, TELEGRAM_COMMAND_NAME_PATTERN, } from "./command-config.js"; -import { - resolveTelegramConversationBaseSessionKey, - resolveTelegramConversationRoute, -} from "./conversation-route.js"; -import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; -import { - evaluateTelegramGroupBaseAccess, - evaluateTelegramGroupPolicyAccess, -} from "./group-access.js"; -import { - resolveTelegramDirectToolPolicy, - resolveTelegramGroupPromptSettings, -} from "./group-config-helpers.js"; -import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; -import { buildInlineKeyboard } from "./inline-keyboard.js"; -import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; -import { recordSentMessage } from "./sent-message-cache.js"; -import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; -export { parseTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; - -const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; -const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({ - kind: "non-plugin", -}) satisfies PluginCommandCatalogDecision; -const activeTelegramCodexLoginFlows = codexChannelLoginRuntime.createFlowRegistry(); +const loadTelegramBuiltinCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-builtins.js"), +); +const loadTelegramPluginCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-plugins.js"), +); type TelegramNativeCommandContext = Context & { match?: string }; -type TelegramChunkMode = ReturnType< - typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode ->; -type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult; -type TelegramNativeReplyChannelData = { - buttons?: TelegramInlineButtons; - pin?: boolean; - reaction?: { - emoji?: unknown; - }; -}; -type FastModeState = ReturnType; - -type TelegramCommandAuthResult = { - chatId: number; - isGroup: boolean; - isForum: boolean; - resolvedThreadId?: number; - senderId: string; - senderUsername: string; - groupConfig?: TelegramGroupConfig | TelegramDirectConfig; - topicConfig?: TelegramTopicConfig; - commandAuthorized: boolean; - senderIsOwner: boolean; -}; - -type TelegramNativeCommandThreadContext = { - chatId: number; - isGroup: boolean; - isForum: boolean; - threadSpec: ReturnType; - threadParams: ReturnType; -}; - -type TelegramLoginDeviceCode = { - title: string; - code: string; - expiresInMinutes?: number; - message?: string; -}; - -// Telegram's inline-code entity provides the tap-to-copy affordance needed for -// short-lived device codes; plain text and literal backticks do not. -function formatTelegramLoginDeviceCode(params: TelegramLoginDeviceCode): string { - return [ - `${escapeHtml(params.title)}`, - "", - ...(params.message ? [escapeHtml(params.message)] : []), - `Code: ${escapeHtml(params.code)}`, - ...(params.expiresInMinutes - ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] - : []), - ].join("\n"); -} - -function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string { - const providerValue = commandArgs?.values?.provider; - return typeof providerValue === "string" && providerValue.trim() - ? providerValue - : (commandArgs?.raw ?? "codex"); -} - -function buildTelegramCodexLoginFlowKey(params: { - accountId: string; - chatId: number; - threadSpec: ReturnType; - agentId: string; - provider: string; -}): string { - const threadKey = - params.threadSpec.id == null - ? params.threadSpec.scope - : `${params.threadSpec.scope}:${params.threadSpec.id}`; - return [ - "telegram", - params.accountId, - String(params.chatId), - threadKey, - params.agentId, - params.provider, - ].join(":"); -} - -type TelegramCommandMenuModelContext = { - provider?: string; - model?: string; - agentRuntime?: string; - thinkingLevel?: string; - fastMode?: SessionEntry["fastMode"]; -}; - -function buildTelegramCommandMenuModelContext(params: { - provider: string; - model: string; - thinkingLevel?: string; - fastMode?: SessionEntry["fastMode"]; -}): TelegramCommandMenuModelContext { - return { - provider: params.provider, - model: params.model, - ...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}), - ...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}), - }; -} - -const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule( - () => import("./bot-native-commands.delivery.runtime.js"), -); - -const loadTelegramNativeCommandRuntime = createLazyRuntimeModule( - () => import("./bot-native-commands.runtime.js"), -); - -type TelegramNativeCommandRuntime = Awaited>; - -function resolveTelegramCommandSessionFile(params: { - agentId: string; - sessionId: string; - storePath: string; -}): string { - return formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.sessionId, - storePath: params.storePath, - }); -} - -async function resolveTelegramCommandTranscriptContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - threadId?: string | number; -}): Promise<{ sessionId?: string; sessionFile?: string; authProfileId?: string }> { - const sessionKey = params.sessionKey.trim(); - if (!sessionKey) { - return {}; - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ - agentId: params.agentId, - sessionKey, - storePath, - }); - const sessionId = entry?.sessionId?.trim() || randomUUID(); - const sessionFile = resolveTelegramCommandSessionFile({ - agentId: params.agentId, - sessionId, - storePath, - }); - const authProfileId = normalizeOptionalString(entry?.authProfileOverride); - return { - sessionId, - sessionFile, - ...(authProfileId ? { authProfileId } : {}), - }; - } catch { - return {}; - } -} - -function resolveTelegramCommandMenuModelContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): TelegramCommandMenuModelContext { - if (!params.sessionKey.trim()) { - return {}; - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel); - const fastMode = entry?.fastMode; - let context: TelegramCommandMenuModelContext; - if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { - context = buildTelegramCommandMenuModelContext({ - provider: defaultModel.provider, - model: defaultModel.model, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }); - } else { - const override = resolveStoredModelOverride({ - sessionEntry: entry, - loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), - sessionKey: params.sessionKey, - defaultProvider: defaultModel.provider, - }); - if (override?.model) { - context = buildTelegramCommandMenuModelContext({ - provider: override.provider || defaultModel.provider, - model: override.model, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }); - } else { - const provider = - normalizeOptionalString(entry?.providerOverride) ?? - normalizeOptionalString(entry?.modelProvider); - const model = - normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); - context = { - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }; - } - } - return { - ...context, - agentRuntime: resolveEffectiveAgentRuntime({ - cfg: params.cfg, - provider: context.provider ?? defaultModel.provider, - modelId: context.model ?? defaultModel.model, - agentId: params.agentId, - sessionKey: params.sessionKey, - sessionEntry: entry, - }), - }; - } catch { - return {}; - } -} - -function resolveTelegramFastCommandModelContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): { - provider?: string; - model?: string; -} { - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const fallback = () => ({ - provider: defaultModel.provider, - model: defaultModel.model, - }); - if (!params.sessionKey.trim()) { - return fallback(); - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { - return fallback(); - } - const override = resolveStoredModelOverride({ - sessionEntry: entry, - loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), - sessionKey: params.sessionKey, - defaultProvider: defaultModel.provider, - }); - return { - provider: override?.provider ?? defaultModel.provider, - model: override?.model ?? defaultModel.model, - }; - } catch { - return fallback(); - } -} - -function resolveTelegramFastCommandState(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): FastModeState { - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const fallback = () => - resolveFastModeState({ - cfg: params.cfg, - provider: defaultModel.provider, - model: defaultModel.model, - agentId: params.agentId, - }); - if (!params.sessionKey.trim()) { - return fallback(); - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - const modelContext = resolveTelegramFastCommandModelContext(params); - return resolveFastModeState({ - cfg: params.cfg, - provider: modelContext.provider ?? defaultModel.provider, - model: modelContext.model ?? defaultModel.model, - agentId: params.agentId, - sessionEntry: - entry?.fastMode !== undefined - ? { - fastMode: entry.fastMode, - } - : undefined, - }); - } catch { - return fallback(); - } -} - -async function resolveTelegramThinkMenuCurrentLevel(params: { - cfg: OpenClawConfig; - agentId: string; - provider?: string; - model?: string; - agentRuntime?: string; - thinkingLevel?: string; - catalog: Awaited>; -}): Promise { - const explicit = normalizeOptionalString(params.thinkingLevel); - if (explicit) { - return explicit; - } - const agentThinkingDefault = normalizeOptionalString( - resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault, - ); - if (agentThinkingDefault) { - return agentThinkingDefault; - } - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - return await resolveThinkingDefaultWithRuntimeCatalog({ - cfg: params.cfg, - provider: params.provider ?? defaultModel.provider, - model: params.model ?? defaultModel.model, - agentRuntime: params.agentRuntime, - loadRuntimeCatalog: async () => params.catalog, - }); -} - -function formatTelegramCommandArgMenuTitle(params: { - command: NonNullable>; - menu: NonNullable>; - currentThinkingLevel?: string; - currentFastModeStatus?: string; -}): string { - const title = formatCommandArgMenuTitle({ command: params.command, menu: params.menu }); - if (params.command.key === "think" && params.currentThinkingLevel) { - return `Current thinking level: ${params.currentThinkingLevel}.\n${title}`; - } - if (params.command.key === "fast" && params.currentFastModeStatus) { - const options = params.menu.choices - .map((choice) => choice.label.trim()) - .filter(Boolean) - .join(", "); - return options - ? `${params.currentFastModeStatus}\nOptions: ${options}.` - : params.currentFastModeStatus; - } - return title; -} - -function resolveTelegramFastMenuCurrentStatus(params: { state: FastModeState }): string { - return formatFastModeCurrentStatus({ - mode: params.state.mode, - source: params.state.source, - fastAutoOnSeconds: params.state.fastAutoOnSeconds, - }); -} - -function resolveTelegramNativeReplyChannelData( - result: TelegramNativeReplyPayload, -): TelegramNativeReplyChannelData | undefined { - return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined; -} - -function normalizeTelegramNativeReplyPayload( - result: TelegramNativeReplyPayload | null | undefined, -): TelegramNativeReplyPayload { - return result && typeof result === "object" ? result : {}; -} - -function isSuppressedTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - return result.suppressReply === true; -} - -function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean { - const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji; - return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0; -} - -function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - const { channelData: _channelData, ...portableContent } = result; - if (hasOutboundReplyContent(portableContent, { trimText: true })) { - return true; - } - const telegramData = resolveTelegramNativeReplyChannelData(result); - return Boolean( - buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result), - ); -} - -function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { - const telegramData = resolveTelegramNativeReplyChannelData(result); - return Boolean( - typeof result.text === "string" && - result.text.trim() && - !result.mediaUrl && - (!result.mediaUrls || result.mediaUrls.length === 0) && - !result.presentation && - !result.interactive && - !result.btw && - !hasTelegramNativeReplyReaction(result) && - telegramData?.pin !== true, - ); -} - -async function cleanupTelegramProgressPlaceholder(params: { - bot: Bot; - chatId: number; - progressMessageId?: number; - runtime: RuntimeEnv; -}): Promise { - const progressMessageId = params.progressMessageId; - if (progressMessageId == null) { - return; - } - try { - await withTelegramApiErrorLogging({ - operation: "deleteMessage", - runtime: params.runtime, - fn: () => params.bot.api.deleteMessage(params.chatId, progressMessageId), - }); - } catch { - // Best-effort cleanup before fallback or suppression exits. - } -} - -async function resolveTelegramNativeCommandThreadContext(params: { - msg: NonNullable; - bot: Bot; -}): Promise { - const { msg, bot } = params; - const chatId = msg.chat.id; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const getChat = - typeof bot.api.getChat === "function" - ? (bot.api.getChat.bind(bot.api) as TelegramGetChat) - : undefined; - const isForum = - msg.chat.is_direct_messages === true - ? false - : await resolveTelegramForumFlag({ - chatId, - chatType: msg.chat.type, - isGroup, - isForum: extractTelegramForumFlag(msg.chat), - isTopicMessage: msg.is_topic_message, - getChat, - }); - const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); - return { - chatId, - isGroup, - isForum, - threadSpec, - threadParams: buildTelegramThreadParams(threadSpec), - }; -} - -function resolveTelegramNativeCommandDisableBlockStreaming( - telegramCfg: TelegramAccountConfig, -): boolean | undefined { - const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg); - return typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined; -} - type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: OpenClawConfig; @@ -634,229 +68,6 @@ type RegisterTelegramNativeCommandsParams = { >; }; -async function resolveTelegramCommandAuth(params: { - msg: NonNullable; - bot: Bot; - cfg: OpenClawConfig; - accountId: string; - telegramCfg: TelegramAccountConfig; - readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; - allowFrom?: Array; - groupAllowFrom?: Array; - resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; - resolveTelegramGroupConfig: ( - chatId: string | number, - messageThreadId: number | undefined, - cfg: OpenClawConfig, - ) => TelegramResolvedGroupConfig; - requireAuth: boolean; -}): Promise { - const { - msg, - bot, - cfg, - accountId, - telegramCfg, - readChannelAllowFromStore, - allowFrom, - groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth, - } = params; - const { chatId, isGroup, isForum, threadSpec, threadParams } = - await resolveTelegramNativeCommandThreadContext({ msg, bot }); - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - // Best-effort pre-context check: if commands.allowFrom already authorizes the - // sender at chat level, skip the pairing-store read so a transient store I/O - // failure cannot block a command this sender is explicitly allowed to run. - // resolvedThreadId is not known yet; the post-context check below is still - // the authoritative decision for topic-scoped command auth. - const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg); - const preContextCommandsAllowFromAccess = commandsAllowFromConfigured - ? resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - senderId, - senderUsername, - }) - : null; - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ - cfg, - chatId, - accountId, - dmPolicy: telegramCfg.dmPolicy, - allowFrom, - senderId, - isGroup, - threadSpec, - groupAllowFrom, - skipPairingStoreRead: Boolean(preContextCommandsAllowFromAccess?.isAuthorizedSender), - readChannelAllowFromStore, - resolveTelegramGroupConfig, - }); - const { - resolvedThreadId, - dmThreadId, - storeAllowFrom, - groupConfig, - topicConfig, - groupAllowOverride, - effectiveGroupAllow, - hasGroupAllowOverride, - } = groupAllowContext; - const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ - isGroup, - groupConfig, - dmPolicy: telegramCfg.dmPolicy, - }); - const requireTopic = - !isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined; - if (!isGroup && requireTopic === true && dmThreadId == null) { - logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); - return null; - } - const dmAllowFrom = groupAllowOverride ?? allowFrom; - const commandsAllowFromAccess = commandsAllowFromConfigured - ? resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - senderId, - senderUsername, - }) - : null; - const ownerAccess = resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - senderId, - senderUsername, - }); - - const sendAuthMessage = async (text: string) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}), - }); - return null; - }; - const rejectNotAuthorized = async () => { - return await sendAuthMessage("You are not authorized to use this command."); - }; - - const baseAccess = evaluateTelegramGroupBaseAccess({ - isGroup, - groupConfig, - topicConfig, - hasGroupAllowOverride, - effectiveGroupAllow, - senderId, - senderUsername, - enforceAllowOverride: requireAuth, - requireSenderForAllowOverride: true, - }); - if (!baseAccess.allowed) { - if (baseAccess.reason === "group-disabled") { - logVerbose(`Blocked telegram command in group ${chatId} (group disabled)`); - return null; - } - if (baseAccess.reason === "topic-disabled") { - logVerbose( - `Blocked telegram command in topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, - ); - return null; - } - return await rejectNotAuthorized(); - } - - const policyAccess = evaluateTelegramGroupPolicyAccess({ - isGroup, - chatId, - cfg, - telegramCfg, - topicConfig, - groupConfig, - effectiveGroupAllow, - senderId, - senderUsername, - resolveGroupPolicy, - enforcePolicy: true, - enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, - allowEmptyAllowlistEntries: true, - requireSenderForAllowlistAuthorization: true, - checkChatAllowlist: true, - }); - if (!policyAccess.allowed) { - if (policyAccess.reason === "group-policy-disabled") { - logVerbose("Blocked telegram command (groupPolicy: disabled)"); - return null; - } - if ( - policyAccess.reason === "group-policy-allowlist-no-sender" || - policyAccess.reason === "group-policy-allowlist-unauthorized" - ) { - return await rejectNotAuthorized(); - } - if (policyAccess.reason === "group-chat-not-allowed") { - logVerbose(`Blocked telegram command in group ${chatId} (group not allowed)`); - return null; - } - } - - const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ - cfg, - allowFrom: dmAllowFrom, - accountId, - senderId, - }); - const dmAllow = normalizeDmAllowFromWithStore({ - allowFrom: expandedDmAllowFrom, - storeAllowFrom: isGroup ? [] : storeAllowFrom, - dmPolicy: effectiveDmPolicy, - }); - const commandAuthorized = commandsAllowFromConfigured - ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) - : ( - await resolveTelegramCommandIngressAuthorization({ - accountId, - cfg, - dmPolicy: effectiveDmPolicy, - isGroup, - chatId, - resolvedThreadId, - senderId, - effectiveDmAllow: dmAllow, - effectiveGroupAllow, - ownerAccess, - eventKind: "native-command", - }) - ).authorized; - if (requireAuth && !commandAuthorized) { - return await rejectNotAuthorized(); - } - - return { - chatId, - isGroup, - isForum, - resolvedThreadId, - senderId, - senderUsername, - groupConfig, - topicConfig, - commandAuthorized, - senderIsOwner: ownerAccess.senderIsOwner, - }; -} - export const registerTelegramNativeCommands = ({ bot, cfg, @@ -883,10 +94,7 @@ export const registerTelegramNativeCommands = ({ } const skillCommands = nativeEnabled && nativeSkillsEnabled && boundRoute - ? telegramDeps.listSkillCommandsForAgents({ - cfg, - agentIds: [boundRoute.agentId], - }) + ? telegramDeps.listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] }) : []; const pluginCommandRuntime = createPluginCommandRuntime(); const pluginCommandSpecs = pluginCommandRuntime.listNativeCandidates("telegram"); @@ -930,22 +138,17 @@ export const registerTelegramNativeCommands = ({ ); return null; } - const menuCommand: TelegramMenuCommand = { + return { command: normalized, description: command.description, + ...(command.isAlias ? { isAlias: true } : {}), + ...(index >= firstSkillCommandIndex ? { isSkill: true } : {}), + ...(command.descriptionLocalizations + ? { descriptionLocalizations: command.descriptionLocalizations } + : {}), }; - if (command.isAlias) { - menuCommand.isAlias = true; - } - if (index >= firstSkillCommandIndex) { - menuCommand.isSkill = true; - } - if (command.descriptionLocalizations) { - menuCommand.descriptionLocalizations = command.descriptionLocalizations; - } - return menuCommand; }) - .filter((cmd) => cmd !== null); + .filter((command) => command !== null); const customCommandNames = new Set(customCommands.map((command) => command.command)); const fullCommandCatalog = buildCappedTelegramMenuCommands({ allCommands: [ @@ -970,9 +173,6 @@ export const registerTelegramNativeCommands = ({ : loginCommand ? [loginCommand] : []; - const loadFreshRuntimeConfig = (): OpenClawConfig => telegramDeps.getRuntimeConfig(); - const resolveFreshTelegramConfig = (runtimeCfg: OpenClawConfig): TelegramAccountConfig => - resolveTelegramAccount({ cfg: runtimeCfg, accountId }).config; const { commandsToRegister, totalCommands, @@ -1001,8 +201,7 @@ export const registerTelegramNativeCommands = ({ } const syncTelegramMenuCommands = telegramDeps.syncTelegramMenuCommands ?? syncTelegramMenuCommandsRuntime; - // Telegram only limits the setMyCommands payload (menu entries). - // Keep hidden commands callable by registering handlers for the full catalog. + // Telegram only limits menu entries; hidden commands remain callable. syncTelegramMenuCommands({ bot, runtime, @@ -1012,143 +211,21 @@ export const registerTelegramNativeCommands = ({ botToken: opts.token, }); - const resolveCommandRuntimeContext = async (params: { - msg: NonNullable; - runtimeCfg: OpenClawConfig; - isGroup: boolean; - isForum: boolean; - resolvedThreadId?: number; - senderId?: string; - topicAgentId?: string; - }): Promise<{ - chatId: number; - threadSpec: ReturnType; - route: ReturnType["route"]; - mediaLocalRoots: readonly string[] | undefined; - tableMode: ReturnType; - chunkMode: TelegramChunkMode; - } | null> => { - const { msg, runtimeCfg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params; - const chatId = msg.chat.id; - const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); - const { route, bindingMode } = resolveTelegramConversationRoute({ - cfg: runtimeCfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - replyThreadId: threadSpec.id, - senderId, - topicAgentId, - }); - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - if (bindingMode.kind === "configured") { - const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({ - cfg: runtimeCfg, - bindingResolution: bindingMode.binding, - }); - if (!ensured.ok) { - logVerbose( - `telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`, - ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage( - chatId, - "Configured ACP binding is unavailable right now. Please try again.", - buildTelegramThreadParams(threadSpec) ?? {}, - ), - }); - return null; - } - } - const mediaLocalRoots = nativeCommandRuntime.getAgentScopedMediaLocalRoots( - runtimeCfg, - route.agentId, - ); - const tableMode = resolveMarkdownTableMode({ - cfg: runtimeCfg, - channel: "telegram", - accountId: route.accountId, - supportsBlockTables: true, - }); - const chunkMode = nativeCommandRuntime.resolveChunkMode( - runtimeCfg, - "telegram", - route.accountId, - ); - return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode }; - }; - const buildCommandDeliveryBaseOptions = (params: { - cfg: OpenClawConfig; - chatId: string | number; - accountId: string; - sessionKeyForInternalHooks?: string; - policySessionKey?: string; - mirrorIsGroup?: boolean; - mirrorGroupId?: string; - mediaLocalRoots?: readonly string[]; - threadSpec: ReturnType; - tableMode: ReturnType; - chunkMode: TelegramChunkMode; - replyToMode: ReplyToMode; - textLimit: number; - linkPreview?: boolean; - richMessages?: boolean; + const buildExecutorParams = (params: { + botUser: Context["me"]; + msg: NonNullable; + rawText: string; }) => ({ - cfg: params.cfg, - chatId: String(params.chatId), - accountId: params.accountId, - sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, - policySessionKey: params.policySessionKey, - mirrorIsGroup: params.mirrorIsGroup, - mirrorGroupId: params.mirrorGroupId, - token: opts.token, - runtime, + ...params, bot, - mediaLocalRoots: params.mediaLocalRoots, + runtime, + accountId, mediaMaxBytes, - replyToMode: params.replyToMode, - textLimit: params.textLimit, - thread: params.threadSpec, - tableMode: params.tableMode, - chunkMode: params.chunkMode, - linkPreview: params.linkPreview, - richMessages: params.richMessages, + resolveGroupPolicy, + resolveTelegramGroupConfig, + telegramDeps, + opts, }); - const resolveCommandTargetSessionKey = (params: { - runtimeCfg: OpenClawConfig; - route: ReturnType["route"]; - chatId: number; - isGroup: boolean; - senderId?: string; - threadSpec: ReturnType; - botHasTopicsEnabled?: boolean; - resolveThreadSessionKeys: TelegramNativeCommandRuntime["resolveThreadSessionKeys"]; - }): string => { - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ - cfg: params.runtimeCfg, - route: params.route, - chatId: params.chatId, - isGroup: params.isGroup, - senderId: params.senderId, - }); - const dmThreadId = params.threadSpec.scope === "dm" ? params.threadSpec.id : undefined; - const threadKeys = - shouldUseTelegramDmThreadSession({ - dmThreadId, - botHasTopicsEnabled: params.botHasTopicsEnabled, - }) && dmThreadId != null - ? params.resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${params.chatId}:${dmThreadId}`, - }) - : null; - return threadKeys?.sessionKey ?? baseSessionKey; - }; - let handleLoginCallback: | (( botUser: Context["me"], @@ -1156,918 +233,57 @@ export const registerTelegramNativeCommands = ({ rawText: string, ) => Promise) | undefined; - if (nativeCommandsToHandle.length > 0 || pluginCatalog.selectedCommands.length > 0) { - for (const command of nativeCommandsToHandle) { - const normalizedCommandName = normalizeTelegramCommandName(command.name); - const commandDefinition = findCommandByNativeName(command.name, "telegram"); - const handleNativeCommand = async ( - botUser: Context["me"], - msg: NonNullable, - rawText: string, - ): Promise => { - const runtimeCfg = loadFreshRuntimeConfig(); - const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); - const turnSettings = resolveTelegramMessageTurnSettings({ - accountId, - cfg: runtimeCfg, - telegramCfg: runtimeTelegramCfg, - opts, - }); - const auth = await resolveTelegramCommandAuth({ - msg, - bot, - cfg: runtimeCfg, - accountId, - telegramCfg: runtimeTelegramCfg, - readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, - allowFrom: turnSettings.allowFrom, - groupAllowFrom: turnSettings.groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth: true, - }); - if (!auth) { - return false; - } - const { - chatId, - isGroup, - isForum, - resolvedThreadId, - senderId, - senderUsername, - groupConfig, - topicConfig, - commandAuthorized, - senderIsOwner, - } = auth; - const runtimeContext = await resolveCommandRuntimeContext({ - msg, - runtimeCfg, - isGroup, - isForum, - resolvedThreadId, - senderId, - topicAgentId: topicConfig?.agentId, - }); - if (!runtimeContext) { - return false; - } - const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; - const originatingTo = buildTelegramRoutingTarget(chatId, threadSpec); - const commandArgs = commandDefinition - ? parseCommandArgs(commandDefinition, rawText) - : rawText - ? ({ raw: rawText } satisfies CommandArgs) - : undefined; - const prompt = commandDefinition - ? buildCommandTextFromArgs(commandDefinition, commandArgs) - : rawText - ? `/${command.name} ${rawText}` - : `/${command.name}`; - - if (commandDefinition?.key === "login") { - const sendLoginMessage = async (text: string) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => bot.api.sendMessage(chatId, text, threadParams), - }); - }; - const sendLoginDeviceCode = async (params: TelegramLoginDeviceCode) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, formatTelegramLoginDeviceCode(params), { - ...threadParams, - parse_mode: "HTML", - }), - }); - }; - const sendLoginResultMessage = async (text: string) => { - await telegramDeps.sendMessageTelegram( - buildTelegramRoutingTarget(chatId, threadSpec), - text, - { - cfg: runtimeCfg, - token: opts.token, - accountId: route.accountId, - }, - ); - }; - if ( - !senderIsOwner || - !codexChannelLoginRuntime.hasConfiguredCommandOwnerAllowlist(runtimeCfg) - ) { - await sendLoginMessage( - "Only a configured OpenClaw owner can start Codex login from Telegram.", - ); - return false; - } - if (isGroup) { - await sendLoginMessage( - "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", - ); - return true; - } - const loginProvider = codexChannelLoginRuntime.resolveProvider( - resolveTelegramCodexLoginProviderInput(commandArgs), - ); - if (!loginProvider) { - await sendLoginMessage("Unsupported login provider. Use `/login codex`."); - return false; - } - const flowKey = buildTelegramCodexLoginFlowKey({ - accountId: route.accountId, - chatId, - threadSpec, - agentId: route.agentId, - provider: loginProvider, - }); - const reservation = codexChannelLoginRuntime.reserveFlow({ - flows: activeTelegramCodexLoginFlows, - flowKey, - }); - if (reservation.status === "active") { - await sendLoginMessage( - "A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.", - ); - return true; - } - const flowSignal = opts.accountAbortSignal - ? AbortSignal.any([reservation.record.signal, opts.accountAbortSignal]) - : reservation.record.signal; - const deviceCodeDelivered = createDeferred(); - let deviceCodeWasDelivered = false; - // Device-code delivery releases Telegram's serialized chat lane. The - // reservation and account signal still own polling through completion. - const completion = (async () => { - const sessionSwitchFailedMessage = - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually."; - let terminalMessage: string; - const loginFlow = - telegramDeps.runModelsAuthLoginFlow ?? - defaultTelegramNativeCommandDeps.runModelsAuthLoginFlow; - try { - if (!loginFlow) { - throw new Error("Codex login flow is unavailable."); - } - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - const targetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(botUser), - resolveThreadSessionKeys: nativeCommandRuntime.resolveThreadSessionKeys, - }); - const targetSessionEntryAtStart = nativeCommandRuntime.getSessionEntry({ - agentId: route.agentId, - sessionKey: targetSessionKey, - }); - const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({ - runLoginFlow: loginFlow, - provider: loginProvider, - agentId: route.agentId, - config: runtimeCfg, - runtime, - signal: flowSignal, - sendMessage: sendLoginMessage, - sendDeviceCode: async (deviceCode) => { - flowSignal.throwIfAborted(); - await sendLoginDeviceCode(deviceCode); - flowSignal.throwIfAborted(); - deviceCodeWasDelivered = true; - deviceCodeDelivered.resolve(); - }, - unsupportedPromptMessage: - "Telegram /login supports only fixed Codex device-code auth.", - }); - flowSignal.throwIfAborted(); - const nextProfileId = loginResult.profiles.find( - (profile) => profile.provider === loginProvider, - )?.profileId; - terminalMessage = "Codex login complete. Try your request again now."; - if (!nextProfileId) { - terminalMessage = sessionSwitchFailedMessage; - } else { - const storePath = resolveStorePath(runtimeCfg.session?.store, { - agentId: route.agentId, - }); - let entryObserved = false; - let adoptionAllowed = false; - try { - const persisted = await updateSessionStoreEntry({ - sessionKey: targetSessionKey, - storePath, - requireWriteSuccess: true, - skipMaintenance: true, - update: (entry) => { - entryObserved = true; - const source = - entry.authProfileOverrideSource ?? - (typeof entry.authProfileOverrideCompactionCount === "number" - ? "auto" - : entry.authProfileOverride - ? "user" - : undefined); - if ( - flowSignal.aborted || - (targetSessionEntryAtStart - ? entry.sessionId !== targetSessionEntryAtStart.sessionId || - entry.authProfileOverride !== - targetSessionEntryAtStart.authProfileOverride || - entry.authProfileOverrideSource !== - targetSessionEntryAtStart.authProfileOverrideSource || - entry.authProfileOverrideCompactionCount !== - targetSessionEntryAtStart.authProfileOverrideCompactionCount - : source === "user" && entry.authProfileOverride !== nextProfileId) - ) { - return null; - } - adoptionAllowed = true; - return entry.authProfileOverride !== nextProfileId || - entry.authProfileOverrideSource !== "user" || - entry.authProfileOverrideCompactionCount !== undefined - ? { - authProfileOverride: nextProfileId, - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - } - : null; - }, - }); - flowSignal.throwIfAborted(); - if ( - entryObserved && - (!adoptionAllowed || - !persisted || - persisted.authProfileOverride !== nextProfileId || - persisted.authProfileOverrideSource !== "user" || - persisted.authProfileOverrideCompactionCount !== undefined) - ) { - terminalMessage = sessionSwitchFailedMessage; - } - } catch (error) { - flowSignal.throwIfAborted(); - runtime.error?.( - danger( - `telegram /login codex completed but failed to update session auth profile: ${String( - error, - )}`, - ), - ); - terminalMessage = sessionSwitchFailedMessage; - } - } - } catch (error) { - if (flowSignal.aborted) { - return; - } - runtime.error?.(danger(`telegram /login codex failed: ${String(error)}`)); - terminalMessage = - "Codex login did not complete. Send `/login codex` to request a new code."; - } - if (flowSignal.aborted) { - return; - } - try { - await sendLoginResultMessage(terminalMessage); - } catch (error) { - runtime.error?.( - danger(`telegram /login codex result notification failed: ${String(error)}`), - ); - } - })().finally(() => { - codexChannelLoginRuntime.releaseFlow({ - flows: activeTelegramCodexLoginFlows, - flowKey, - record: reservation.record, - }); - }); - await Promise.race([deviceCodeDelivered.promise, completion]); - return deviceCodeWasDelivered; - } - - let cachedTargetSessionKey: string | undefined; - let cachedNativeCommandRuntime: - | Awaited> - | undefined; - const resolveNativeCommandRuntime = async () => { - cachedNativeCommandRuntime ??= await loadTelegramNativeCommandRuntime(); - return cachedNativeCommandRuntime; - }; - const resolveTargetSessionKey = async (): Promise => { - if (cachedTargetSessionKey) { - return cachedTargetSessionKey; - } - cachedTargetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(botUser), - resolveThreadSessionKeys: (await resolveNativeCommandRuntime()) - .resolveThreadSessionKeys, - }); - return cachedTargetSessionKey; - }; - const menuNeedsModelContext = - commandDefinition?.argsMenu && - !(commandArgs?.raw && !commandArgs.values) && - commandDefinition.args?.some( - (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, - ); - const targetSessionKeyForMenu = - commandDefinition && menuNeedsModelContext ? await resolveTargetSessionKey() : ""; - const fastCommandState = - commandDefinition?.key === "fast" && menuNeedsModelContext - ? resolveTelegramFastCommandState({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }) - : undefined; - const fastMenuModelContext = - commandDefinition?.key === "fast" && menuNeedsModelContext - ? resolveTelegramFastCommandModelContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }) - : undefined; - const menuModelContext = - commandDefinition && menuNeedsModelContext - ? (fastMenuModelContext ?? - resolveTelegramCommandMenuModelContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - })) - : {}; - // Native /think must not wait on provider discovery; persisted rows retain its metadata. - const menuModelCatalog = - commandDefinition?.key === "think" && menuNeedsModelContext - ? await loadPreparedModelCatalog({ - config: runtimeCfg, - agentId: route.agentId, - agentDir: resolveAgentDir(runtimeCfg, route.agentId), - readOnly: true, - }) - : undefined; - const menu = commandDefinition - ? resolveCommandArgMenu({ - command: commandDefinition, - args: commandArgs, - cfg: runtimeCfg, - ...menuModelContext, - ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), - }) - : null; - if (menu && commandDefinition) { - const title = formatTelegramCommandArgMenuTitle({ - command: commandDefinition, - menu, - currentThinkingLevel: - commandDefinition.key === "think" - ? await resolveTelegramThinkMenuCurrentLevel({ - cfg: runtimeCfg, - agentId: route.agentId, - ...menuModelContext, - catalog: menuModelCatalog ?? [], - }) - : undefined, - currentFastModeStatus: - commandDefinition.key === "fast" - ? resolveTelegramFastMenuCurrentStatus({ - state: - fastCommandState ?? - resolveTelegramFastCommandState({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }), - }) - : undefined, - }); - const rows: Array> = []; - for (let i = 0; i < menu.choices.length; i += 2) { - const slice = menu.choices.slice(i, i + 2); - rows.push( - slice.map((choice) => { - const args: CommandArgs = { - values: { [menu.arg.name]: choice.value }, - }; - return { - text: choice.label, - callback_data: buildTelegramNativeCommandCallbackData( - buildCommandTextFromArgs(commandDefinition, args), - ), - }; - }), - ); - } - const replyMarkup = buildInlineKeyboard(rows); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, title, { - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - ...threadParams, - }), - }); - return false; - } - const nativeCommandRuntime = await resolveNativeCommandRuntime(); - const sessionKey = await resolveTargetSessionKey(); - const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ - groupConfig, - topicConfig, - }); - const { sessionKey: commandSessionKey, commandTargetSessionKey } = - resolveNativeCommandSessionTargets({ - agentId: route.agentId, - sessionPrefix: "telegram:slash", - userId: String(senderId || chatId), - targetSessionKey: sessionKey, - }); - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - cfg: runtimeCfg, - chatId, - accountId: route.accountId, - sessionKeyForInternalHooks: commandSessionKey, - policySessionKey: commandTargetSessionKey, - mirrorIsGroup: isGroup, - mirrorGroupId: isGroup ? String(chatId) : undefined, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - replyToMode: turnSettings.replyToMode, - textLimit: turnSettings.textLimit, - linkPreview: runtimeTelegramCfg.linkPreview, - richMessages: runtimeTelegramCfg.richMessages, - }); - let topicName: string | undefined; - if (isForum && resolvedThreadId != null) { - try { - const storePath = resolveStorePath(runtimeCfg.session?.store, { - agentId: route.accountId, - }); - const scope = resolveTopicNameCacheScope(storePath); - topicName = await getTopicName(chatId, resolvedThreadId, scope); - } catch { - // best-effort: topic name is supplementary metadata - } - } - const conversationLabel = isGroup - ? msg.chat.title - ? `${msg.chat.title} id:${chatId}` - : `group:${chatId}` - : (buildSenderName(msg) ?? String(senderId || chatId)); - const ctxPayload = nativeCommandRuntime.finalizeInboundContext({ - Body: prompt, - BodyForAgent: prompt, - RawBody: prompt, - CommandBody: prompt, - CommandArgs: commandArgs, - From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, - To: `slash:${senderId || chatId}`, - ChatType: isGroup ? "group" : "direct", - ConversationToolPolicy: isGroup - ? undefined - : resolveTelegramDirectToolPolicy({ - directConfig: groupConfig, - senderId, - senderName: buildSenderName(msg), - senderUsername, - }), - ConversationLabel: conversationLabel, - GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, - GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined, - SenderName: buildSenderName(msg), - SenderId: senderId || undefined, - SenderUsername: senderUsername || undefined, - Surface: "telegram", - Provider: "telegram", - MessageSid: String(msg.message_id), - Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: true, - CommandAuthorized: commandAuthorized, - CommandTurn: { - kind: "native" as const, - source: "native" as const, - authorized: commandAuthorized, - body: prompt, - }, - CommandSource: "native" as const, - SessionKey: commandSessionKey, - AccountId: route.accountId, - CommandTargetSessionKey: commandTargetSessionKey, - MessageThreadId: threadSpec.id, - IsForum: isForum, - TopicName: isForum && topicName ? topicName : undefined, - // Originating context for sub-agent announce routing - OriginatingChannel: "telegram" as const, - OriginatingTo: originatingTo, - }); - const disableBlockStreaming = - resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg); - const deliveryState = { - delivered: false, - skippedNonSilent: 0, - failedNonSilent: 0, - }; - let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined; - - const { deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime(); - let recordSessionMetaTask: Promise | undefined; - - const turnPlan: ChannelInboundTurnPlan<"provider_message_sending"> = { - cfg: runtimeCfg, - channel: "telegram", - accountId: route.accountId, - route: { - agentId: route.agentId, - sessionKey: commandSessionKey, - }, - ctxPayload, - record: { - sessionKey: commandTargetSessionKey, - trackSessionMetaTask: (task) => { - recordSessionMetaTask = task; - }, - onRecordError: (err) => - runtime.error?.( - danger(`telegram slash: failed updating session meta: ${String(err)}`), - ), - }, - // Native commands historically persisted target metadata before dispatch. - // Preserve that ordering while the shared recorder owns the write. - afterRecord: async () => { - await recordSessionMetaTask; - }, - replyPipeline: {}, - dispatcherOptions: { - beforeDeliver: async (payload) => payload, - onSkip: (_payload, info) => { - if (info.reason !== "silent") { - deliveryState.skippedNonSilent += 1; - } - }, - }, - delivery: { - deliverWithProviderMessageSending: async (payload, info) => { - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg: runtimeCfg, - accountId: route.accountId, - payload, - }) - ) { - deliveryState.delivered = true; - return { - visibleReplySent: false, - suppression: { reason: "no_visible_result" }, - }; - } - const targetedPayload = payload.replyToId - ? payload - : { ...payload, replyToId: String(msg.message_id) }; - const result = await deliverReplies({ - // Bind custody so a lost response on the native-command path is - // recorded as ambiguous instead of silently unaccounted. - replies: [ - info.bindPendingFinalDelivery - ? info.bindPendingFinalDelivery(targetedPayload) - : targetedPayload, - ], - ...deliveryBaseOptions, - silent: runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, - onPlatformSendDispatch: info.onPlatformSendDispatch, - }); - if (result.delivered) { - deliveryState.delivered = true; - } - return result.delivered - ? { visibleReplySent: true } - : { - visibleReplySent: false, - suppression: { reason: "no_visible_result" as const }, - }; - }, - onDelivered: (_payload, info, result) => { - const reason = result?.suppression?.reason; - if (info.kind === "final" && result?.visibleReplySent) { - finalReplyOutcome = "accepted"; - } - if ( - info.kind === "final" && - finalReplyOutcome !== "failed" && - (reason === "cancelled_by_reply_payload_sending_hook" || - reason === "empty_after_reply_payload_sending_hook") - ) { - finalReplyOutcome = "suppressed"; - } - }, - onError: (err, info) => { - deliveryState.failedNonSilent += 1; - const partialDelivery = isChannelPartialDeliveryError(err); - if (partialDelivery) { - deliveryState.delivered = true; - logVerbose("telegram slash reply partially delivered before failure"); - } - if (info.kind === "final") { - // A failed final outweighs any earlier suppression until a final delivers. - finalReplyOutcome = partialDelivery ? "accepted" : "failed"; - } - runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - [PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH, - }, - }; - const turnResult = await ( - telegramDeps.dispatchChannelInboundTurn ?? - defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn - )(turnPlan); - if ( - !deliveryState.delivered && - finalReplyOutcome !== "suppressed" && - (deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) && - (!turnResult.dispatched || - turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" || - deliveryState.failedNonSilent > 0) - ) { - await deliverReplies({ - replies: [{ text: EMPTY_RESPONSE_FALLBACK }], - ...deliveryBaseOptions, - }); - } - return false; - }; - if (nativeEnabled) { - bot.command(normalizedCommandName, async (ctx) => { - if (shouldSkipUpdate(ctx)) { - return; - } - const msg = ctx.message; - if (!msg) { - return; - } - await handleNativeCommand( - ctx.me, - msg, - typeof ctx.match === "string" ? ctx.match.trim() : "", - ); - }); - } - if (commandDefinition?.key === "login") { - handleLoginCallback = handleNativeCommand; - } - } - - for (const pluginCommand of pluginCatalog.selectedCommands) { - bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { - const msg = ctx.message; - if (!msg) { + for (const command of nativeCommandsToHandle) { + const normalizedCommandName = normalizeTelegramCommandName(command.name); + const handleNativeCommand = async ( + botUser: Context["me"], + msg: NonNullable, + rawText: string, + ): Promise => { + const { executeTelegramBuiltinCommand } = await loadTelegramBuiltinCommandExecutor(); + return await executeTelegramBuiltinCommand({ + ...buildExecutorParams({ botUser, msg, rawText }), + commandName: command.name, + }); + }; + if (nativeEnabled) { + bot.command(normalizedCommandName, async (ctx) => { + if (shouldSkipUpdate(ctx) || !ctx.message) { return; } - if (shouldSkipUpdate(ctx)) { - return; - } - const chatId = msg.chat.id; - const runtimeCfg = loadFreshRuntimeConfig(); - const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); - const turnSettings = resolveTelegramMessageTurnSettings({ - accountId, - cfg: runtimeCfg, - telegramCfg: runtimeTelegramCfg, - opts, - }); - const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot }); - const rawText = ctx.match?.trim() ?? ""; - const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; - const candidate = pluginCommand.spec; - const pluginCommandDispatch = candidate.prepareDispatch(rawText); - if (pluginCommandDispatch.kind === "non-plugin") { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}), - }); - return; - } - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - const auth = await resolveTelegramCommandAuth({ - msg, - bot, - cfg: runtimeCfg, - accountId, - telegramCfg: runtimeTelegramCfg, - readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, - allowFrom: turnSettings.allowFrom, - groupAllowFrom: turnSettings.groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth: candidate.requireAuth, - }); - if (!auth) { - return; - } - const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } = - auth; - const runtimeContext = await resolveCommandRuntimeContext({ - msg, - runtimeCfg, - isGroup, - isForum, - resolvedThreadId, - senderId, - topicAgentId: auth.topicConfig?.agentId, - }); - if (!runtimeContext) { - return; - } - const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const targetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), - resolveThreadSessionKeys: nativeCommandRuntime.resolveThreadSessionKeys, - }); - const targetSessionEntry = nativeCommandRuntime.getSessionEntry({ - agentId: route.agentId, - sessionKey: targetSessionKey, - }); - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - cfg: runtimeCfg, - chatId, - accountId: route.accountId, - sessionKeyForInternalHooks: targetSessionKey, - policySessionKey: targetSessionKey, - mirrorIsGroup: isGroup, - mirrorGroupId: isGroup ? String(chatId) : undefined, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - replyToMode: turnSettings.replyToMode, - textLimit: turnSettings.textLimit, - linkPreview: runtimeTelegramCfg.linkPreview, - richMessages: runtimeTelegramCfg.richMessages, - }); - const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; - const to = - threadSpec.scope === "direct-messages" - ? buildTelegramRoutingTarget(chatId, threadSpec) - : `telegram:${chatId}`; - const { deliverReplies, emitTelegramMessageSentHooks } = - await loadTelegramNativeCommandDeliveryRuntime(); - let progressMessageId: number | undefined; - const progressPlaceholder = candidate.progressMessage; - - if (progressPlaceholder) { - try { - const sent = await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage( - chatId, - progressPlaceholder, - buildTelegramThreadParams(threadSpec), - ), - }); - const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id; - if (typeof maybeMessageId === "number") { - progressMessageId = maybeMessageId; - } - } catch { - // Fall back to the normal final reply path if the placeholder send fails. - } - } - - const transcriptContext = await resolveTelegramCommandTranscriptContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKey, - threadId: threadSpec.id, - }); - - const result = normalizeTelegramNativeReplyPayload( - await pluginCommandDispatch.execute({ - senderId, - channel: "telegram", - isAuthorizedSender: commandAuthorized, - senderIsOwner, - agentId: route.agentId, - sessionKey: targetSessionKey, - sessionId: transcriptContext.sessionId, - sessionFile: transcriptContext.sessionFile, - authProfileId: - transcriptContext.authProfileId ?? targetSessionEntry?.authProfileOverride, - commandBody, - config: runtimeCfg, - from, - to, - accountId, - messageThreadId: threadSpec.id, - }), + await handleNativeCommand( + ctx.me, + ctx.message, + typeof ctx.match === "string" ? ctx.match.trim() : "", ); - - const suppressTelegramNativeReply = - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg: runtimeCfg, - accountId: route.accountId, - payload: result, - }) || isSuppressedTelegramNativeReplyPayload(result); - if (suppressTelegramNativeReply) { - await cleanupTelegramProgressPlaceholder({ - bot, - chatId, - progressMessageId, - runtime, - }); - return; - } - - const hasReaction = hasTelegramNativeReplyReaction(result); - const deliverableResult: TelegramNativeReplyPayload = - hasRenderableTelegramNativeReplyPayload(result) - ? hasReaction && !normalizeOptionalString(result.replyToId) - ? { ...result, replyToId: String(msg.message_id) } - : result - : { text: EMPTY_RESPONSE_FALLBACK }; - const progressResultText = - typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 - ? deliverableResult.text - : null; - const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult); - if ( - progressMessageId != null && - telegramDeps.editMessageTelegram && - progressResultText && - isEditableTelegramProgressResult(deliverableResult) - ) { - try { - await telegramDeps.editMessageTelegram(chatId, progressMessageId, progressResultText, { - cfg: runtimeCfg, - accountId: route.accountId, - textMode: "markdown", - linkPreview: runtimeTelegramCfg.linkPreview, - buttons: telegramResultData?.buttons, - }); - recordSentMessage(chatId, progressMessageId, runtimeCfg); - emitTelegramMessageSentHooks({ - sessionKeyForInternalHooks: targetSessionKey, - chatId: String(chatId), - accountId: route.accountId, - content: progressResultText, - success: true, - messageId: progressMessageId, - isGroup, - groupId: isGroup ? String(chatId) : undefined, - }); - return; - } catch { - // Fall through to cleanup + normal delivered reply if editing fails. - } - } - await cleanupTelegramProgressPlaceholder({ - bot, - chatId, - progressMessageId, - runtime, - }); - await deliverReplies({ - replies: [deliverableResult], - ...deliveryBaseOptions, - ...(hasReaction ? { replyToMode: "all" as const } : {}), - silent: - runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, - }); }); } - if (pluginCatalog.selectedCommands.length > 0) { - pluginCommandRuntime.retainNativeCatalog("telegram"); + if (findCommandByNativeName(command.name, "telegram")?.key === "login") { + handleLoginCallback = handleNativeCommand; } } + for (const pluginCommand of pluginCatalog.selectedCommands) { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { + if (shouldSkipUpdate(ctx) || !ctx.message) { + return; + } + const { executeTelegramPluginCommand } = await loadTelegramPluginCommandExecutor(); + await executeTelegramPluginCommand({ + ...buildExecutorParams({ + botUser: ctx.me, + msg: ctx.message, + rawText: ctx.match?.trim() ?? "", + }), + commandName: pluginCommand.command, + candidate: pluginCommand.spec, + }); + }); + } + if (pluginCatalog.selectedCommands.length > 0) { + pluginCommandRuntime.retainNativeCatalog("telegram"); + } + if (!handleLoginCallback) { return undefined; } @@ -2087,19 +303,20 @@ export const registerTelegramNativeCommands = ({ if (!callbackMessage || callbackMessage.date <= 0) { return { handled: true, clearButtons: false }; } - const chat = callbackMessage.chat; - if (chat.type === "channel") { + if (callbackMessage.chat.type === "channel") { return { handled: true, clearButtons: false }; } const rawText = separatorIndex === -1 ? "" : commandBody.slice(separatorIndex + 1).trim(); - const message = { - ...callbackMessage, - chat, - from: callbackQuery.from, - text: commandText, - }; - const clearButtons = await handleLoginCallback(botUser, message, rawText); + const clearButtons = await handleLoginCallback( + botUser, + { + ...callbackMessage, + chat: callbackMessage.chat, + from: callbackQuery.from, + text: commandText, + }, + rawText, + ); return { handled: true, clearButtons }; }; }; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index da796726467b..ad4762da6b5b 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -9,12 +9,17 @@ import { buildAgentSessionKey, deriveLastRoutePolicy, resolveAgentRoute, + resolveThreadSessionKeys, } from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, sanitizeAgentId } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultTelegramAccountId } from "./accounts.js"; -import { buildTelegramGroupPeerId, buildTelegramParentPeer } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + shouldUseTelegramDmThreadSession, +} from "./bot/helpers.js"; import { resolveTelegramDirectPeerId, resolveTelegramNamedAccountBaseSessionKey, @@ -162,3 +167,26 @@ export function resolveTelegramConversationBaseSessionKey( params, ); } + +export function resolveTelegramTargetSession(params: { + cfg: OpenClawConfig; + route: TelegramResolvedRoute; + chatId: number | string; + isGroup: boolean; + senderId?: string | number | null; + dmThreadId?: number; + botHasTopicsEnabled?: boolean; +}): string { + const baseSessionKey = resolveTelegramConversationBaseSessionKey(params); + const threadKeys = + shouldUseTelegramDmThreadSession({ + dmThreadId: params.dmThreadId, + botHasTopicsEnabled: params.botHasTopicsEnabled, + }) && params.dmThreadId != null + ? resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${params.chatId}:${params.dmThreadId}`, + }) + : null; + return threadKeys?.sessionKey ?? baseSessionKey; +} diff --git a/extensions/telegram/src/native-command-callback-data.test.ts b/extensions/telegram/src/native-command-callback-data.test.ts new file mode 100644 index 000000000000..253582f0a8c2 --- /dev/null +++ b/extensions/telegram/src/native-command-callback-data.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { parseTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; + +describe("parseTelegramNativeCommandCallbackData", () => { + it("preserves prefixed native commands and rejects malformed command bodies", () => { + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); + }); +}); From a3b2700dff451c7451aac64ee03e70d4a1be2d1b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:46:22 -0700 Subject: [PATCH 013/165] fix(deps): bump @openclaw/fs-safe to 0.5.5 for win32 zero-inode identity (#122427) --- extensions/onepassword/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/onepassword/package.json b/extensions/onepassword/package.json index 7bd9d2d28d83..9d87e7b146b2 100644 --- a/extensions/onepassword/package.json +++ b/extensions/onepassword/package.json @@ -5,7 +5,7 @@ "description": "1Password SecretRef resolver and audited agent secrets broker for OpenClaw", "type": "module", "dependencies": { - "@openclaw/fs-safe": "0.5.4", + "@openclaw/fs-safe": "0.5.5", "execa": "10.0.0" }, "devDependencies": { diff --git a/package.json b/package.json index 1dfaed270448..92805a628d8e 100644 --- a/package.json +++ b/package.json @@ -2026,7 +2026,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "0.6.0", "@openclaw/ai": "workspace:*", - "@openclaw/fs-safe": "0.5.4", + "@openclaw/fs-safe": "0.5.5", "@openclaw/proxyline": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4", "@trycua/cua-driver": "0.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b567698b434b..04deda2564f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,8 +89,8 @@ importers: specifier: workspace:* version: link:packages/ai '@openclaw/fs-safe': - specifier: 0.5.4 - version: 0.5.4 + specifier: 0.5.5 + version: 0.5.5 '@openclaw/proxyline': specifier: 0.3.4 version: 0.3.4(undici@8.9.0) @@ -1487,8 +1487,8 @@ importers: extensions/onepassword: dependencies: '@openclaw/fs-safe': - specifier: 0.5.4 - version: 0.5.4 + specifier: 0.5.5 + version: 0.5.5 execa: specifier: 10.0.0 version: 10.0.0 @@ -4107,8 +4107,8 @@ packages: engines: {node: '>=22'} hasBin: true - '@openclaw/fs-safe@0.5.4': - resolution: {integrity: sha512-lttlWKRBQU7eYDYykU2BPoF1S6AESGrT77mVCXsuWBxnRBTAI/PuGB89DB3D1lBCDaqTykIjymPJz4pFesGnkg==} + '@openclaw/fs-safe@0.5.5': + resolution: {integrity: sha512-x8wYigrOwmnsE8v4LfAh2eTvvkuDMspBrQeBp/GyB9/c7eFf8aL7gK4keewDbhscKX1pACD8Yd+ehZq6o29xHw==} engines: {node: '>=22'} '@openclaw/libterminal@0.3.2': @@ -11395,7 +11395,7 @@ snapshots: - bufferutil - utf-8-validate - '@openclaw/fs-safe@0.5.4': + '@openclaw/fs-safe@0.5.5': optionalDependencies: jszip: 3.10.1 tar: 7.5.22 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ea050d19072a..956c1f5c971e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,7 @@ minimumReleaseAge: 2880 minimumReleaseAgeExclude: - "@openclaw/crabline@0.1.11" - - "@openclaw/fs-safe@0.5.4" + - "@openclaw/fs-safe@0.5.5" - "@openclaw/libterminal@0.3.2" - "@openclaw/proxyline@0.3.4" - "@openclaw/uirouter@0.1.1" From b82ad646a5151e5fb6378e72dbbe257fd5012813 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:47:44 -0700 Subject: [PATCH 014/165] fix(delivery): preserve suppressed send outcomes (#122421) * fix(delivery): preserve suppressed send outcomes Preserve authoritative durable-send suppression outcomes so task, subagent, and exec followups no longer report false delivery or duplicate ambiguous adapter dispatches. * fix(delivery): keep suppression reason internal Derive the outbound projection from the existing durable batch result union so the repair does not change the public plugin SDK contract. * fix(delivery): avoid retrying ambiguous task updates Advance the state-change event cursor after an adapter returns no identity so a possibly visible progress notification is not sent twice; intentional suppression remains retryable. --- .../bash-tools.exec-approval-followup.test.ts | 51 +++++++++++++ .../bash-tools.exec-approval-followup.ts | 12 +++- .../subagent-announce-delivery.test.ts | 39 ++++++++++ .../announce/subagent-announce-delivery.ts | 20 +++++- src/infra/outbound/message.test.ts | 66 +++++++++-------- src/infra/outbound/message.ts | 3 + src/tasks/task-registry-delivery.ts | 34 ++++++++- src/tasks/task-registry.test.ts | 72 +++++++++++++++++++ 8 files changed, 261 insertions(+), 36 deletions(-) diff --git a/src/agents/bash-tools.exec-approval-followup.test.ts b/src/agents/bash-tools.exec-approval-followup.test.ts index 9805e1e558d6..04e196edeb37 100644 --- a/src/agents/bash-tools.exec-approval-followup.test.ts +++ b/src/agents/bash-tools.exec-approval-followup.test.ts @@ -758,6 +758,57 @@ describe("exec approval followup", () => { expect(callGatewayTool).not.toHaveBeenCalled(); }); + it.each([ + { + suppressionReason: "cancelled_by_message_sending_hook", + expectedMessage: "delivery was suppressed", + }, + { + suppressionReason: "adapter_returned_no_identity", + expectedMessage: "delivery could not be confirmed", + }, + ] as const)( + "rejects direct followup after $suppressionReason", + async ({ suppressionReason, expectedMessage }) => { + vi.mocked(sendMessage).mockResolvedValueOnce({ + channel: "discord", + to: "123", + via: "direct", + mediaUrl: null, + deliveryStatus: "suppressed", + suppressionReason, + }); + + await expect( + sendExecApprovalFollowup({ + approvalId: `req-${suppressionReason}`, + turnSourceChannel: "discord", + turnSourceTo: "123", + resultText: "Exec finished (gateway id=req-suppressed, code 0)\nall good", + }), + ).rejects.toThrow(expectedMessage); + }, + ); + + it("accepts direct followup success without a delivery status", async () => { + vi.mocked(sendMessage).mockResolvedValueOnce({ + channel: "discord", + to: "123", + via: "gateway", + mediaUrl: null, + result: { messageId: "gateway-message-1" }, + }); + + await expect( + sendExecApprovalFollowup({ + approvalId: "req-gateway-compatible", + turnSourceChannel: "discord", + turnSourceTo: "123", + resultText: "Exec finished (gateway id=req-gateway-compatible, code 0)\nall good", + }), + ).resolves.toBe(true); + }); + it("redacts credentials before direct delivery", async () => { const secret = "sk-abcdefghijklmnopqrstuvwxyz123456"; diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 51a7da95fa70..2bc8b5b3b28b 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -414,7 +414,7 @@ async function sendDirectFollowupFallback(params: { Math.max(0, directText.length - Math.max(1, availableBodyUnits)), )}`; const deliveryIntentId = `exec-approval-followup:${params.approvalId}`; - await sendMessage({ + const sendResult = await sendMessage({ channel: params.deliveryTarget.channel, to: params.deliveryTarget.to ?? "", accountId: params.deliveryTarget.accountId, @@ -427,6 +427,16 @@ async function sendDirectFollowupFallback(params: { reusePendingDeliveryIntent: true, completionRetention: DIRECT_FOLLOWUP_COMPLETION_RETENTION, }); + if (sendResult.deliveryStatus === "suppressed") { + if (sendResult.suppressionReason === "adapter_returned_no_identity") { + throw new Error( + "exec approval followup delivery could not be confirmed: adapter returned no identity", + ); + } + throw new Error( + `exec approval followup delivery was suppressed: ${sendResult.suppressionReason ?? "unknown reason"}`, + ); + } return true; } diff --git a/src/agents/subagents/announce/subagent-announce-delivery.test.ts b/src/agents/subagents/announce/subagent-announce-delivery.test.ts index 06ef1df4719c..b5278199db03 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.test.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.test.ts @@ -1313,6 +1313,45 @@ describe("deliverSubagentAnnouncement completion delivery", () => { } }); + it.each([ + { + name: "intentional suppression", + suppressionReason: "cancelled_by_message_sending_hook", + disposition: "intentional_non_delivery", + }, + { + name: "adapter ambiguity", + suppressionReason: "adapter_returned_no_identity", + disposition: "ambiguous", + }, + ] as const)("reports $name from direct text completion fallback", async (testCase) => { + const callGateway = createPayloadGatewayMock(); + const onDeliveryResult = vi.fn(); + const sendMessage = vi.fn(async () => ({ + channel: "discord", + to: "dm:U123", + via: "direct" as const, + mediaUrl: null, + deliveryStatus: "suppressed" as const, + suppressionReason: testCase.suppressionReason, + })) as unknown as typeof runtimeSendMessage; + + const result = await deliverDiscordDirectMessageCompletion({ + callGateway, + sendMessage, + internalEvents: taskCompletionEvents({ childSessionId: "child-session-id" }), + onDeliveryResult, + }); + + expectRecordFields(result, { + delivered: false, + path: "direct", + disposition: testCase.disposition, + }); + expect(onDeliveryResult).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + it("sanitizes and bounds text before direct completion fallback delivery", async () => { const callGateway = createPayloadGatewayMock(); const sendMessage = createSendMessageMock(); diff --git a/src/agents/subagents/announce/subagent-announce-delivery.ts b/src/agents/subagents/announce/subagent-announce-delivery.ts index fe79d3368fca..9002834d6c2f 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.ts @@ -760,7 +760,7 @@ async function deliverCompletionDirect(params: { if (params.isSourceSessionEffectsAllowed?.() === false) { return sourceOwnerChangedResult(); } - await subagentAnnounceDeliveryDeps.sendMessage({ + const sendResult = await subagentAnnounceDeliveryDeps.sendMessage({ cfg: params.cfg, channel: params.deliveryTarget.channel, to: params.deliveryTarget.to, @@ -786,7 +786,23 @@ async function deliverCompletionDirect(params: { idempotencyKey, }, }); - return committedDelivery ?? { delivered: true, path: "direct" }; + if (committedDelivery) { + return committedDelivery; + } + if (sendResult.deliveryStatus === "suppressed") { + const ambiguous = sendResult.suppressionReason === "adapter_returned_no_identity"; + return { + delivered: false, + path: "direct", + error: ambiguous + ? "text completion direct delivery could not be confirmed: adapter returned no identity" + : `text completion direct delivery was suppressed: ${sendResult.suppressionReason ?? "unknown reason"}`, + ...(ambiguous + ? { disposition: "ambiguous" as const } + : { disposition: "intentional_non_delivery" as const, terminal: true }), + }; + } + return { delivered: true, path: "direct" }; } catch (err) { if (committedDelivery) { // Post-send bookkeeping must never turn an identified delivery into a diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index cf37eea5e0b3..1a0764673565 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -554,40 +554,44 @@ describe("sendMessage", () => { expectDeliveryCallFields({ to: "prepared:123456" }); }); - it("preserves suppressed direct-send status", async () => { - mocks.deliverOutboundPayloads.mockImplementationOnce(async (params: unknown) => { - const callbacks = params as { - onPayloadDeliveryOutcome?: (outcome: unknown) => void; - }; - callbacks.onPayloadDeliveryOutcome?.({ - index: 0, - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - hookEffect: { - cancelReason: "owned-by-other-agent", - metadata: { unsafeForJson: 1n }, - }, + it.each(["cancelled_by_message_sending_hook", "adapter_returned_no_identity"] as const)( + "preserves aggregate suppression reason %s", + async (reason) => { + mocks.deliverOutboundPayloads.mockImplementationOnce(async (params: unknown) => { + const callbacks = params as { + onPayloadDeliveryOutcome?: (outcome: unknown) => void; + }; + callbacks.onPayloadDeliveryOutcome?.({ + index: 0, + status: "suppressed", + reason, + hookEffect: { + cancelReason: "owned-by-other-agent", + metadata: { unsafeForJson: 1n }, + }, + }); + return []; }); - return []; - }); - const result = await sendMessage({ - cfg: {}, - channel: "forum", - to: "123456", - content: "hidden", - }); + const result = await sendMessage({ + cfg: {}, + channel: "forum", + to: "123456", + content: "hidden", + }); - expect(result.deliveryStatus).toBe("suppressed"); - expect(result.payloadOutcomes).toEqual([ - { - index: 0, - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - }, - ]); - expect(() => JSON.stringify(result)).not.toThrow(); - }); + expect(result.deliveryStatus).toBe("suppressed"); + expect(result).toMatchObject({ suppressionReason: reason }); + expect(result.payloadOutcomes).toEqual([ + { + index: 0, + status: "suppressed", + reason, + }, + ]); + expect(() => JSON.stringify(result)).not.toThrow(); + }, + ); it("does not throw best-effort direct send failures but reports the failure", async () => { mocks.deliverOutboundPayloads.mockImplementationOnce(async (params: unknown) => { diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 77e712ba3912..e574599696e7 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -6,6 +6,7 @@ import { deriveDurableFinalDeliveryRequirementsForBatch } from "../../channels/m import { sendDurableMessageBatchCore, serializeDurableMessagePayloadOutcomes, + type DurableMessageBatchSendResult, type SerializedDurableMessagePayloadOutcome, } from "../../channels/message/runtime.js"; import type { DurableMessageSendIntent } from "../../channels/message/types.js"; @@ -129,6 +130,7 @@ export type MessageSendResult = { mediaUrls?: string[]; result?: OutboundDeliveryResult | { messageId: string }; deliveryStatus?: "sent" | "suppressed" | "partial_failed" | "failed"; + suppressionReason?: Extract["reason"]; /** Formatted send error when deliveryStatus is "failed" or "partial_failed". */ error?: string; sentBeforeError?: boolean; @@ -441,6 +443,7 @@ export async function sendMessage(params: MessageSendParams): Promise { }); }); + it.each([ + { + name: "intentional suppression queues the session fallback", + suppressionReason: "cancelled_by_message_sending_hook", + expectedFallbackCount: 1, + }, + { + name: "adapter ambiguity avoids a duplicate session fallback", + suppressionReason: "adapter_returned_no_identity", + expectedFallbackCount: 0, + }, + ] as const)("records terminal non-delivery when $name", async (testCase) => { + await withTaskRegistryTempDir(async () => { + hoisted.sendMessageMock.mockResolvedValue({ + channel: "notifychat", + to: "notifychat:123", + via: "direct", + deliveryStatus: "suppressed", + suppressionReason: testCase.suppressionReason, + }); + const task = createTaskFixture("acp", { + requesterOrigin: NOTIFYCHAT_ORIGIN, + runId: `run-terminal-${testCase.suppressionReason}`, + task: "Investigate suppressed delivery", + deliveryStatus: "pending", + }); + markTaskTerminalById({ taskId: task.taskId, status: "succeeded", endedAt: 250 }); + + await maybeDeliverTaskTerminalUpdate(task.taskId); + + expectRecordFields(requireTaskById(task.taskId), { deliveryStatus: "failed" }); + expect(peekSystemEvents("agent:main:main")).toHaveLength(testCase.expectedFallbackCount); + }); + }); + it("still wakes the parent when blocked delivery misses the outward channel", async () => { await withTaskRegistryTempDir(async () => { resetTaskRegistryMemoryForTest(); @@ -3965,6 +4000,43 @@ describe("task-registry", () => { }); }); + it.each([ + { + name: "retries intentional suppression", + suppressionReason: "cancelled_by_message_sending_hook", + expectedSendCount: 2, + }, + { + name: "does not retry adapter ambiguity", + suppressionReason: "adapter_returned_no_identity", + expectedSendCount: 1, + }, + ] as const)("$name for the same state-change event", async (testCase) => { + await withTaskRegistryTempDir(async () => { + hoisted.sendMessageMock.mockResolvedValue({ + channel: "guildchat", + to: "guildchat:123", + via: "direct", + deliveryStatus: "suppressed", + suppressionReason: testCase.suppressionReason, + }); + const task = createTaskFixture("acp", { + deliveryStatus: undefined, + requesterOrigin: GUILDCHAT_ORIGIN, + childSessionKey: "agent:codex:acp:child", + runId: "run-state-change-suppressed", + task: "Investigate suppressed state change", + notifyPolicy: "state_changes", + }); + const event = { at: 250, kind: "progress" as const, summary: "Still working." }; + + await maybeDeliverTaskStateChangeUpdate(task.taskId, event); + await maybeDeliverTaskStateChangeUpdate(task.taskId, event); + + expect(hoisted.sendMessageMock).toHaveBeenCalledTimes(testCase.expectedSendCount); + }); + }); + it("keeps background ACP progress off the foreground lane and only sends a terminal notify", async () => { await withTaskRegistryTempDir(async () => { resetTaskRegistryMemoryForTest(); From f6459a325561f2754019b5fcd8f29383d789fdbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:49:47 -0700 Subject: [PATCH 015/165] refactor(ui): split chat thread into transcript owners (#122420) * refactor(ui): split chat thread into transcript owners * refactor(ui): delete orphaned pinned-messages surface * chore(lint): ratchet max-lines baseline after chat-thread split * fix(ui): tighten transcript owner type boundaries * refactor(ui): prune pinned-message orphans --- config/max-lines-baseline.txt | 1 - test/vitest/vitest.ui-isolated-paths.mjs | 4 +- ui/src/i18n/locales/en.ts | 2 - ui/src/pages/chat/chat-pane-base.ts | 2 +- ui/src/pages/chat/chat-pane-lifecycle.test.ts | 4 +- ui/src/pages/chat/chat-pane-render.ts | 1 - .../chat/chat-pane-retained-presentation.ts | 4 +- ui/src/pages/chat/chat-thread.test.ts | 212 -- ui/src/pages/chat/chat-view-state.ts | 4 +- ui/src/pages/chat/chat-view.test-helpers.ts | 2 +- ui/src/pages/chat/chat-view.test.ts | 18 +- ui/src/pages/chat/chat-view.ts | 31 +- .../components/chat-thread-interactions.ts | 562 +++++ .../components/chat-thread.measure.test.ts | 909 -------- ui/src/pages/chat/components/chat-thread.ts | 1984 +---------------- .../chat-transcript-controller.test.ts | 197 ++ .../components/chat-transcript-controller.ts | 670 ++++++ .../chat-transcript-invalidation.test.ts | 507 +++++ .../components/chat-transcript-projection.ts | 681 ++++++ .../components/chat-transcript-render.test.ts | 271 +++ .../chat-transcript.test-support.ts | 121 + ui/src/pages/chat/persisted-set.ts | 46 - ui/src/pages/chat/pinned-messages.ts | 30 - 23 files changed, 3064 insertions(+), 3199 deletions(-) create mode 100644 ui/src/pages/chat/components/chat-thread-interactions.ts delete mode 100644 ui/src/pages/chat/components/chat-thread.measure.test.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-controller.test.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-controller.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-invalidation.test.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-projection.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-render.test.ts create mode 100644 ui/src/pages/chat/components/chat-transcript.test-support.ts delete mode 100644 ui/src/pages/chat/persisted-set.ts delete mode 100644 ui/src/pages/chat/pinned-messages.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 27753e029aa0..ebe7313541c6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -909,7 +909,6 @@ ui/src/pages/chat/chat-view.test.ts ui/src/pages/chat/components/chat-message.test.ts ui/src/pages/chat/components/chat-session-workspace.ts ui/src/pages/chat/components/chat-sidebar.ts -ui/src/pages/chat/components/chat-thread.ts ui/src/pages/chat/components/chat-tool-cards.ts ui/src/pages/chat/composer-persistence.test.ts ui/src/pages/chat/composer-persistence.ts diff --git a/test/vitest/vitest.ui-isolated-paths.mjs b/test/vitest/vitest.ui-isolated-paths.mjs index 045f4e160d18..881ed3adc149 100644 --- a/test/vitest/vitest.ui-isolated-paths.mjs +++ b/test/vitest/vitest.ui-isolated-paths.mjs @@ -20,7 +20,9 @@ export const uiIsolatedTestFiles = [ "ui/src/pages/chat/chat-pane.read-marker.test.ts", "ui/src/pages/chat/chat-pane.session-discussion.test.ts", "ui/src/pages/chat/chat-pane.test.ts", - "ui/src/pages/chat/components/chat-thread.measure.test.ts", + "ui/src/pages/chat/components/chat-transcript-controller.test.ts", + "ui/src/pages/chat/components/chat-transcript-invalidation.test.ts", + "ui/src/pages/chat/components/chat-transcript-render.test.ts", "ui/src/pages/config/config-page.custom-theme.test.ts", "ui/src/pages/config/memory-mutation-owner.test.ts", "ui/src/pages/config/memory-page.test.ts", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fd6fc1e386ce..01268476e9ea 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5093,10 +5093,8 @@ export const en: TranslationMap = { search: "Search messages", searchPlaceholder: "Search messages...", closeSearch: "Close search", - unpin: "Unpin", loading: "Loading chat", noMatches: "No matching messages", - pinnedCount: "{count} pinned", }, pairingQrExpired: { title: "Pairing QR expired", diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 67517f87b787..46b85af406d1 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -57,7 +57,7 @@ import type { ChatPageHost } from "./chat-state-host.ts"; import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts"; import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts"; import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; -import { ChatTranscriptController } from "./components/chat-thread.ts"; +import { ChatTranscriptController } from "./components/chat-transcript-controller.ts"; import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index 2e9aec9414a5..baf9325e494c 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -22,7 +22,7 @@ import { dismissConfirmedActionPopovers, openChatRewindConfirmation, } from "./components/chat-message.ts"; -import * as chatThread from "./components/chat-thread.ts"; +import * as chatThread from "./components/chat-thread-interactions.ts"; import { prepareInitialUserMessageHandoff } from "./initial-turn-handoff.ts"; const SKIP_REWIND_CONFIRM_PREFERENCE = "openclaw:skip-rewind-confirm"; @@ -709,7 +709,7 @@ afterEach(() => { owner.remove(); } confirmationOwners.clear(); - chatThread.resetChatThreadPresentationState(); + chatThread.resetThreadPresentation(); window.localStorage.removeItem(SKIP_REWIND_CONFIRM_PREFERENCE); vi.unstubAllGlobals(); }); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index edb5269253f1..c5ccb817002d 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -602,7 +602,6 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never), resolveArtifactDownload: (params) => resolveChatArtifactDownload(state, params), basePath: state.basePath, - gatewayUrl: state.settings.gatewayUrl, }; const chat = renderChat(props); const primary = this.renderBoardPrimary(board, chat); diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.ts b/ui/src/pages/chat/chat-pane-retained-presentation.ts index 097139100e74..4be8b39a658a 100644 --- a/ui/src/pages/chat/chat-pane-retained-presentation.ts +++ b/ui/src/pages/chat/chat-pane-retained-presentation.ts @@ -12,7 +12,7 @@ import { setChatError } from "./chat-send-queue-state.ts"; import { refreshCurrentChatSessionList } from "./chat-session.ts"; import { invalidateImageLightbox } from "./chat-state-page.ts"; import { dismissConfirmedActionPopovers } from "./components/chat-message.ts"; -import { resetChatThreadSessionPresentationState } from "./components/chat-thread.ts"; +import { resetTranscriptSession } from "./components/chat-thread-interactions.ts"; import { CHAT_COMPOSER_DRAFT_STORAGE_ERROR } from "./composer-persistence.ts"; /** Owns the resources and composer state that follow one retained presentation. */ @@ -58,7 +58,7 @@ export abstract class ChatPaneRetainedPresentation extends ChatPaneBoard { this.settleResetConfirmation(false); this.cancelHeaderRename(); dismissConfirmedActionPopovers(this); - resetChatThreadSessionPresentationState(this.presentationId, this); + resetTranscriptSession(this.presentationId, this); const state = this.state; if (state) { stopChatRealtimeTalk(state); diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 2ea70f14a6bb..07aabeb88a99 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -3867,218 +3867,6 @@ describe("expansion-state render dependencies", () => { resetChatThreadState(); expect(getExpansionStateVersion(getExpandedUserMessages("reset-session"))).toBe(0); }); - - it("keeps mounted disclosure handlers attached to recreated session expansion maps", async () => { - resetChatThreadState(); - const { builtinEnvironments } = await import("vitest/runtime"); - const fixtureGlobals = ["Request", "URL", "jsdom"] as const; - const originalFixtureGlobals = fixtureGlobals.map( - (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, - ); - const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); - let environment: Awaited> | undefined; - - try { - environment = await builtinEnvironments.jsdom.setup(globalThis, { - jsdom: { url: "http://localhost/", pretendToBeVisual: true }, - }); - const [{ render }, { ChatTranscriptController, resetChatThreadPresentationState }] = - await Promise.all([import("lit"), import("./components/chat-thread.ts")]); - const host = { - addController() {}, - removeController() {}, - requestUpdate() {}, - updateComplete: Promise.resolve(true), - }; - const sessionKey = "retained-session"; - const props = { - paneId: "retained-pane", - sessionKey, - loading: false, - messages: [ - { role: "user", content: "long user message ".repeat(100), timestamp: 1 }, - { - role: "assistant", - content: [ - { type: "text", text: "assistant reply" }, - { type: "toolcall", id: "retained-call", name: "browser.open" }, - ], - timestamp: 2, - }, - ], - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - queue: [], - showThinking: false, - showToolCalls: true, - sessions: null, - assistantName: "Molty", - assistantAvatar: null, - onDraftChange() {}, - onSend() {}, - }; - const controller = new ChatTranscriptController(host); - const retainedPane = document.createElement("div"); - document.body.append(retainedPane); - render(controller.render(props), retainedPane); - const staleTools = getExpandedToolCards(sessionKey); - const staleUsers = getExpandedUserMessages(sessionKey); - const previousToolVersion = getExpansionStateVersion(staleTools); - const previousUserVersion = getExpansionStateVersion(staleUsers); - - for (let index = 0; index < 20; index += 1) { - const alternatePane = document.createElement("div"); - document.body.append(alternatePane); - render( - new ChatTranscriptController(host).render({ - ...props, - paneId: `alternate-pane-${index}`, - sessionKey: `alternate-session-${index}`, - }), - alternatePane, - ); - } - - render(controller.render(props), retainedPane); - const currentTools = getExpandedToolCards(sessionKey); - const currentUsers = getExpandedUserMessages(sessionKey); - expect(currentTools).not.toBe(staleTools); - expect(currentUsers).not.toBe(staleUsers); - expect(getExpansionStateVersion(currentTools)).toBe(previousToolVersion); - expect(getExpansionStateVersion(currentUsers)).toBe(previousUserVersion); - const toolCardId = expectDefined(currentTools.keys().next().value, "retained tool card"); - expectDefined( - retainedPane.querySelector( - ".chat-group.user .chat-message-disclosure__toggle", - ), - "mounted user disclosure", - ).click(); - expectDefined( - retainedPane.querySelector(".chat-tool-msg-summary"), - "mounted tool disclosure", - ).click(); - - expect(currentTools.get(toolCardId)).toBe(true); - expect(staleTools.get(toolCardId)).toBe(false); - expect(currentUsers.size).toBe(1); - expect(staleUsers.size).toBe(0); - - const toolVisibilitySession = "tool-visibility-session"; - const toolVisibilityProps = { - ...props, - paneId: "tool-visibility-pane", - sessionKey: toolVisibilitySession, - messages: [ - { role: "user", content: "tool visibility prompt", timestamp: 1 }, - { - role: "toolResult", - toolCallId: "expanded-tool", - toolName: "browser.open", - content: "Expanded tool result", - timestamp: 2, - }, - { role: "assistant", content: "The first tool completed.", timestamp: 3 }, - { role: "user", content: "Show the next tool result.", timestamp: 4 }, - { - role: "toolResult", - toolCallId: "collapsed-tool", - toolName: "browser.open", - content: "Collapsed tool result", - timestamp: 5, - }, - ], - }; - const toolVisibilityController = new ChatTranscriptController(host); - const toolVisibilityPane = document.createElement("div"); - document.body.append(toolVisibilityPane); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - const visibilityState = getExpandedToolCards(toolVisibilitySession); - const visibilityIds = [...visibilityState.keys()].filter((key) => key.startsWith("toolmsg:")); - const expandedToolId = expectDefined(visibilityIds[0], "expanded standalone tool disclosure"); - const collapsedToolId = expectDefined( - visibilityIds[1], - "collapsed standalone tool disclosure", - ); - const disclosureButtons = () => - Array.from( - toolVisibilityPane.querySelectorAll(".chat-tool-msg-summary"), - ).filter((button) => !button.closest(".chat-tool-msg-body")); - expect(disclosureButtons()).toHaveLength(2); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "false", - "false", - ]); - expectDefined(disclosureButtons()[0], "first mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "true", - "false", - ]); - - render( - toolVisibilityController.render({ ...toolVisibilityProps, showToolCalls: false }), - toolVisibilityPane, - ); - expect(disclosureButtons()).toHaveLength(0); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - - expect(disclosureButtons()).toHaveLength(2); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "true", - "false", - ]); - expect(visibilityState.get(expandedToolId)).toBe(true); - expect(visibilityState.get(collapsedToolId)).toBe(false); - render( - toolVisibilityController.render({ - ...toolVisibilityProps, - messages: toolVisibilityProps.messages.filter( - (message) => !("toolCallId" in message && message.toolCallId === "expanded-tool"), - ), - }), - toolVisibilityPane, - ); - expect(visibilityState.has(expandedToolId)).toBe(false); - expect(visibilityState.get(collapsedToolId)).toBe(false); - resetChatThreadPresentationState(); - } finally { - try { - if (environment) { - try { - document.body.replaceChildren(); - await new Promise((resolve) => { - window.setTimeout(resolve, 0); - }); - } finally { - await environment.teardown(globalThis); - } - } - } finally { - // Vitest assigns these compatibility globals after its own restore snapshot. - for (const [name, descriptor] of originalFixtureGlobals) { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - } else { - Reflect.deleteProperty(globalThis, name); - } - } - resetChatThreadState(); - } - } - - for (const [name, descriptor] of originalFixtureGlobals) { - expect(Object.getOwnPropertyDescriptor(globalThis, name)).toEqual(descriptor); - } - expect(Object.getOwnPropertyDescriptor(globalThis, "document")).toEqual(originalDocument); - expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toEqual(originalWindow); - }); }); describe("user message expansion state", () => { diff --git a/ui/src/pages/chat/chat-view-state.ts b/ui/src/pages/chat/chat-view-state.ts index 5bc383d39c6a..d3143c890046 100644 --- a/ui/src/pages/chat/chat-view-state.ts +++ b/ui/src/pages/chat/chat-view-state.ts @@ -1,7 +1,7 @@ import { resetChatComposerState } from "./components/chat-composer.ts"; -import { resetChatThreadPresentationState } from "./components/chat-thread.ts"; +import { resetThreadPresentation } from "./components/chat-thread-interactions.ts"; export function resetChatViewState(paneId?: string, owner?: ParentNode) { resetChatComposerState(paneId); - resetChatThreadPresentationState(paneId, owner); + resetThreadPresentation(paneId, owner); } diff --git a/ui/src/pages/chat/chat-view.test-helpers.ts b/ui/src/pages/chat/chat-view.test-helpers.ts index 72a095f02164..24d9f776c997 100644 --- a/ui/src/pages/chat/chat-view.test-helpers.ts +++ b/ui/src/pages/chat/chat-view.test-helpers.ts @@ -1,6 +1,6 @@ import type { ReactiveControllerHost } from "lit"; import { vi } from "vitest"; -import { ChatTranscriptController } from "./components/chat-thread.ts"; +import { ChatTranscriptController } from "./components/chat-transcript-controller.ts"; export function createTestTranscript(): ChatTranscriptController { return new ChatTranscriptController({ diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 27934d1638b1..6b77c2c95d7a 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -52,10 +52,10 @@ import * as chatMessage from "./components/chat-message.ts"; import { renderChatModelControls } from "./components/chat-model-controls.ts"; import { ChatSessionRailElement } from "./components/chat-session-rail.ts"; import { - resetChatThreadPresentationState, - resetChatThreadSessionPresentationState, - toggleChatThreadSearch, -} from "./components/chat-thread.ts"; + resetThreadPresentation, + resetTranscriptSession, + toggleTranscriptSearch, +} from "./components/chat-thread-interactions.ts"; import { renderWelcomeState } from "./components/chat-welcome.ts"; import { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts"; import { @@ -2141,14 +2141,14 @@ describe("per-pane chat presentation state", () => { renderChatInto(container, { paneId, draft, getDraft: () => draft }); }; - toggleChatThreadSearch("pane-a", vi.fn()); + toggleTranscriptSearch("pane-a", vi.fn()); renderPane(paneA, "pane-a", ""); renderPane(paneB, "pane-b", ""); expect(paneA.querySelector(".agent-chat__search-bar")).not.toBeNull(); expect(paneB.querySelector(".agent-chat__search-bar")).toBeNull(); - toggleChatThreadSearch("pane-b", vi.fn()); - resetChatThreadSessionPresentationState("pane-a"); + toggleTranscriptSearch("pane-b", vi.fn()); + resetTranscriptSession("pane-a"); renderPane(paneA, "pane-a", ""); renderPane(paneB, "pane-b", ""); expect(paneA.querySelector(".agent-chat__search-bar")).toBeNull(); @@ -6848,11 +6848,11 @@ describe("right-click Reply", () => { .click(); flushFrames(); - resetChatThreadPresentationState("pane-b"); + resetThreadPresentation("pane-b"); expect(document.querySelector(".chat-reply-context-menu")).not.toBeNull(); expect(document.querySelector(".chat-confirm-popover")).not.toBeNull(); - resetChatThreadPresentationState("pane-a"); + resetThreadPresentation("pane-a"); expect(document.querySelector(".chat-reply-context-menu")).toBeNull(); expect(document.querySelector(".chat-confirm-popover")).toBeNull(); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 257e5acfd9b3..534e8f89da43 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -57,13 +57,13 @@ import type { SidebarContent, SidebarFullMessageLoader } from "./components/chat import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts"; import { renderChatTaskSuggestionTray } from "./components/chat-task-suggestions.ts"; import type { ChatTaskSuggestionTrayProps } from "./components/chat-task-suggestions.ts"; -import type { ChatReplyMessageAccess, ChatTranscriptController } from "./components/chat-thread.ts"; +import type { ReplyMessageAccess } from "./components/chat-thread-interactions.ts"; import { - renderChatPinnedMessages, - renderChatSearchBar, - renderChatThread, - toggleChatThreadSearch, -} from "./components/chat-thread.ts"; + renderTranscriptSearch, + toggleTranscriptSearch, +} from "./components/chat-thread-interactions.ts"; +import { renderChatThread } from "./components/chat-thread.ts"; +import type { ChatTranscriptController } from "./components/chat-transcript-controller.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts"; import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation.ts"; import type { RealtimeTalkCameraDevice } from "./realtime-talk-input.ts"; @@ -256,12 +256,11 @@ export type ChatProps = ChatTaskSuggestionTrayProps & onRevealWorkspaceFile?: (path: string) => void; onChatScroll?: (event: Event) => void; basePath?: string; - gatewayUrl?: string; composerControls?: TemplateResult | typeof nothing; replyTarget?: ChatReplyTarget | null; onClearReply?: () => void; onSetReply?: (target: ChatReplyTarget) => void; - replyMessageAccess?: ChatReplyMessageAccess; + replyMessageAccess?: ReplyMessageAccess; onRewindMessage?: (entryId: string) => Promise | boolean; onForkMessage?: (entryId: string) => Promise | void; sessionWorkspace?: SessionWorkspaceProps; @@ -348,7 +347,6 @@ export function renderChat(props: ChatProps) { questionPrompts: props.gatewayQuestionPrompts, sessions: props.sessions, sessionHost: props.sessionHost, - gatewayUrl: props.gatewayUrl, boardProvider: props.boardProvider, assistantName: props.assistantName, assistantAvatar: props.assistantAvatar, @@ -422,7 +420,6 @@ export function renderChat(props: ChatProps) { persistCommentary: props.persistCommentary, sessions: props.sessions, sessionHost: props.sessionHost, - gatewayUrl: props.gatewayUrl, assistantName: props.assistantName, assistantAvatar: props.assistantAvatar, assistantAvatarUrl: props.assistantAvatarUrl, @@ -582,21 +579,11 @@ export function renderChat(props: ChatProps) { resolveAsciiShortcutKey(event) === "f" ) { event.preventDefault(); - toggleChatThreadSearch(props.paneId, requestUpdate, event); + toggleTranscriptSearch(props.paneId, requestUpdate, event); } }} > - ${renderChatViewNotices(props)} ${renderChatSearchBar(props.paneId, requestUpdate)} - ${renderChatPinnedMessages( - { - paneId: props.paneId, - sessionKey: props.sessionKey, - messages: props.messages, - userName: props.userName, - userAvatar: props.userAvatar, - }, - requestUpdate, - )} + ${renderChatViewNotices(props)} ${renderTranscriptSearch(props.paneId, requestUpdate)}
void; + onOpenReply?: (replyToId: string) => void; + }; +}; + +export type ReplyMessageAccess = { + revision: number; + navigationId: string | null; + read: (messageId: string) => unknown; + request: (messageId: string) => void; + open: (messageId: string) => void; +}; + +export type ChatThreadProps = { + paneId: string; + sessionKey: string; + boardProvider?: BoardProvider; + announceTranscript?: boolean; + loading: boolean; + historyPagination?: { loading: boolean }; + messages: unknown[]; + toolMessages: unknown[]; + streamSegments: ChatStreamSegment[]; + stream: string | null; + streamStartedAt: number | null; + runId?: string | null; + runOutputTokens?: number | null; + queue: ChatQueueItem[]; + showThinking: boolean; + showToolCalls: boolean; + persistCommentary?: boolean; + runActive?: boolean; + runWorking?: boolean; + startupStatus?: ChatRunStartupStatus | null; + waitingApproval?: boolean; + planStatus?: PlanStatus | null; + questionPrompts?: readonly QuestionPrompt[]; + sessions: SessionsListResult | null; + sessionHost?: UiSessionDefaultsHost | null; + assistantName: string; + assistantAvatar: string | null; + assistantAvatarUrl?: string | null; + userId?: string | null; + userName?: string | null; + userAvatar?: string | null; + basePath?: string; + fullMessageAgentId?: string; + loadFullAssistantMessage?: SidebarFullMessageLoader | null; + localMediaPreviewRoots?: string[]; + assistantAttachmentAuthToken?: string | null; + resolveArtifactDownload?: ArtifactDownloadResolver; + canvasPluginSurfaceUrl?: string | null; + embedSandboxMode?: EmbedSandboxMode; + allowExternalEmbedUrls?: boolean; + autoExpandToolCalls?: boolean; + realtimeTalkConversation?: RealtimeTalkConversationEntry[]; + onOpenSidebar?: (content: SidebarContent) => void; + onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void; + onOpenSessionCheckpoints?: () => void | Promise; + onAssistantAttachmentLoaded?: () => void; + onRequestOpenImage?: () => number; + onOpenImage?: (item: ImageLightboxItem, requestVersion?: number) => void; + onRequestUpdate?: () => void; + onChatScroll?: (event: Event) => void; + onHistoryIntent?: (event: Event) => void; + onDraftChange: (next: string) => void; + onSend: () => void; + onSetReply?: (target: MessageReplyTarget) => void; + replyMessageAccess?: ReplyMessageAccess; + onRewindMessage?: (entryId: string) => Promise | boolean; + onForkMessage?: (entryId: string) => Promise | void; + onFocusComposer?: () => void; + onCompanionQuestion?: (question: string) => void; + onCompanionPrefill?: (question: string) => void; + onOpenSession?: (sessionKey: string) => void; + modelSetupRequired?: boolean; + onModelSetup?: () => void; + backgroundTasks?: BackgroundTasksProps; +}; + +type TranscriptInteractionProps = Pick< + ChatThreadProps, + | "paneId" + | "runActive" + | "runWorking" + | "onSetReply" + | "onRewindMessage" + | "onForkMessage" + | "onFocusComposer" + | "onCompanionQuestion" + | "onCompanionPrefill" +>; + +function createTranscriptState(): ChatThreadState { + return { + searchOpen: false, + searchQuery: "", + searchFocusPending: false, + searchReturnFocusTarget: null, + searchReturnFocusOwner: null, + transcriptRenderDependencies: [], + transcriptRenderContext: {}, + }; +} + +const transcriptStates = new Map(); + +export function getTranscriptState(paneId: string): ChatThreadState { + const existing = transcriptStates.get(paneId); + if (existing) { + return existing; + } + const state = createTranscriptState(); + transcriptStates.set(paneId, state); + return state; +} + +function dismissThreadPortals(paneId?: string, owner?: ParentNode): void { + removeReplyContextMenu(paneId); + if (owner) { + dismissConfirmedActionPopovers(owner); + } + // The selection popup is body-portaled; pane teardown/route changes must + // drop it so it cannot outlive the render that owns its callbacks. + removeChatSelectionPopup(); +} + +export function resetTranscriptSession(paneId: string, owner?: ParentNode): void { + dismissThreadPortals(paneId, owner); + const state = transcriptStates.get(paneId); + if (state) { + // Search input belongs to the outgoing transcript. Other fields are pane + // preferences or dependency memos and invalidate themselves on new props. + state.searchOpen = false; + state.searchQuery = ""; + state.searchFocusPending = false; + state.searchReturnFocusTarget = null; + state.searchReturnFocusOwner = null; + } +} + +export function resetThreadPresentation(paneId?: string, owner?: ParentNode) { + dismissThreadPortals(paneId, owner); + if (paneId) { + transcriptStates.delete(paneId); + resetChatThreadState(paneId); + } else { + transcriptStates.clear(); + resetChatThreadState(); + } +} + +export function renderTranscriptSearch( + paneId: string, + requestUpdate: () => void, +): TemplateResult | typeof nothing { + const state = getTranscriptState(paneId); + if (!state.searchOpen) { + return nothing; + } + return html` + + `; +} + +export function closeTranscriptSearch(state: ChatThreadState, requestUpdate: () => void): void { + const returnFocusTarget = state.searchReturnFocusTarget; + const returnFocusOwner = state.searchReturnFocusOwner; + state.searchOpen = false; + state.searchQuery = ""; + state.searchFocusPending = false; + state.searchReturnFocusTarget = null; + state.searchReturnFocusOwner = null; + requestUpdate(); + queueMicrotask(() => { + const target = returnFocusTarget?.isConnected + ? returnFocusTarget + : returnFocusOwner?.querySelector( + ".agent-chat__composer-combobox > textarea", + ); + target?.focus({ preventScroll: true }); + }); +} + +/** Toggles transcript search and retains the shortcut origin for focus restoration. */ +export function toggleTranscriptSearch( + paneId: string, + requestUpdate: () => void, + triggerEvent?: Event, +): void { + const state = getTranscriptState(paneId); + if (state.searchOpen) { + closeTranscriptSearch(state, requestUpdate); + return; + } + + state.searchOpen = true; + state.searchFocusPending = true; + const returnFocusTarget = triggerEvent?.target; + const returnFocusOwner = triggerEvent?.currentTarget; + state.searchReturnFocusTarget = + returnFocusTarget instanceof HTMLElement && returnFocusTarget.isConnected + ? returnFocusTarget + : null; + state.searchReturnFocusOwner = + returnFocusOwner instanceof HTMLElement && returnFocusOwner.isConnected + ? returnFocusOwner + : null; + requestUpdate(); +} + +let activeReplyContextMenu: HTMLElement | null = null; +let activeReplyContextMenuPaneId: string | null = null; +let contextMenuDocumentClickHandler: ((event: MouseEvent) => void) | null = null; +let contextMenuDocumentContextMenuHandler: ((event: MouseEvent) => void) | null = null; +let contextMenuKeydownHandler: ((event: KeyboardEvent) => void) | null = null; + +function removeReplyContextMenu(paneId?: string) { + if (paneId && paneId !== activeReplyContextMenuPaneId) { + return; + } + if (activeReplyContextMenu) { + dismissConfirmedActionPopovers(activeReplyContextMenu); + activeReplyContextMenu.remove(); + } + activeReplyContextMenu = null; + activeReplyContextMenuPaneId = null; + const fallbackMenu = document.querySelector(".chat-reply-context-menu"); + if (fallbackMenu) { + dismissConfirmedActionPopovers(fallbackMenu); + fallbackMenu.remove(); + } + if (contextMenuDocumentClickHandler) { + document.removeEventListener("click", contextMenuDocumentClickHandler); + contextMenuDocumentClickHandler = null; + } + if (contextMenuDocumentContextMenuHandler) { + document.removeEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true); + contextMenuDocumentContextMenuHandler = null; + } + if (contextMenuKeydownHandler) { + document.removeEventListener("keydown", contextMenuKeydownHandler); + contextMenuKeydownHandler = null; + } +} + +function stableReplyMessageId(senderLabel: string | undefined, text: string): string { + const source = `${senderLabel ?? ""}\n${text}`; + return `reply:${fnv1aUtf16(source).toString(16)}`; +} + +function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.setAttribute("aria-label", t("chat.messages.replyToMessage")); + button.textContent = t("chat.messages.reply"); + button.addEventListener("click", onClick); + return button; +} + +function createMessageActionContextButton(params: { + label: string; + disabled: boolean; + tooltip: string; + onClick: () => void; +}): { element: HTMLElement; button: HTMLButtonElement } { + const button = document.createElement("button"); + button.type = "button"; + button.disabled = params.disabled; + button.setAttribute("role", "menuitem"); + button.setAttribute("aria-label", params.label); + button.textContent = params.label; + button.addEventListener("click", params.onClick); + const tooltip = document.createElement("openclaw-tooltip"); + tooltip.content = params.tooltip; + tooltip.append(button); + return { element: tooltip, button }; +} + +export function handleTranscriptSelection(event: PointerEvent, props: TranscriptInteractionProps) { + if ( + typeof props.onCompanionQuestion !== "function" || + typeof props.onCompanionPrefill !== "function" + ) { + return; + } + handleChatSelectionPointerUp(event, { + onMoreDetails: (selection) => { + const question = buildMoreDetailsCompanionQuestion(selection); + if (question) { + props.onCompanionQuestion?.(question); + } + }, + onAskSideChat: (selection) => { + const question = buildCompanionQuestionPrefill(selection); + if (question) { + props.onCompanionPrefill?.(question); + } + }, + }); +} + +function selectionIntersectsElement(selection: Selection | null, element: Element): boolean { + if (!selection || selection.isCollapsed) { + return false; + } + for (let index = 0; index < selection.rangeCount; index += 1) { + if (selection.getRangeAt(index).intersectsNode(element)) { + return true; + } + } + return false; +} + +export function handleTranscriptContextMenu(event: MouseEvent, props: TranscriptInteractionProps) { + if (event.composedPath().some((target) => target instanceof HTMLAnchorElement)) { + return; + } + const bubble = (event.target as HTMLElement).closest(".chat-bubble"); + if (!bubble) { + return; + } + const group = bubble.closest(".chat-group"); + if (!group) { + return; + } + if ( + group.querySelector(".chat-reading-indicator") || + group.querySelector(".chat-bubble.streaming") + ) { + return; + } + const senderEl = group.querySelector(".chat-sender-name"); + const senderLabel = senderEl?.textContent?.trim() ?? undefined; + const text = truncateUtf16Safe((bubble as HTMLElement).dataset.messageText?.trim() ?? "", 500); + const entryId = (bubble as HTMLElement).dataset.entryId?.trim() ?? ""; + const messageId = (bubble as HTMLElement).dataset.messageId?.trim() ?? ""; + const isUserMessage = group.classList.contains("user") && Boolean(entryId); + // Grouped rows can contain several bubbles. Match the clicked bubble to its + // own action owner so copy never targets a sibling message. + const actionOwner = [...group.querySelectorAll("[data-message-actions-for]")].find( + (element) => element.dataset.messageActionsFor === messageId, + ); + const copyButton = actionOwner?.querySelector(".chat-copy-btn"); + const canReply = Boolean(text && props.onSetReply); + const canRewind = isUserMessage && typeof props.onRewindMessage === "function"; + const canCopy = Boolean(copyButton); + const canFork = isUserMessage && typeof props.onForkMessage === "function"; + if (!canReply && !canRewind && !canCopy && !canFork) { + return; + } + + const selection = window.getSelection(); + const selectedText = selectionIntersectsElement(selection, bubble) ? selection?.toString() : ""; + + event.preventDefault(); + event.stopPropagation(); + removeReplyContextMenu(); + const menu = document.createElement("div"); + menu.className = "chat-reply-context-menu"; + menu.setAttribute("role", "menu"); + menu.setAttribute("aria-label", t("chat.messages.actions")); + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + const focusCandidates: HTMLButtonElement[] = []; + if (selectedText) { + const action = createMessageActionContextButton({ + label: t("chat.messages.copySelection"), + disabled: false, + tooltip: t("chat.messages.copySelection"), + onClick: () => { + void copyToClipboard(selectedText); + removeReplyContextMenu(); + }, + }); + menu.append(action.element); + focusCandidates.push(action.button); + } + if (canReply) { + const replyMessageId = messageId || stableReplyMessageId(senderLabel, text); + const replyButton = createReplyContextMenuButton(() => { + props.onSetReply?.({ + messageId: replyMessageId, + text, + senderLabel, + ...(entryId ? { sourceMessageId: entryId } : {}), + }); + removeReplyContextMenu(); + props.onFocusComposer?.(); + }); + menu.append(replyButton); + focusCandidates.push(replyButton); + } + const working = Boolean(props.runActive || props.runWorking); + if (canRewind) { + const action = createMessageActionContextButton({ + label: t("chat.messages.rewindToHere"), + disabled: working, + tooltip: working ? t("chat.messages.rewindUnavailable") : t("chat.messages.rewindToHere"), + onClick: () => { + openChatRewindConfirmation(action.button, () => { + removeReplyContextMenu(); + void Promise.resolve(props.onRewindMessage?.(entryId)).then((rewound) => { + if (rewound) { + props.onFocusComposer?.(); + } + }); + }); + }, + }); + action.element.classList.add("chat-confirm-wrap", "chat-rewind-wrap"); + menu.append(action.element); + focusCandidates.push(action.button); + } + if (canCopy) { + const action = createMessageActionContextButton({ + label: copyMarkdownLabel(), + disabled: false, + tooltip: copyMarkdownLabel(), + onClick: () => { + removeReplyContextMenu(); + copyButton?.click(); + }, + }); + menu.append(action.element); + focusCandidates.push(action.button); + } + if (canFork) { + const action = createMessageActionContextButton({ + label: t("chat.messages.forkFromHere"), + disabled: working, + tooltip: working ? t("chat.messages.forkUnavailable") : t("chat.messages.forkFromHere"), + onClick: () => { + removeReplyContextMenu(); + void props.onForkMessage?.(entryId); + }, + }); + menu.append(action.element); + focusCandidates.push(action.button); + } + document.body.appendChild(menu); + activeReplyContextMenu = menu; + activeReplyContextMenuPaneId = props.paneId; + + const menuRect = menu.getBoundingClientRect(); + let left = event.clientX; + let top = event.clientY; + if (left + menuRect.width > window.innerWidth) { + left = window.innerWidth - menuRect.width - 8; + } + if (top + menuRect.height > window.innerHeight) { + top = window.innerHeight - menuRect.height - 8; + } + menu.style.left = `${Math.max(0, left)}px`; + menu.style.top = `${Math.max(0, top)}px`; + focusCandidates.find((button) => !button.disabled)?.focus(); + requestAnimationFrame(() => { + if (!menu.isConnected || activeReplyContextMenu !== menu) { + return; + } + contextMenuDocumentClickHandler = (nextEvent: MouseEvent) => { + if (!menu.contains(nextEvent.target as Node | null)) { + removeReplyContextMenu(); + } + }; + contextMenuDocumentContextMenuHandler = (nextEvent: MouseEvent) => { + if (!menu.contains(nextEvent.target as Node | null)) { + removeReplyContextMenu(); + } + }; + const handleKeydown = (nextEvent: KeyboardEvent) => { + if (nextEvent.key === "Escape") { + nextEvent.preventDefault(); + nextEvent.stopPropagation(); + removeReplyContextMenu(); + props.onFocusComposer?.(); + } + }; + contextMenuKeydownHandler = handleKeydown; + document.addEventListener("click", contextMenuDocumentClickHandler); + // Capture closes this owner even when the next menu stops event propagation. + document.addEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true); + document.addEventListener("keydown", handleKeydown); + }); +} diff --git a/ui/src/pages/chat/components/chat-thread.measure.test.ts b/ui/src/pages/chat/components/chat-thread.measure.test.ts deleted file mode 100644 index ccf2d51c3e31..000000000000 --- a/ui/src/pages/chat/components/chat-thread.measure.test.ts +++ /dev/null @@ -1,909 +0,0 @@ -/* @vitest-environment jsdom */ - -// Regression: re-stamping the transcript into a new container (the -// chat<->dashboard face switch) must keep every rendered row observed for -// size changes. A synchronous measureElement(null) prune during the commit -// unobserved just-registered sibling rows, freezing their heights at the old -// pane width and overlapping the bubbles in the dashboard chat dock. -import { render } from "lit"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { BoardProvider } from "../../../lib/board/provider.ts"; -import { resolveAssistantAttachmentAuthToken } from "../chat-pane-state.ts"; -import { createTestChatPane } from "../chat-pane.test-support.ts"; -import * as chatThreadBuild from "../chat-thread-build.ts"; -import { buildCachedChatItems, resetChatThreadState } from "../chat-thread.ts"; -import { createTestTranscript } from "../chat-view.test-helpers.ts"; -import { - isChatMediaResourceCurrent, - observeChatMediaResource, - releaseChatMediaResourceSubscriber, -} from "./chat-message-media.ts"; -import { - renderChatThread, - renderChatSearchBar, - resetChatThreadPresentationState, - resetChatThreadSessionPresentationState, - toggleChatThreadSearch, -} from "./chat-thread.ts"; - -const observedElements = new Set(); -const resizeObservers = new Set(); -let measuredRowHeight = 100; - -class RecordingResizeObserver implements ResizeObserver { - private readonly targets = new Set(); - constructor(private readonly callback: ResizeObserverCallback) { - resizeObservers.add(this); - } - observe(target: Element): void { - this.targets.add(target); - observedElements.add(target); - } - unobserve(target: Element): void { - this.targets.delete(target); - observedElements.delete(target); - } - disconnect(): void { - for (const target of this.targets) { - observedElements.delete(target); - } - this.targets.clear(); - resizeObservers.delete(this); - } - emit(width: number, height: number): void { - const entries = [...this.targets].map( - (target) => - ({ - target, - borderBoxSize: [{ inlineSize: width, blockSize: height }], - }) as unknown as ResizeObserverEntry, - ); - if (entries.length > 0) { - this.callback(entries, this); - } - } - - observes(target: Element): boolean { - return this.targets.has(target); - } -} - -const defaultMessages = [ - { role: "user", content: "message one", timestamp: 1_000 }, - { role: "assistant", content: "reply one", timestamp: 2_000 }, - { role: "user", content: "message two", timestamp: 3_000 }, - { role: "assistant", content: "reply two", timestamp: 4_000 }, -]; - -function threadProps( - paneId: string, - sessionKey = "agent:main:main", - messages: unknown[] = defaultMessages, -) { - return { - paneId, - sessionKey, - loading: false, - messages, - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - queue: [], - showThinking: false, - showToolCalls: false, - sessions: null, - assistantName: "Molty", - assistantAvatar: null, - onDraftChange: () => {}, - onSend: () => {}, - }; -} - -function transcriptRows(container: HTMLElement): HTMLElement[] { - return [...container.querySelectorAll(".chat-virtual-row")]; -} - -async function flushDeferredRowPrune(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); -} - -describe("chat transcript row measurement", () => { - beforeEach(() => { - observedElements.clear(); - resizeObservers.clear(); - measuredRowHeight = 100; - vi.stubGlobal("ResizeObserver", RecordingResizeObserver); - // jsdom reports 0x0 rects and offsetHeight 0; keep the virtualizer - // viewport and measured row sizes non-zero so re-renders keep producing - // virtual rows. - vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( - () => measuredRowHeight, - ); - vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - top: 0, - left: 0, - right: 800, - bottom: 600, - width: 800, - height: 600, - toJSON: () => ({}), - } as DOMRect); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - resetChatThreadPresentationState(); - resetChatThreadState(); - document.body.replaceChildren(); - }); - - it("keeps every re-stamped row observed after moving containers", async () => { - const transcript = createTestTranscript(); - const props = threadProps("pane-measure"); - const chatFace = document.body.appendChild(document.createElement("div")); - render(renderChatThread(props, transcript), chatFace); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const chatRows = transcriptRows(chatFace); - expect(chatRows.length).toBeGreaterThanOrEqual(4); - for (const row of chatRows) { - expect(observedElements.has(row)).toBe(true); - } - - // Re-stamp the same session transcript into a new container while the old - // tree is still tracked, mirroring the dashboard face-switch commit. - const dashboardDock = document.body.appendChild(document.createElement("div")); - render(renderChatThread(props, transcript), dashboardDock); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const dockRows = transcriptRows(dashboardDock); - expect(dockRows.length).toBe(chatRows.length); - for (const row of dockRows) { - expect(observedElements.has(row)).toBe(true); - } - for (const row of chatRows) { - expect(observedElements.has(row)).toBe(false); - } - }); - - it("resolves persisted replies to their source and highlights it on click", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const props = threadProps("pane-reply-preview", "agent:main:main", [ - { - role: "assistant", - content: "The original answer", - __openclaw: { id: "source-message" }, - timestamp: 1_000, - }, - { - role: "user", - content: "Follow up", - __openclaw: { id: "reply-message", replyToId: "source-message" }, - timestamp: 2_000, - }, - ]); - render(renderChatThread(props, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const preview = container.querySelector(".chat-reply-preview--message"); - expect(preview?.textContent).toContain("Replying to Molty"); - expect(preview?.textContent).toContain("The original answer"); - expect(preview?.textContent).not.toContain("source-message"); - - preview?.click(); - await Promise.resolve(); - - const sourceBubble = [...container.querySelectorAll(".chat-bubble")].find( - (bubble) => bubble.dataset.entryId === "source-message", - ); - expect(sourceBubble?.classList.contains("chat-bubble--reply-target")).toBe(true); - transcript.hostDisconnected(); - }); - - it("hydrates an unloaded reply preview without inserting its source row", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - let resolvedMessage: unknown = undefined; - const request = vi.fn(); - const open = vi.fn(); - const props = { - ...threadProps("pane-reply-hydration", "agent:main:main", [ - { - role: "user", - content: "Follow up", - __openclaw: { id: "reply-message", replyToId: "source-message" }, - timestamp: 2_000, - }, - ]), - replyMessageAccess: { - revision: 0, - navigationId: null, - read: () => resolvedMessage, - request, - open, - }, - }; - const rerender = () => { - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - }; - rerender(); - transcript.hostConnected(); - await flushDeferredRowPrune(); - - expect(request).toHaveBeenCalledWith("source-message"); - expect(container.querySelector("[data-entry-id='source-message']")).toBeNull(); - - resolvedMessage = { - role: "assistant", - content: "The original answer", - __openclaw: { id: "source-message" }, - timestamp: 1_000, - }; - props.replyMessageAccess.revision += 1; - rerender(); - - const preview = container.querySelector(".chat-reply-preview--message"); - expect(preview?.textContent).toContain("Replying to Molty"); - expect(preview?.textContent).toContain("The original answer"); - preview?.click(); - expect(open).toHaveBeenCalledWith("source-message"); - transcript.hostDisconnected(); - }); - - it("clears search before navigating to a filtered reply target", async () => { - const transcript = createTestTranscript(); - const searchContainer = document.body.appendChild(document.createElement("div")); - const threadContainer = document.body.appendChild(document.createElement("div")); - const open = vi.fn(); - const paneId = "pane-filtered-reply-navigation"; - const props = { - ...threadProps(paneId, "agent:main:main", [ - { - role: "assistant", - content: "The original answer", - __openclaw: { id: "source-message" }, - timestamp: 1_000, - }, - { - role: "user", - content: "Follow up", - __openclaw: { - id: "reply-message", - replyToId: "source-message", - replyToPreview: { text: "The original answer", senderLabel: "Molty" }, - }, - timestamp: 2_000, - }, - ]), - replyMessageAccess: { - revision: 0, - navigationId: null, - read: () => undefined, - request: vi.fn(), - open, - }, - }; - const rerender = () => { - render(renderChatSearchBar(paneId, rerender), searchContainer); - render( - renderChatThread({ ...props, onRequestUpdate: rerender }, transcript), - threadContainer, - ); - transcript.hostUpdated(); - }; - toggleChatThreadSearch(paneId, rerender); - rerender(); - transcript.hostConnected(); - const input = searchContainer.querySelector("input"); - expect(input).not.toBeNull(); - input!.value = "Follow up"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - await flushDeferredRowPrune(); - - expect(threadContainer.querySelector("[data-entry-id='source-message']")).toBeNull(); - const preview = threadContainer.querySelector( - ".chat-reply-preview--message", - ); - expect(preview).not.toBeNull(); - preview!.click(); - - expect(open).toHaveBeenCalledWith("source-message"); - expect(searchContainer.querySelector("input")).toBeNull(); - transcript.hostDisconnected(); - }); - - it("loads a truncated assistant message once and keeps the full text visible", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const loadFullAssistantMessage = vi.fn().mockResolvedValue({ - ok: true, - message: { role: "assistant", content: "Complete assistant content." }, - }); - function rerender() { - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - } - const props = { - ...threadProps("pane-assistant-expand", "agent:work:main", [ - { - role: "assistant", - content: "Preview\n...(truncated)...", - __openclaw: { id: "assistant-full-1" }, - timestamp: 1_000, - }, - ]), - fullMessageAgentId: "work", - loadFullAssistantMessage, - onRequestUpdate: rerender, - }; - rerender(); - transcript.hostConnected(); - transcript.hostUpdated(); - - await vi.waitFor(() => expect(container.textContent).toContain("Complete assistant content.")); - expect(loadFullAssistantMessage).toHaveBeenCalledOnce(); - expect(loadFullAssistantMessage).toHaveBeenCalledWith({ - sessionKey: "agent:work:main", - agentId: "work", - messageId: "assistant-full-1", - kind: "assistant_message", - }); - - expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull(); - expect(container.textContent).toContain("Complete assistant content."); - expect(loadFullAssistantMessage).toHaveBeenCalledOnce(); - transcript.hostDisconnected(); - }); - - it("keeps transport-cut assistant text as received when full content is unavailable", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const loadFullAssistantMessage = vi.fn().mockRejectedValue(new Error("offline")); - function rerender() { - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - } - const props = { - ...threadProps("pane-assistant-retry", "agent:main:main", [ - { - role: "assistant", - content: "Preview\n...(truncated)...", - __openclaw: { id: "assistant-retry-1" }, - timestamp: 1_000, - }, - ]), - loadFullAssistantMessage, - onRequestUpdate: rerender, - }; - rerender(); - transcript.hostConnected(); - transcript.hostUpdated(); - - await vi.waitFor(() => expect(loadFullAssistantMessage).toHaveBeenCalledOnce()); - expect(container.textContent).toContain("Preview"); - expect(container.textContent).toContain("...(truncated)..."); - expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull(); - transcript.hostDisconnected(); - }); - - it.each(["Enter", " "])("opens focused transcript file links with %j", async (key) => { - const transcript = createTestTranscript(); - const onOpenWorkspaceFile = vi.fn(); - const onHistoryIntent = vi.fn(); - const container = document.body.appendChild(document.createElement("div")); - const props = { - ...threadProps("pane-file-link", "agent:main:main", [ - { role: "assistant", content: "Inspect `src/chat.ts:17`", timestamp: 1_000 }, - ]), - onOpenWorkspaceFile, - onHistoryIntent, - }; - render(renderChatThread(props, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const link = container.querySelector("a.markdown-file-link"); - link?.focus(); - expect(document.activeElement).toBe(link); - const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); - link?.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(onOpenWorkspaceFile).toHaveBeenCalledWith({ path: "src/chat.ts", line: 17 }); - expect(onHistoryIntent).not.toHaveBeenCalled(); - transcript.hostDisconnected(); - }); - - it("keeps built row identities across an A to B to A presentation reset", () => { - const paneId = "pane-session-items"; - const messagesA = [{ role: "assistant", content: "session A", timestamp: 1_000 }]; - const messagesB = [{ role: "assistant", content: "session B", timestamp: 2_000 }]; - const stableInputs = { - paneId, - runId: null, - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - showToolCalls: true, - }; - const buildSpy = vi.spyOn(chatThreadBuild, "buildChatItems"); - const itemsA = buildCachedChatItems({ - ...stableInputs, - sessionKey: "agent:main:session-a", - messages: messagesA, - }); - - resetChatThreadSessionPresentationState(paneId); - buildCachedChatItems({ - ...stableInputs, - sessionKey: "agent:main:session-b", - messages: messagesB, - }); - resetChatThreadSessionPresentationState(paneId); - const restoredItemsA = buildCachedChatItems({ - ...stableInputs, - sessionKey: "agent:main:session-a", - messages: messagesA, - }); - - expect(buildSpy).toHaveBeenCalledTimes(2); - expect(restoredItemsA).toBe(itemsA); - expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true); - }); - - it("pauses an unmeasurable restore until loading commits an empty transcript", () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const props = threadProps("pane-loading-scroll", "agent:main:session-a", []); - render(renderChatThread({ ...props, loading: true }, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - transcript.scrollToOffset(420); - transcript.hostUpdated(); - - expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBe(420); - - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull(); - }); - - it("settles a restored offset when loaded rows no longer overflow", () => { - const frames: FrameRequestCallback[] = []; - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - frames.push(callback); - return frames.length; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const props = threadProps("pane-short-scroll", "agent:main:session-a"); - render(renderChatThread(props, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - transcript.scrollToOffset(420); - - for (let index = 0; index <= 60; index += 1) { - transcript.hostUpdated(); - for (const frame of frames.splice(0)) { - frame(0); - } - } - - expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull(); - }); - - it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const props = threadProps("pane-width-remeasure"); - const renderTranscript = async () => { - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - }; - - await renderTranscript(); - transcript.hostConnected(); - await renderTranscript(); - expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)"); - - const scrollElement = container.querySelector(".chat-thread"); - expect(scrollElement).not.toBeNull(); - scrollElement!.scrollTop = 40; - scrollElement!.dispatchEvent(new Event("scroll")); - const virtualizer = ( - transcript as unknown as { - sessionVirtualizer: { - virtualizerController: { getVirtualizer: () => { isScrolling: boolean } }; - }; - } - ).sessionVirtualizer.virtualizerController.getVirtualizer(); - expect(virtualizer.isScrolling).toBe(true); - - measuredRowHeight = 180; - for (const observer of resizeObservers) { - if (scrollElement && observer.observes(scrollElement)) { - observer.emit(640, 600); - } - } - await renderTranscript(); - - expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(180px)"); - transcript.hostDisconnected(); - }); - - it.each([ - { label: "end-pinned", distanceFromEnd: 0, expectedCalls: 1 }, - { label: "scrolled away", distanceFromEnd: 100, expectedCalls: 0 }, - ])( - "$label transcript preserves its resize anchor", - async ({ distanceFromEnd, expectedCalls }) => { - measuredRowHeight = 240; - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const messages = Array.from({ length: 12 }, (_, index) => ({ - role: index % 2 === 0 ? "user" : "assistant", - content: `message ${index}`, - timestamp: index + 1, - })); - const props = threadProps( - `pane-height-resize-${distanceFromEnd}`, - "agent:main:resize", - messages, - ); - render(renderChatThread(props, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const scrollElement = container.querySelector(".chat-thread"); - expect(scrollElement).not.toBeNull(); - const virtualizer = ( - transcript as unknown as { - sessionVirtualizer: { - virtualizerController: { - getVirtualizer: () => { - scrollOffset: number | null; - getTotalSize: () => number; - scrollToEnd: (options?: { behavior?: ScrollBehavior }) => void; - }; - }; - }; - } - ).sessionVirtualizer.virtualizerController.getVirtualizer(); - const scrollToEnd = vi.spyOn(virtualizer, "scrollToEnd"); - const emitViewportResize = (height: number) => { - for (const observer of resizeObservers) { - if (scrollElement && observer.observes(scrollElement)) { - observer.emit(800, height); - } - } - }; - - emitViewportResize(600); - scrollToEnd.mockClear(); - expect(virtualizer.getTotalSize()).toBeGreaterThan(700); - virtualizer.scrollOffset = Math.max(0, virtualizer.getTotalSize() - 600 - distanceFromEnd); - emitViewportResize(560); - - expect(scrollToEnd).toHaveBeenCalledTimes(expectedCalls); - if (expectedCalls > 0) { - expect(scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" }); - } - transcript.hostDisconnected(); - }, - ); - - it("rebinds guarded transcript images when the gateway rotates its auth token", async () => { - const NativeUrl = URL; - const blobUrl = `blob:transcript-media-${crypto.randomUUID()}`; - vi.stubGlobal( - "URL", - class extends NativeUrl { - static override createObjectURL = vi.fn(() => blobUrl); - static override revokeObjectURL = vi.fn(); - }, - ); - - let previousSignal: AbortSignal | undefined; - const fetchMock = vi.fn((_source: string, init?: RequestInit) => { - if (fetchMock.mock.calls.length === 1) { - return new Promise((_resolve, reject) => { - previousSignal = init?.signal ?? undefined; - previousSignal?.addEventListener( - "abort", - () => reject(new DOMException("media scope changed", "AbortError")), - { once: true }, - ); - }); - } - return Promise.resolve({ - ok: true, - blob: async () => new Blob(["png"], { type: "image/png" }), - } as Response); - }); - vi.stubGlobal("fetch", fetchMock); - - const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`; - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const client = { - request: vi.fn(async () => null), - } as unknown as Parameters[0]["client"]; - const sessions = {} as Parameters[0]["sessions"]; - const { pane, state } = createTestChatPane({ client, sessions }); - state.hello = { - auth: { deviceToken: "old-token" }, - } as typeof state.hello; - const messages = [ - { - role: "assistant", - content: [{ type: "image", url: source }], - timestamp: 1_000, - }, - ]; - const renderPane = () => { - render( - renderChatThread( - { - ...threadProps("pane-gateway-media-auth", state.sessionKey, messages), - assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state), - onRequestUpdate: renderPane, - }, - transcript, - ), - container, - ); - transcript.hostUpdated(); - }; - state.requestUpdate = renderPane; - - renderPane(); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const thumbnailSource = source.replace(/\/full$/u, "/thumbnail"); - const previousResource = observeChatMediaResource( - "managed-image", - `${thumbnailSource}::old-token::`, - ); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(previousResource.subscribers.size).toBe(1); - - pane.applyGatewaySnapshot({ - ...pane.context.gateway.snapshot, - client, - phase: "connected", - hello: { - ...pane.context.gateway.snapshot.hello, - auth: { deviceToken: "next-token" }, - } as typeof pane.context.gateway.snapshot.hello, - }); - expect(previousSignal?.aborted).toBe(true); - expect(isChatMediaResourceCurrent(previousResource)).toBe(false); - await flushDeferredRowPrune(); - - const nextResource = observeChatMediaResource( - "managed-image", - `${thumbnailSource}::next-token::`, - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe( - "Bearer next-token", - ); - expect(isChatMediaResourceCurrent(nextResource)).toBe(true); - expect(nextResource.subscribers.size).toBe(1); - expect(container.querySelector(".chat-message-image")?.src).toBe(blobUrl); - - releaseChatMediaResourceSubscriber(renderPane); - transcript.hostDisconnected(); - }); - - it("reconciles guarded local attachments when pane preview roots change", async () => { - let previousSignal: AbortSignal | undefined; - const fetchMock = vi.fn((_source: string, init?: RequestInit) => { - if (fetchMock.mock.calls.length === 1) { - return new Promise((_resolve, reject) => { - previousSignal = init?.signal ?? undefined; - previousSignal?.addEventListener( - "abort", - () => reject(new DOMException("preview roots changed", "AbortError")), - { once: true }, - ); - }); - } - return Promise.resolve({ - ok: true, - json: async () => ({ - available: true, - mediaTicket: "root-restored-ticket", - mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(), - }), - } as Response); - }); - vi.stubGlobal("fetch", fetchMock); - - const client = { - request: vi.fn(async () => null), - } as unknown as Parameters[0]["client"]; - const sessions = {} as Parameters[0]["sessions"]; - const { pane, state } = createTestChatPane({ client, sessions }); - const configPane = pane as typeof pane & { - applyApplicationConfig: (config: typeof pane.context.config.current) => void; - }; - state.hello = { - auth: { deviceToken: "old-token" }, - } as typeof state.hello; - state.localMediaPreviewRoots = ["/tmp/openclaw"]; - state.embedSandboxMode = "scripts"; - state.allowExternalEmbedUrls = false; - - const source = `/tmp/openclaw/${crypto.randomUUID()}.pdf`; - const messages = [ - { - role: "assistant", - content: `Local document\nMEDIA:${source}`, - timestamp: 1_000, - }, - ]; - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const renderPane = () => { - render( - renderChatThread( - { - ...threadProps("pane-local-media-roots", state.sessionKey, messages), - assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state), - localMediaPreviewRoots: state.localMediaPreviewRoots, - onRequestUpdate: renderPane, - }, - transcript, - ), - container, - ); - transcript.hostUpdated(); - }; - state.requestUpdate = renderPane; - - renderPane(); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - const previousResource = observeChatMediaResource( - "assistant-attachment", - `::old-token::${source}`, - ); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(previousResource.subscribers.size).toBe(1); - - const config = { - ...pane.context.config.current, - localMediaPreviewRoots: ["/tmp/elsewhere"], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - }; - configPane.applyApplicationConfig(config); - await flushDeferredRowPrune(); - - expect(previousSignal?.aborted).toBe(true); - expect(isChatMediaResourceCurrent(previousResource)).toBe(false); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect( - container.querySelector(".chat-assistant-attachment-card__reason")?.textContent, - ).toContain("Outside allowed folders"); - - configPane.applyApplicationConfig({ - ...config, - localMediaPreviewRoots: ["/tmp/openclaw"], - }); - await flushDeferredRowPrune(); - - const restoredResource = observeChatMediaResource( - "assistant-attachment", - `::old-token::${source}`, - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe( - "Bearer old-token", - ); - expect(isChatMediaResourceCurrent(restoredResource)).toBe(true); - expect(restoredResource.subscribers.size).toBe(1); - expect( - container.querySelector(".chat-assistant-attachment-card__link")?.getAttribute("href"), - ).toContain("mediaTicket=root-restored-ticket"); - - releaseChatMediaResourceSubscriber(renderPane); - transcript.hostDisconnected(); - }); - - it("updates MCP App pinning when the same provider's capability changes", async () => { - const provider = { - sessionKey: "agent:main:main", - canPinWidgets: true, - canPinMcpApps: false, - pinMcpApp: vi.fn(async () => undefined), - snapshot$: { - value: { - sessionKey: "agent:main:main", - revision: 1, - tabs: [], - widgets: [], - }, - subscribe: () => () => undefined, - }, - }; - const props = { - ...threadProps("pane-mcp-capability"), - boardProvider: provider as unknown as BoardProvider, - messages: [ - { - role: "assistant", - timestamp: 1_000, - content: [ - { type: "text", text: "Here is the dashboard app." }, - { - type: "canvas", - preview: { - kind: "canvas", - surface: "assistant_message", - render: "url", - title: "Dashboard app", - viewId: "outer-view-must-not-be-pinned", - mcpApp: { - viewId: "view-dashboard-app", - serverName: "dashboard", - toolName: "show", - uiResourceUri: "ui://dashboard/app.html", - toolCallId: "call-dashboard-app", - originSessionKey: "agent:main:main", - }, - }, - }, - ], - }, - ], - }; - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - - render(renderChatThread(props, transcript), container); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - expect(container.querySelector('[data-content-kind="mcp-app"]')).not.toBeNull(); - expect(container.querySelector("[data-pin-widget]")).toBeNull(); - - provider.canPinMcpApps = true; - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - - expect(container.querySelector("[data-pin-widget]")).not.toBeNull(); - expect(provider.snapshot$.value.revision).toBe(1); - - provider.canPinMcpApps = false; - render(renderChatThread(props, transcript), container); - transcript.hostUpdated(); - - expect(container.querySelector("[data-pin-widget]")).toBeNull(); - expect(provider.snapshot$.value.revision).toBe(1); - }); -}); diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 6ffa96ffff3c..70fec717c2fe 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -1,1395 +1,22 @@ -// Chat-owned message thread presentation and thread-local interaction state. -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { VirtualizerController } from "@tanstack/lit-virtual"; -import { defaultRangeExtractor, observeElementRect } from "@tanstack/virtual-core"; -import { - html, - nothing, - type ReactiveController, - type ReactiveControllerHost, - type TemplateResult, -} from "lit"; -import { guard } from "lit/directives/guard.js"; -import { ref } from "lit/directives/ref.js"; -import { repeat } from "lit/directives/repeat.js"; -import { styleMap } from "lit/directives/style-map.js"; -import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js"; -import type { SessionsListResult } from "../../../api/types.ts"; -import type { QuestionPrompt } from "../../../app/question-prompt.ts"; -import { resolveLocalUserName } from "../../../app/user-identity.ts"; -import { copyMarkdownLabel } from "../../../components/copy-button.ts"; -import { icons } from "../../../components/icons.ts"; -import type { ImageLightboxItem } from "../../../components/image-lightbox.ts"; +// Public chat transcript renderer and DOM shell. +import { html, nothing, type TemplateResult } from "lit"; import { handleMarkdownCodeBlockCopy } from "../../../components/markdown-code-blocks.ts"; import { markdownFileLinkFromEvent, markdownFileLinkFromKeyboardEvent, } from "../../../components/markdown-file-links.ts"; -import "../../../components/tooltip.ts"; -import { McpAppUnmountGate } from "../../../components/mcp-app-unmount.ts"; -import { i18n, t } from "../../../i18n/index.ts"; -import type { BoardProvider } from "../../../lib/board/provider.ts"; -import type { - ChatQueueItem, - ChatStreamSegment, - MessageGroup, -} from "../../../lib/chat/chat-types.ts"; +import { t } from "../../../i18n/index.ts"; import { - buildCompanionQuestionPrefill, - buildMoreDetailsCompanionQuestion, -} from "../../../lib/chat/companion-question.ts"; -import { extractTextCached } from "../../../lib/chat/message-extract.ts"; -import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; -import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; -import { copyToClipboard } from "../../../lib/clipboard.ts"; -import { fnv1aUtf16 } from "../../../lib/fnv1a.ts"; + handleTranscriptContextMenu, + handleTranscriptSelection, + type ChatThreadProps, +} from "./chat-thread-interactions.ts"; import { - areUiSessionKeysEquivalent, - isUiGlobalScopeConfigured, - parseAgentSessionKey, - resolveUiGlobalAliasAgentId, - type UiSessionDefaultsHost, -} from "../../../lib/sessions/session-key.ts"; -import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts"; -import type { ChatRunStartupStatus } from "../chat-run-startup.ts"; -import { - assistantGroupCanOwnActiveRunStatus, - assistantMessageExpansionSignature, - buildCachedChatItems, - coalesceActivityRuns, - coalesceStreamRuns, - collapseCompletedTurnWork, - getExpansionStateVersion, - getExpandedToolCards, - getExpandedAssistantMessages, - getExpandedUserMessages, - persistedMessageEntryId, - resetChatThreadState, - setExpansionState, - syncToolCardExpansionState, -} from "../chat-thread.ts"; -import { PinnedMessages } from "../pinned-messages.ts"; -import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; -import { - CHAT_TRANSCRIPT_END_THRESHOLD_PX, - getChatSessionScrollPosition, - saveChatSessionScrollPosition, - type ChatSessionScrollPosition, -} from "../scroll.ts"; -import { getOrCreateSessionCacheValue } from "../session-cache.ts"; -import type { PlanStatus } from "../tool-stream.ts"; -import { getToolTitlesVersion } from "../tool-titles.ts"; -import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; -import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; -import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; -import { resolveMessageGroupSenderLabel } from "./chat-message-group.ts"; -import { resolveMessageReplyText } from "./chat-message-markdown.ts"; -import type { ArtifactDownloadResolver } from "./chat-message-media.ts"; -import { - dismissConfirmedActionPopovers, - getChatMediaRenderVersion, - openChatRewindConfirmation, - renderMessageGroup, - renderActivityGroup, - renderStreamGroup, - renderWorkGroupSummary, - type MessageReplyTarget, - type StreamGroupOptions, - type StreamGroupPart, -} from "./chat-message.ts"; -import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts"; -import { handleChatSelectionPointerUp, removeChatSelectionPopup } from "./chat-selection-popup.ts"; -import type { SidebarContent, SidebarFullMessageLoader } from "./chat-sidebar.ts"; -import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcome.ts"; -import { renderTurnRecapRow } from "./chat-working-indicator.ts"; - -const pinnedMessagesMap = new Map(); - -type ChatThreadState = { - searchOpen: boolean; - searchQuery: string; - searchFocusPending: boolean; - searchReturnFocusTarget: HTMLElement | null; - searchReturnFocusOwner: HTMLElement | null; - pinnedExpanded: boolean; - transcriptRenderDependencies: readonly unknown[]; - transcriptRenderContext: { - onSetReply?: ChatThreadProps["onSetReply"]; - onOpenReply?: (replyToId: string) => void; - }; -}; - -export type ChatReplyMessageAccess = { - revision: number; - navigationId: string | null; - read: (messageId: string) => unknown; - request: (messageId: string) => void; - open: (messageId: string) => void; -}; - -type ChatThreadProps = { - paneId: string; - sessionKey: string; - boardProvider?: BoardProvider; - announceTranscript?: boolean; - loading: boolean; - historyPagination?: { - loading: boolean; - }; - messages: unknown[]; - toolMessages: unknown[]; - streamSegments: ChatStreamSegment[]; - stream: string | null; - streamStartedAt: number | null; - runId?: string | null; - runOutputTokens?: number | null; - queue: ChatQueueItem[]; - showThinking: boolean; - showToolCalls: boolean; - persistCommentary?: boolean; - /** True while the session has an abortable live run (marks running tool rows). */ - runActive?: boolean; - /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ - runWorking?: boolean; - /** Coarse startup stage shown until assistant or tool activity becomes visible. */ - startupStatus?: ChatRunStartupStatus | null; - /** Re-labels the working spark while the active run is parked on an approval. */ - waitingApproval?: boolean; - planStatus?: PlanStatus | null; - questionPrompts?: readonly QuestionPrompt[]; - sessions: SessionsListResult | null; - /** Host context resolving global-alias session keys (scope=global fleets). */ - /** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */ - sessionHost?: UiSessionDefaultsHost | null; - gatewayUrl?: string; - assistantName: string; - assistantAvatar: string | null; - assistantAvatarUrl?: string | null; - userId?: string | null; - userName?: string | null; - userAvatar?: string | null; - basePath?: string; - fullMessageAgentId?: string; - loadFullAssistantMessage?: SidebarFullMessageLoader | null; - localMediaPreviewRoots?: string[]; - assistantAttachmentAuthToken?: string | null; - resolveArtifactDownload?: ArtifactDownloadResolver; - canvasPluginSurfaceUrl?: string | null; - embedSandboxMode?: EmbedSandboxMode; - allowExternalEmbedUrls?: boolean; - autoExpandToolCalls?: boolean; - realtimeTalkConversation?: RealtimeTalkConversationEntry[]; - onOpenSidebar?: (content: SidebarContent) => void; - onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void; - onOpenSessionCheckpoints?: () => void | Promise; - onAssistantAttachmentLoaded?: () => void; - onRequestOpenImage?: () => number; - onOpenImage?: (item: ImageLightboxItem, requestVersion?: number) => void; - onRequestUpdate?: () => void; - onChatScroll?: (event: Event) => void; - onHistoryIntent?: (event: Event) => void; - onDraftChange: (next: string) => void; - onSend: () => void; - onSetReply?: (target: MessageReplyTarget) => void; - replyMessageAccess?: ChatReplyMessageAccess; - onRewindMessage?: (entryId: string) => Promise | boolean; - onForkMessage?: (entryId: string) => Promise | void; - onFocusComposer?: () => void; - onCompanionQuestion?: (question: string) => void; - onCompanionPrefill?: (question: string) => void; - onOpenSession?: (sessionKey: string) => void; - modelSetupRequired?: boolean; - onModelSetup?: () => void; - /** Tasks-rail snapshot backing the post-turn running-tasks status row. */ - backgroundTasks?: BackgroundTasksProps; -}; - -type ChatPinnedMessagesProps = Pick< - ChatThreadProps, - "paneId" | "sessionKey" | "messages" | "userName" | "userAvatar" ->; - -type ChatRenderItem = ReturnType[number]; - -type ChatTranscriptRow = - | { kind: "item"; key: string; item: ChatRenderItem } - | { kind: "content"; key: string; content: unknown }; - -type ChatTranscriptAnnouncement = { - key: string; - text: string; -}; - -type LoadedReplySource = { - rowKey: string; - preview: MessageReplyTarget & { sourceMessageId: string }; -}; - -function projectResolvedReplyPreview( - message: unknown, - replyToId: string, - props: Pick, -): LoadedReplySource["preview"] | undefined { - const normalized = normalizeMessage(message); - const text = resolveMessageReplyText(message); - if (!text) { - return undefined; - } - const group: MessageGroup = { - kind: "group", - key: replyToId, - role: normalized.role, - senderLabel: normalized.senderLabel, - ...(normalized.sender ? { sender: normalized.sender } : {}), - messages: [{ key: replyToId, message }], - timestamp: normalized.timestamp, - isStreaming: false, - }; - const sourceMessageId = persistedMessageEntryId(message) ?? replyToId; - return { - messageId: sourceMessageId, - sourceMessageId, - senderLabel: resolveMessageGroupSenderLabel(group, props), - text, - }; -} - -const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120; -const CHAT_TRANSCRIPT_OVERSCAN = 6; -const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500; -// Initial virtual rows can correct their estimates for several frames. Hold a -// restored offset for ~200ms so those corrections cannot reapply the end anchor. -const CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES = 12; -// A committed short transcript can legitimately remain at maxOffset=0. Give -// initial measurement one second before treating that zero range as final. -const CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES = 60; -function initialTranscriptRect(host: ReactiveControllerHost) { - const width = host instanceof HTMLElement ? host.clientWidth : 0; - const height = host instanceof HTMLElement ? host.clientHeight : 0; - return { - width: width || (typeof window === "undefined" ? 0 : window.innerWidth), - height: height || (typeof window === "undefined" ? 0 : window.innerHeight), - }; -} - -function transcriptScrollMargin(element: Element | null): number { - if (!(element instanceof HTMLElement) || typeof getComputedStyle !== "function") { - return 0; - } - const margin = Number.parseFloat(getComputedStyle(element).paddingTop); - return Number.isFinite(margin) ? margin : 0; -} - -function initialTranscriptScrollMargin(host: ReactiveControllerHost): number { - return host instanceof HTMLElement - ? transcriptScrollMargin(host.querySelector(".chat-thread")) - : 0; -} - -class ChatSessionVirtualizerHost implements ReactiveControllerHost { - private readonly controllers = new Set(); - private readonly virtualizerController: VirtualizerController; - private threadInnerElement: HTMLDivElement | null = null; - private connected = false; - private observedWidth: number | null = null; - private observedHeight: number | null = null; - private contentReady = false; - private pendingScrollOffset: { - offset: number; - stableFrames: number; - zeroMaxFrames: number; - onSettled?: (position: ChatSessionScrollPosition) => void; - } | null = null; - private pendingScrollFrame: number | null = null; - // Lit calls refs before newly rendered nodes are connected. Resolve the - // scroll parent lazily or a stable ref can permanently capture null. - private get scrollElement(): HTMLDivElement | null { - const parent = this.threadInnerElement?.parentElement; - return parent instanceof HTMLDivElement ? parent : null; - } - // Stable Lit refs: inline arrows change identity per render, making Lit - // re-invoke them for every visible row and re-measure each row every render. - // Lit tracks the last element per callback, so each row needs its own. - private readonly scrollElementRef = (element?: Element) => { - this.threadInnerElement = element instanceof HTMLDivElement ? element : null; - }; - private readonly measureRowRefs = new Map void>(); - private pruneDetachedRowsQueued = false; - private pendingRowMeasureFrame: number | null = null; - private measureConnectedRows(): void { - // Only width invalidation owns forced DOM reads. Ordinary row refs stay on - // TanStack's observer path so resizeItem cannot perturb scroll restoration. - const instance = this.virtualizerController.getVirtualizer(); - for (const row of this.threadInnerElement?.querySelectorAll(".chat-virtual-row") ?? - []) { - instance.resizeItem( - instance.indexFromElement(row), - row[instance.options.horizontal ? "offsetWidth" : "offsetHeight"], - ); - } - } - private queueConnectedRowMeasure(): void { - if (this.pendingRowMeasureFrame !== null) { - return; - } - this.pendingRowMeasureFrame = requestAnimationFrame(() => { - this.pendingRowMeasureFrame = null; - this.measureConnectedRows(); - }); - } - private measureRowRefFor(key: string): (element?: Element) => void { - let callback = this.measureRowRefs.get(key); - if (!callback) { - callback = (element?: Element) => { - if (element instanceof HTMLElement) { - this.virtualizerController.getVirtualizer().measureElement(element); - return; - } - // Re-stamps (e.g. the chat<->dashboard face switch) re-invoke each - // stable row ref as an (undefined, element) pair while the new subtree - // is still detached. measureElement(null) prunes every disconnected - // row, so calling it synchronously unobserves just-registered sibling - // rows and freezes their heights at the old pane width (overlapping - // bubbles). Defer until the commit lands so only removed rows prune. - if (this.pruneDetachedRowsQueued) { - return; - } - this.pruneDetachedRowsQueued = true; - queueMicrotask(() => { - this.pruneDetachedRowsQueued = false; - this.virtualizerController.getVirtualizer().measureElement(null); - }); - }; - this.measureRowRefs.set(key, callback); - } - return callback; - } - private rowKeys: readonly string[] = []; - private rowIndexesByKey = new Map(); - private messageRowKeysById = new Map(); - private focusedRowKey: string | null = null; - private announcementInitialized = false; - private announcementKey: string | null = null; - private currentAnnouncementText = ""; - private readonly mcpAppUnmountGate = new McpAppUnmountGate(this); - - constructor( - private readonly host: ReactiveControllerHost, - initialOffset: number | null = null, - onInitialOffsetSettled?: (position: ChatSessionScrollPosition) => void, - ) { - this.virtualizerController = new VirtualizerController(this, { - count: 0, - getScrollElement: () => this.scrollElement, - estimateSize: () => CHAT_TRANSCRIPT_ESTIMATED_ROW_PX, - getItemKey: () => "", - initialRect: initialTranscriptRect(host), - initialOffset: initialOffset ?? Number.MAX_SAFE_INTEGER, - scrollMargin: initialTranscriptScrollMargin(host), - anchorTo: "end", - followOnAppend: false, - observeElementRect: (instance, callback) => - observeElementRect(instance, (rect) => { - const previousHeight = this.observedHeight; - const widthChanged = this.observedWidth !== null && this.observedWidth !== rect.width; - const heightChanged = previousHeight !== null && previousHeight !== rect.height; - const scrollOffset = instance.scrollOffset; - const wasAtEndBeforeResize = - heightChanged && - this.pendingScrollOffset === null && - scrollOffset !== null && - instance.getTotalSize() - previousHeight - scrollOffset <= - CHAT_TRANSCRIPT_END_THRESHOLD_PX; - this.observedWidth = rect.width; - this.observedHeight = rect.height; - this.syncScrollMargin(instance.scrollElement); - callback(rect); - if (wasAtEndBeforeResize) { - instance.scrollToEnd({ behavior: "auto" }); - } - if (widthChanged) { - // Cached offscreen sizes belong to the old wrapping width. Reset - // them, seed current rows, then repeat after any same-commit - // re-stamp has attached and completed layout. - instance.measure(); - this.measureConnectedRows(); - this.queueConnectedRowMeasure(); - } - }), - rangeExtractor: (range) => { - const indexes = defaultRangeExtractor(range); - const focused = - this.focusedRowKey === null ? undefined : this.rowIndexesByKey.get(this.focusedRowKey); - if ( - focused === undefined || - focused < 0 || - focused >= range.count || - indexes.includes(focused) - ) { - return indexes; - } - return [...indexes, focused].toSorted((left, right) => left - right); - }, - scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX, - overscan: CHAT_TRANSCRIPT_OVERSCAN, - }); - if (initialOffset !== null) { - this.pendingScrollOffset = { - offset: initialOffset, - stableFrames: 0, - zeroMaxFrames: 0, - onSettled: onInitialOffsetSettled, - }; - } - } - - get updateComplete() { - return this.host.updateComplete; - } - - get liveAnnouncementText() { - return this.currentAnnouncementText; - } - - requestUpdate = () => { - this.host.requestUpdate(); - }; - - addController(controller: ReactiveController): void { - this.controllers.add(controller); - } - - removeController(controller: ReactiveController): void { - this.controllers.delete(controller); - } - - connect(): void { - if (this.connected) { - return; - } - this.connected = true; - for (const controller of this.controllers) { - controller.hostConnected?.(); - } - if (this.pendingScrollOffset) { - this.host.requestUpdate(); - } - } - - update(): void { - for (const controller of this.controllers) { - controller.hostUpdated?.(); - } - this.applyPendingScrollOffset(); - } - - disconnect(): void { - if (this.pendingRowMeasureFrame !== null) { - cancelAnimationFrame(this.pendingRowMeasureFrame); - this.pendingRowMeasureFrame = null; - } - if (this.pendingScrollFrame !== null) { - cancelAnimationFrame(this.pendingScrollFrame); - this.pendingScrollFrame = null; - } - if (!this.connected) { - this.threadInnerElement = null; - return; - } - this.connected = false; - for (const controller of this.controllers) { - controller.hostDisconnected?.(); - } - this.threadInnerElement = null; - } - - dispose(): void { - this.disconnect(); - this.measureRowRefs.clear(); - this.rowKeys = []; - this.rowIndexesByKey.clear(); - this.messageRowKeysById.clear(); - this.focusedRowKey = null; - this.pendingScrollOffset = null; - } - - render( - rows: readonly ChatTranscriptRow[], - renderRow: (row: ChatTranscriptRow) => unknown, - announcement: ChatTranscriptAnnouncement | null, - announce: boolean, - overlay: unknown = nothing, - ): TemplateResult { - this.syncRows(rows); - this.syncAnnouncement(announcement, announce); - const virtualizer = this.virtualizerController.getVirtualizer(); - const virtualRows = virtualizer.getVirtualItems(); - const nextRowKeys = new Set( - virtualRows.flatMap((virtualRow) => { - const row = rows[virtualRow.index]; - return row ? [row.key] : []; - }), - ); - const rendered = html` -
-
- ${overlay} - ${repeat( - virtualRows, - (virtualRow) => virtualRow.key, - (virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) { - return nothing; - } - return html` -
- ${renderRow(row)} -
- `; - }, - )} -
-
- `; - return this.mcpAppUnmountGate.render(JSON.stringify([...nextRowKeys]), rendered, () => - this.threadInnerElement - ? [...this.threadInnerElement.querySelectorAll(".chat-virtual-row")].filter( - (row) => !nextRowKeys.has(row.dataset.virtualRowKey ?? ""), - ) - : [], - ) as TemplateResult; - } - - scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void { - this.virtualizerController.getVirtualizer().scrollToEnd(options); - } - - scrollToOffset(offset: number): void { - if (this.scrollElement) { - this.scrollElement.scrollTop = offset; - } - this.virtualizerController.getVirtualizer().scrollToOffset(offset); - } - - syncMessageRows(messageRowKeysById: ReadonlyMap): void { - this.messageRowKeysById = new Map(messageRowKeysById); - } - - revealMessage(messageId: string): boolean { - const rowKey = this.messageRowKeysById.get(messageId); - if (!rowKey) { - return false; - } - const rowIndex = this.rowIndexesByKey.get(rowKey); - if (rowIndex === undefined) { - return false; - } - this.virtualizerController.getVirtualizer().scrollToIndex(rowIndex, { align: "center" }); - this.host.requestUpdate(); - void this.host.updateComplete.then(() => { - const bubble = [ - ...(this.threadInnerElement?.querySelectorAll(".chat-bubble") ?? []), - ].find((candidate) => candidate.dataset.entryId === messageId); - if (!bubble) { - return; - } - this.threadInnerElement - ?.querySelector(".chat-bubble--reply-target") - ?.classList.remove("chat-bubble--reply-target"); - bubble.scrollIntoView?.({ behavior: "smooth", block: "center" }); - bubble.classList.add("chat-bubble--reply-target"); - bubble.addEventListener( - "animationend", - () => bubble.classList.remove("chat-bubble--reply-target"), - { once: true }, - ); - }); - return true; - } - - getScrollOffset(): number | null { - return this.scrollElement?.scrollTop ?? null; - } - - getMaxScrollOffset(): number | null { - const scrollElement = this.scrollElement; - return scrollElement - ? Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight) - : null; - } - - setContentReady(ready: boolean): void { - this.contentReady = ready; - } - - restoreScrollOffset( - offset: number, - onSettled?: (position: ChatSessionScrollPosition) => void, - ): void { - this.pendingScrollOffset = { offset, stableFrames: 0, zeroMaxFrames: 0, onSettled }; - if (this.connected) { - this.host.requestUpdate(); - } - } - - getPendingScrollOffset(): number | null { - return this.pendingScrollOffset?.offset ?? null; - } - - handleFocusIn(event: FocusEvent): void { - this.focusedRowKey = this.rowKeyFromEvent(event); - } - - handleFocusOut(event: FocusEvent): void { - this.focusedRowKey = this.rowKeyFromEvent(event, event.relatedTarget); - } - - private rowKeyFromEvent(event: FocusEvent, target: EventTarget | null = event.target) { - if (!(target instanceof Element) || !this.scrollElement?.contains(target)) { - return null; - } - const row = target.closest(".chat-virtual-row[data-virtual-row-key]"); - if (!row || !this.scrollElement.contains(row)) { - return null; - } - return row.dataset.virtualRowKey || null; - } - - private syncAnnouncement( - announcement: ChatTranscriptAnnouncement | null, - announce: boolean, - ): void { - if (!this.announcementInitialized || !announce) { - this.announcementInitialized = true; - this.announcementKey = announcement?.key ?? null; - this.currentAnnouncementText = ""; - return; - } - if (!announcement || announcement.key === this.announcementKey) { - return; - } - this.announcementKey = announcement.key; - this.currentAnnouncementText = announcement.text; - } - - private syncRows(rows: readonly ChatTranscriptRow[]): void { - const nextKeys = rows.map((row) => row.key); - if ( - nextKeys.length === this.rowKeys.length && - nextKeys.every((key, index) => key === this.rowKeys[index]) - ) { - return; - } - this.rowKeys = Object.freeze(nextKeys); - this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index])); - for (const key of this.measureRowRefs.keys()) { - if (!this.rowIndexesByKey.has(key)) { - this.measureRowRefs.delete(key); - } - } - const keys = this.rowKeys; - const virtualizer = this.virtualizerController.getVirtualizer(); - virtualizer.setOptions({ - ...virtualizer.options, - count: keys.length, - getItemKey: (index) => keys[index] ?? `missing:${index}`, - }); - } - - private syncScrollMargin(scrollElement: HTMLDivElement | null): void { - const scrollMargin = transcriptScrollMargin(scrollElement); - const virtualizer = this.virtualizerController.getVirtualizer(); - if (scrollMargin === virtualizer.options.scrollMargin) { - return; - } - virtualizer.setOptions({ - ...virtualizer.options, - scrollMargin, - }); - } - - private applyPendingScrollOffset(): void { - const pending = this.pendingScrollOffset; - if (!pending || !this.connected) { - return; - } - const maxOffset = this.getMaxScrollOffset(); - if (maxOffset === null) { - if (this.contentReady && this.rowKeys.length === 0) { - this.settlePendingScroll(0); - } - return; - } - if (maxOffset === 0 && pending.offset > 0) { - if (this.contentReady && this.rowKeys.length === 0) { - this.settlePendingScroll(0); - } else if (this.contentReady) { - if (pending.zeroMaxFrames >= CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES) { - this.settlePendingScroll(0); - return; - } - pending.zeroMaxFrames += 1; - this.schedulePendingScrollRetry(); - } - return; - } - pending.zeroMaxFrames = 0; - const targetOffset = Math.min(pending.offset, maxOffset); - this.scrollToOffset(targetOffset); - const currentOffset = this.getScrollOffset(); - if (currentOffset != null && Math.abs(currentOffset - targetOffset) <= 1) { - if (pending.stableFrames >= CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES) { - this.settlePendingScroll(currentOffset); - } else { - pending.stableFrames += 1; - this.schedulePendingScrollRetry(); - } - } else { - pending.stableFrames = 0; - this.schedulePendingScrollRetry(); - } - } - - private schedulePendingScrollRetry(): void { - if (!this.connected || this.pendingScrollFrame !== null) { - return; - } - this.pendingScrollFrame = requestAnimationFrame(() => { - this.pendingScrollFrame = null; - if (this.connected && this.pendingScrollOffset) { - this.host.requestUpdate(); - } - }); - } - - private settlePendingScroll(scrollTop: number): void { - const pending = this.pendingScrollOffset; - this.pendingScrollOffset = null; - if (!pending) { - return; - } - const maxScrollTop = this.getMaxScrollOffset(); - pending.onSettled?.({ - scrollTop, - anchorToEnd: - maxScrollTop === null - ? this.contentReady && this.rowKeys.length === 0 - : maxScrollTop - scrollTop <= CHAT_TRANSCRIPT_END_THRESHOLD_PX, - }); - } -} - -export class ChatTranscriptController implements ReactiveController { - private activeSessionKey: string | null = null; - private sessionVirtualizer: ChatSessionVirtualizerHost | null = null; - private connected = false; - - constructor(private readonly host: ReactiveControllerHost) { - host.addController(this); - } - - get renderedSessionKey(): string | null { - return this.activeSessionKey; - } - - render(props: ChatThreadProps): TemplateResult { - if ( - !this.sessionVirtualizer || - this.activeSessionKey === null || - !areUiSessionKeysEquivalent(this.activeSessionKey, props.sessionKey) - ) { - this.sessionVirtualizer?.dispose(); - const savedPosition = getChatSessionScrollPosition(props.paneId, props.sessionKey); - const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? null); - this.activeSessionKey = props.sessionKey; - this.sessionVirtualizer = new ChatSessionVirtualizerHost( - this.host, - initialOffset, - initialOffset === null - ? undefined - : (position) => { - saveChatSessionScrollPosition(props.paneId, props.sessionKey, position); - }, - ); - if (this.connected) { - this.sessionVirtualizer.connect(); - } - } - return renderChatThreadContents(props, this.sessionVirtualizer); - } - - scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void { - this.sessionVirtualizer?.scrollToEnd(options); - } - - scrollToOffset(offset: number, onSettled?: (position: ChatSessionScrollPosition) => void): void { - this.sessionVirtualizer?.restoreScrollOffset(offset, onSettled); - } - - revealMessage(messageId: string): boolean { - return this.sessionVirtualizer?.revealMessage(messageId) ?? false; - } - - pendingScrollOffsetFor(sessionKey: string): number | null { - return this.activeSessionKey !== null && - areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey) - ? (this.sessionVirtualizer?.getPendingScrollOffset() ?? null) - : null; - } - - handleFocusIn(event: FocusEvent): void { - this.sessionVirtualizer?.handleFocusIn(event); - } - - handleFocusOut(event: FocusEvent): void { - this.sessionVirtualizer?.handleFocusOut(event); - } - - hostConnected(): void { - this.connected = true; - this.sessionVirtualizer?.connect(); - } - - hostUpdated(): void { - this.sessionVirtualizer?.update(); - } - - hostDisconnected(): void { - this.connected = false; - this.sessionVirtualizer?.disconnect(); - } -} - -function createChatThreadState(): ChatThreadState { - return { - searchOpen: false, - searchQuery: "", - searchFocusPending: false, - searchReturnFocusTarget: null, - searchReturnFocusOwner: null, - pinnedExpanded: false, - transcriptRenderDependencies: [], - transcriptRenderContext: {}, - }; -} - -const threadStates = new Map(); - -function getChatThreadState(paneId: string): ChatThreadState { - const existing = threadStates.get(paneId); - if (existing) { - return existing; - } - const state = createChatThreadState(); - threadStates.set(paneId, state); - return state; -} - -function getPinnedMessages(sessionKey: string): PinnedMessages { - return getOrCreateSessionCacheValue( - pinnedMessagesMap, - sessionKey, - () => new PinnedMessages(sessionKey), - ); -} - -function getPinnedMessageSummary(message: unknown): string { - return extractTextCached(message) ?? ""; -} - -function dismissChatThreadPortals(paneId?: string, owner?: ParentNode): void { - removeReplyContextMenu(paneId); - if (owner) { - dismissConfirmedActionPopovers(owner); - } - // The selection popup is body-portaled; pane teardown/route changes must - // drop it so it cannot outlive the render that owns its callbacks. - removeChatSelectionPopup(); -} - -export function resetChatThreadSessionPresentationState(paneId: string, owner?: ParentNode): void { - dismissChatThreadPortals(paneId, owner); - const state = threadStates.get(paneId); - if (state) { - // Search input belongs to the outgoing transcript. Other fields are pane - // preferences or dependency memos and invalidate themselves on new props. - state.searchOpen = false; - state.searchQuery = ""; - state.searchFocusPending = false; - state.searchReturnFocusTarget = null; - state.searchReturnFocusOwner = null; - } -} - -export function resetChatThreadPresentationState(paneId?: string, owner?: ParentNode) { - dismissChatThreadPortals(paneId, owner); - if (paneId) { - threadStates.delete(paneId); - resetChatThreadState(paneId); - } else { - threadStates.clear(); - resetChatThreadState(); - } -} - -export function renderChatSearchBar( - paneId: string, - requestUpdate: () => void, -): TemplateResult | typeof nothing { - const state = getChatThreadState(paneId); - if (!state.searchOpen) { - return nothing; - } - return html` - - `; -} - -function closeChatThreadSearch(state: ChatThreadState, requestUpdate: () => void): void { - const returnFocusTarget = state.searchReturnFocusTarget; - const returnFocusOwner = state.searchReturnFocusOwner; - state.searchOpen = false; - state.searchQuery = ""; - state.searchFocusPending = false; - state.searchReturnFocusTarget = null; - state.searchReturnFocusOwner = null; - requestUpdate(); - queueMicrotask(() => { - const target = returnFocusTarget?.isConnected - ? returnFocusTarget - : returnFocusOwner?.querySelector( - ".agent-chat__composer-combobox > textarea", - ); - target?.focus({ preventScroll: true }); - }); -} - -/** Toggles transcript search and retains the shortcut origin for focus restoration. */ -export function toggleChatThreadSearch( - paneId: string, - requestUpdate: () => void, - triggerEvent?: Event, -): void { - const state = getChatThreadState(paneId); - if (state.searchOpen) { - closeChatThreadSearch(state, requestUpdate); - return; - } - - state.searchOpen = true; - state.searchFocusPending = true; - const returnFocusTarget = triggerEvent?.target; - const returnFocusOwner = triggerEvent?.currentTarget; - state.searchReturnFocusTarget = - returnFocusTarget instanceof HTMLElement && returnFocusTarget.isConnected - ? returnFocusTarget - : null; - state.searchReturnFocusOwner = - returnFocusOwner instanceof HTMLElement && returnFocusOwner.isConnected - ? returnFocusOwner - : null; - requestUpdate(); -} - -export function renderChatPinnedMessages( - props: ChatPinnedMessagesProps, - requestUpdate: () => void, -): TemplateResult | typeof nothing { - const state = getChatThreadState(props.paneId); - const pinned = getPinnedMessages(props.sessionKey); - const userRoleLabel = resolveLocalUserName({ - name: props.userName ?? null, - avatar: props.userAvatar ?? null, - }); - const messages = Array.isArray(props.messages) ? props.messages : []; - const entries: Array<{ index: number; text: string; role: string }> = []; - for (const idx of pinned.indices) { - const msg = messages[idx] as Record | undefined; - if (!msg) { - continue; - } - const text = getPinnedMessageSummary(msg); - const role = typeof msg.role === "string" ? msg.role : "unknown"; - entries.push({ index: idx, text, role }); - } - if (entries.length === 0) { - return nothing; - } - return html` -
- - ${state.pinnedExpanded - ? html` -
- ${entries.map( - ({ index, text, role }) => html` -
- ${role === "user" ? userRoleLabel : t("common.assistant")} - ${truncateUtf16Safe(text, 100)}${text.length > 100 ? "..." : ""} - - - -
- `, - )} -
- ` - : nothing} -
- `; -} - -let activeReplyContextMenu: HTMLElement | null = null; -let activeReplyContextMenuPaneId: string | null = null; -let contextMenuDocumentClickHandler: ((event: MouseEvent) => void) | null = null; -let contextMenuDocumentContextMenuHandler: ((event: MouseEvent) => void) | null = null; -let contextMenuKeydownHandler: ((event: KeyboardEvent) => void) | null = null; - -function removeReplyContextMenu(paneId?: string) { - if (paneId && paneId !== activeReplyContextMenuPaneId) { - return; - } - if (activeReplyContextMenu) { - dismissConfirmedActionPopovers(activeReplyContextMenu); - activeReplyContextMenu.remove(); - } - activeReplyContextMenu = null; - activeReplyContextMenuPaneId = null; - const fallbackMenu = document.querySelector(".chat-reply-context-menu"); - if (fallbackMenu) { - dismissConfirmedActionPopovers(fallbackMenu); - fallbackMenu.remove(); - } - if (contextMenuDocumentClickHandler) { - document.removeEventListener("click", contextMenuDocumentClickHandler); - contextMenuDocumentClickHandler = null; - } - if (contextMenuDocumentContextMenuHandler) { - document.removeEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true); - contextMenuDocumentContextMenuHandler = null; - } - if (contextMenuKeydownHandler) { - document.removeEventListener("keydown", contextMenuKeydownHandler); - contextMenuKeydownHandler = null; - } -} - -function stableReplyMessageId(senderLabel: string | undefined, text: string): string { - const source = `${senderLabel ?? ""}\n${text}`; - return `reply:${fnv1aUtf16(source).toString(16)}`; -} - -function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.setAttribute("role", "menuitem"); - button.setAttribute("aria-label", t("chat.messages.replyToMessage")); - button.textContent = t("chat.messages.reply"); - button.addEventListener("click", onClick); - return button; -} - -function createMessageActionContextButton(params: { - label: string; - disabled: boolean; - tooltip: string; - onClick: () => void; -}): { element: HTMLElement; button: HTMLButtonElement } { - const button = document.createElement("button"); - button.type = "button"; - button.disabled = params.disabled; - button.setAttribute("role", "menuitem"); - button.setAttribute("aria-label", params.label); - button.textContent = params.label; - button.addEventListener("click", params.onClick); - const tooltip = document.createElement("openclaw-tooltip"); - tooltip.content = params.tooltip; - tooltip.append(button); - return { element: tooltip, button }; -} - -function handleChatThreadSelectionPointerUp(event: PointerEvent, props: ChatThreadProps) { - if ( - typeof props.onCompanionQuestion !== "function" || - typeof props.onCompanionPrefill !== "function" - ) { - return; - } - handleChatSelectionPointerUp(event, { - onMoreDetails: (selection) => { - const question = buildMoreDetailsCompanionQuestion(selection); - if (question) { - props.onCompanionQuestion?.(question); - } - }, - onAskSideChat: (selection) => { - const question = buildCompanionQuestionPrefill(selection); - if (question) { - props.onCompanionPrefill?.(question); - } - }, - }); -} - -function selectionIntersectsElement(selection: Selection | null, element: Element): boolean { - if (!selection || selection.isCollapsed) { - return false; - } - for (let index = 0; index < selection.rangeCount; index += 1) { - if (selection.getRangeAt(index).intersectsNode(element)) { - return true; - } - } - return false; -} - -function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) { - if (event.composedPath().some((target) => target instanceof HTMLAnchorElement)) { - return; - } - const bubble = (event.target as HTMLElement).closest(".chat-bubble"); - if (!bubble) { - return; - } - const group = bubble.closest(".chat-group"); - if (!group) { - return; - } - if ( - group.querySelector(".chat-reading-indicator") || - group.querySelector(".chat-bubble.streaming") - ) { - return; - } - const senderEl = group.querySelector(".chat-sender-name"); - const senderLabel = senderEl?.textContent?.trim() ?? undefined; - const text = truncateUtf16Safe((bubble as HTMLElement).dataset.messageText?.trim() ?? "", 500); - const entryId = (bubble as HTMLElement).dataset.entryId?.trim() ?? ""; - const messageId = (bubble as HTMLElement).dataset.messageId?.trim() ?? ""; - const isUserMessage = group.classList.contains("user") && Boolean(entryId); - // Grouped rows can contain several bubbles. Match the clicked bubble to its - // own action owner so copy never targets a sibling message. - const actionOwner = [...group.querySelectorAll("[data-message-actions-for]")].find( - (element) => element.dataset.messageActionsFor === messageId, - ); - const copyButton = actionOwner?.querySelector(".chat-copy-btn"); - const canReply = Boolean(text && props.onSetReply); - const canRewind = isUserMessage && typeof props.onRewindMessage === "function"; - const canCopy = Boolean(copyButton); - const canFork = isUserMessage && typeof props.onForkMessage === "function"; - if (!canReply && !canRewind && !canCopy && !canFork) { - return; - } - - const selection = window.getSelection(); - const selectedText = selectionIntersectsElement(selection, bubble) ? selection?.toString() : ""; - - event.preventDefault(); - event.stopPropagation(); - removeReplyContextMenu(); - const menu = document.createElement("div"); - menu.className = "chat-reply-context-menu"; - menu.setAttribute("role", "menu"); - menu.setAttribute("aria-label", t("chat.messages.actions")); - menu.style.left = `${event.clientX}px`; - menu.style.top = `${event.clientY}px`; - const focusCandidates: HTMLButtonElement[] = []; - if (selectedText) { - const action = createMessageActionContextButton({ - label: t("chat.messages.copySelection"), - disabled: false, - tooltip: t("chat.messages.copySelection"), - onClick: () => { - void copyToClipboard(selectedText); - removeReplyContextMenu(); - }, - }); - menu.append(action.element); - focusCandidates.push(action.button); - } - if (canReply) { - const replyMessageId = messageId || stableReplyMessageId(senderLabel, text); - const replyButton = createReplyContextMenuButton(() => { - props.onSetReply?.({ - messageId: replyMessageId, - text, - senderLabel, - ...(entryId ? { sourceMessageId: entryId } : {}), - }); - removeReplyContextMenu(); - props.onFocusComposer?.(); - }); - menu.append(replyButton); - focusCandidates.push(replyButton); - } - const working = Boolean(props.runActive || props.runWorking); - if (canRewind) { - const action = createMessageActionContextButton({ - label: t("chat.messages.rewindToHere"), - disabled: working, - tooltip: working ? t("chat.messages.rewindUnavailable") : t("chat.messages.rewindToHere"), - onClick: () => { - openChatRewindConfirmation(action.button, () => { - removeReplyContextMenu(); - void Promise.resolve(props.onRewindMessage?.(entryId)).then((rewound) => { - if (rewound) { - props.onFocusComposer?.(); - } - }); - }); - }, - }); - action.element.classList.add("chat-confirm-wrap", "chat-rewind-wrap"); - menu.append(action.element); - focusCandidates.push(action.button); - } - if (canCopy) { - const action = createMessageActionContextButton({ - label: copyMarkdownLabel(), - disabled: false, - tooltip: copyMarkdownLabel(), - onClick: () => { - removeReplyContextMenu(); - copyButton?.click(); - }, - }); - menu.append(action.element); - focusCandidates.push(action.button); - } - if (canFork) { - const action = createMessageActionContextButton({ - label: t("chat.messages.forkFromHere"), - disabled: working, - tooltip: working ? t("chat.messages.forkUnavailable") : t("chat.messages.forkFromHere"), - onClick: () => { - removeReplyContextMenu(); - void props.onForkMessage?.(entryId); - }, - }); - menu.append(action.element); - focusCandidates.push(action.button); - } - document.body.appendChild(menu); - activeReplyContextMenu = menu; - activeReplyContextMenuPaneId = props.paneId; - - const menuRect = menu.getBoundingClientRect(); - let left = event.clientX; - let top = event.clientY; - if (left + menuRect.width > window.innerWidth) { - left = window.innerWidth - menuRect.width - 8; - } - if (top + menuRect.height > window.innerHeight) { - top = window.innerHeight - menuRect.height - 8; - } - menu.style.left = `${Math.max(0, left)}px`; - menu.style.top = `${Math.max(0, top)}px`; - focusCandidates.find((button) => !button.disabled)?.focus(); - requestAnimationFrame(() => { - if (!menu.isConnected || activeReplyContextMenu !== menu) { - return; - } - contextMenuDocumentClickHandler = (nextEvent: MouseEvent) => { - if (!menu.contains(nextEvent.target as Node | null)) { - removeReplyContextMenu(); - } - }; - contextMenuDocumentContextMenuHandler = (nextEvent: MouseEvent) => { - if (!menu.contains(nextEvent.target as Node | null)) { - removeReplyContextMenu(); - } - }; - const handleKeydown = (nextEvent: KeyboardEvent) => { - if (nextEvent.key === "Escape") { - nextEvent.preventDefault(); - nextEvent.stopPropagation(); - removeReplyContextMenu(); - props.onFocusComposer?.(); - } - }; - contextMenuKeydownHandler = handleKeydown; - document.addEventListener("click", contextMenuDocumentClickHandler); - // Capture closes this owner even when the next menu stops event propagation. - document.addEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true); - document.addEventListener("keydown", handleKeydown); - }); -} + type ChatTranscriptSession, + ChatTranscriptController, +} from "./chat-transcript-controller.ts"; +import { projectChatTranscript } from "./chat-transcript-projection.ts"; +import { renderWelcomeState } from "./chat-welcome.ts"; function renderLoadingSkeleton() { return html` @@ -1446,600 +73,42 @@ function renderHistorySentinel(loading: boolean) { `; } -function latestTranscriptAnnouncement( - items: readonly ChatRenderItem[], -): ChatTranscriptAnnouncement | null { - for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { - const item = items[itemIndex]; - if (!item || item.kind !== "group" || item.role.toLowerCase() !== "assistant") { - continue; - } - for (let messageIndex = item.messages.length - 1; messageIndex >= 0; messageIndex -= 1) { - const message = item.messages[messageIndex]?.message; - const text = extractTextCached(message)?.trim(); - if (text) { - return { - key: item.key, - text: truncateUtf16Safe(text, CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS), - }; - } - } - } - return null; -} - -function chatRenderItemGuardDependencies(item: ChatRenderItem): readonly unknown[] { - if (item.kind === "stream-run") { - return [item.key, ...item.parts]; - } - if (item.kind === "work-group") { - return [item.key, item.durationMs, item.hasError, ...item.groups]; - } - if (item.kind === "activity-run") { - return [item.key, ...item.groups]; - } - return [item]; -} - -function trackTranscriptRenderDependencies( - state: ChatThreadState, - dependencies: unknown[], -): unknown[] { - const previous = state.transcriptRenderDependencies; - const nextLength = dependencies.length - 1; - let changed = previous.length !== nextLength; - for (let index = 0; !changed && index < nextLength; index += 1) { - changed = !Object.is(previous[index], dependencies[index + 1]); - } - if (changed) { - // The first dependency is chatItems. Keep the shared context stable when - // only the live row changes, but invalidate every row for presentation changes. - state.transcriptRenderDependencies = dependencies.slice(1); - state.transcriptRenderContext = {}; - } - return dependencies; -} - -function guardChatRenderItems( - state: ChatThreadState, - // Live run status is not derivable from a row's own item identity: ownership - // is decided by sibling rows, and the usage counter ticks on run patches that - // touch nothing else. Rows showing status must re-render on both, or the - // memoized copy stacks a second claw row or freezes the token count. - liveStatus: (item: ChatRenderItem) => string, - render: (item: ChatRenderItem) => unknown, -) { - return (item: ChatRenderItem) => - guard( - [...chatRenderItemGuardDependencies(item), state.transcriptRenderContext, liveStatus(item)], - () => render(item), - ); -} - export function renderChatThread( props: ChatThreadProps, transcript: ChatTranscriptController, ): TemplateResult { - return transcript.render(props); + return transcript.renderSession(props.paneId, props.sessionKey, (session) => + renderTranscriptShell(props, session), + ); } -function renderChatThreadContents( +function renderTranscriptShell( props: ChatThreadProps, - transcript: ChatSessionVirtualizerHost, + transcript: ChatTranscriptSession, ): TemplateResult { - const state = getChatThreadState(props.paneId); - const requestUpdate = props.onRequestUpdate ?? (() => {}); - const displayStream = props.stream ?? null; - const sessionHost = props.sessionHost ?? null; - // Equivalence, not exact match: the default session travels under alias - // keys ("main" vs "agent:main:main") depending on the caller. - const activeSession = props.sessions?.sessions?.find((row) => - areUiSessionKeysEquivalent(row.key, props.sessionKey), - ); - // Global-alias detection needs no session row: under configured global - // scope, agent::global and configured-main aliases route to the global - // stream even when the capped sessions list omits the canonical row (or it - // does not exist yet). The scope gate keeps per-sender main threads direct. - const isGlobalAliasKey = - parseAgentSessionKey(props.sessionKey)?.rest === "global" || - (sessionHost !== null && - isUiGlobalScopeConfigured(sessionHost) && - resolveUiGlobalAliasAgentId(sessionHost, props.sessionKey) !== null); - const reasoningLevel = activeSession?.reasoningLevel ?? "off"; - const showReasoning = props.showThinking && reasoningLevel !== "off"; - const assistantIdentity = { - name: props.assistantName, - avatar: resolveAssistantDisplayAvatar(props), - }; - const locale = i18n.getLocale(); - const searchFiltering = state.searchOpen && Boolean(state.searchQuery.trim()); - const chatItems = buildCachedChatItems({ - paneId: props.paneId, - sessionKey: props.sessionKey, - runId: props.runId === undefined ? (activeSession?.activeRunIds?.[0] ?? null) : props.runId, - locale, - messages: props.messages, - toolMessages: props.toolMessages, - streamSegments: props.streamSegments, - stream: displayStream, - streamStartedAt: props.streamStartedAt, - queue: props.queue, - showToolCalls: props.showToolCalls, - persistCommentary: props.persistCommentary, - runWorking: Boolean(props.runWorking), - runActive: Boolean(props.runActive), - planStatus: props.planStatus, - questionPrompts: props.questionPrompts, - loading: props.loading, - searchOpen: state.searchOpen, - searchQuery: state.searchQuery, - }); - syncToolCardExpansionState( - props.sessionKey, - chatItems, - Boolean(props.autoExpandToolCalls), - searchFiltering || !props.showToolCalls, - ); - const expandedToolCards = getExpandedToolCards(props.sessionKey); - const expandedUserMessages = getExpandedUserMessages(props.sessionKey); - const expandedAssistantMessages = getExpandedAssistantMessages(props.sessionKey); - const questionPrompts = new Map( - (props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]), - ); - const toggleToolCardExpanded = (toolCardId: string) => { - setExpansionState(expandedToolCards, toolCardId, !expandedToolCards.get(toolCardId)); - requestUpdate(); - }; - const toggleAssistantMessageExpanded = (messageId: string) => { - const current = expandedAssistantMessages.get(messageId); - if (current?.status === "loaded") { - expandedAssistantMessages.set(messageId, { - ...current, - expanded: !current.expanded, - revision: current.revision + 1, - }); - requestUpdate(); - return; - } - const loader = props.loadFullAssistantMessage; - if (!loader || current?.status === "loading") { - return; - } - const revision = (current?.revision ?? 0) + 1; - expandedAssistantMessages.set(messageId, { status: "loading", revision }); - requestUpdate(); - void loader({ - sessionKey: props.sessionKey, - ...(props.fullMessageAgentId ? { agentId: props.fullMessageAgentId } : {}), - messageId, - kind: "assistant_message", - }).then( - (result) => { - const pending = expandedAssistantMessages.get(messageId); - if (pending?.status !== "loading" || pending.revision !== revision) { - return; - } - const markdown = - result?.ok && result.message && typeof result.message === "object" - ? extractTextCached(result.message) - : null; - expandedAssistantMessages.set( - messageId, - markdown === null - ? { status: "error", revision: revision + 1 } - : { status: "loaded", expanded: true, markdown, revision: revision + 1 }, - ); - requestUpdate(); - }, - () => { - const pending = expandedAssistantMessages.get(messageId); - if (pending?.status !== "loading" || pending.revision !== revision) { - return; - } - expandedAssistantMessages.set(messageId, { status: "error", revision: revision + 1 }); - requestUpdate(); - }, - ); - }; - const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0; - const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation; - transcript.setContentReady(!props.loading); - // 1:1 sessions drop the avatar gutter entirely; group threads keep avatars - // as the always-visible identity marker. The canonical session kind decides; - // the sessions list is capped, so absent/unknown rows classify by key: - // global aliases first, then the same core key-shape helper the gateway - // uses. Message senderLabels are not a signal here: gateway sanitization - // labels 1:1 channel DM rows too. - const rowKind = activeSession?.kind; - const sessionKind = - rowKind && rowKind !== "unknown" - ? rowKind - : isGlobalAliasKey - ? "global" - : classifySessionKind(props.sessionKey); - // Only agent-solo kinds qualify: "global" aggregates every inbound context - // under session.scope="global" (including group/channel senders), so it - // keeps avatars like "group" and "unknown" do. An identity-resolving gateway - // (multi-user trusted proxy) also keeps them: several people share these - // sessions, so the author marker is signal, not decoration. - const isDirectThread = - (sessionKind === "direct" || sessionKind === "cron" || sessionKind === "spawn-child") && - !props.userId; - const showLoadingSkeleton = props.loading && chatItems.length === 0; - const threadContextWindow = - activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null; - const activeContinuationByGroupKey = new Map< - string, - { parts: StreamGroupPart[]; options: StreamGroupOptions } - >(); - const turnRecapByGroupKey = new Map(); - const loadedReplySources = new Map(); - const resolvedReplyPreviews = new Map(); - const resolveReplyPreview = (replyToId: string) => { - const loaded = loadedReplySources.get(replyToId)?.preview; - if (loaded) { - return loaded; - } - if (resolvedReplyPreviews.has(replyToId)) { - return resolvedReplyPreviews.get(replyToId); - } - const message = props.replyMessageAccess?.read(replyToId); - const preview = message ? projectResolvedReplyPreview(message, replyToId, props) : undefined; - resolvedReplyPreviews.set(replyToId, preview); - return preview; - }; - const sharedMessageRenderOptions = { - onOpenSidebar: props.onOpenSidebar, - sessionKey: props.sessionKey, - boardProvider: props.boardProvider, - agentId: props.fullMessageAgentId, - runActive: props.runActive, - onOpenWorkspaceFile: props.onOpenWorkspaceFile, - onRequestUpdate: requestUpdate, - basePath: props.basePath, - localMediaPreviewRoots: props.localMediaPreviewRoots ?? [], - assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null, - resolveArtifactDownload: props.resolveArtifactDownload, - onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, - onRequestOpenImage: props.onRequestOpenImage, - onOpenImage: props.onOpenImage, - canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, - embedSandboxMode: props.embedSandboxMode ?? "scripts", - allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, - showAssistantAvatar: false, - } satisfies StreamGroupOptions; - const streamGroupOptions = { - ...sharedMessageRenderOptions, - assistant: assistantIdentity, - } satisfies StreamGroupOptions; - const renderGroupOptions = (item: MessageGroup) => { - const lastMessage = item.messages.at(-1)?.message; - const rewindEntryId = - item.role.toLowerCase() === "user" && lastMessage - ? persistedMessageEntryId(lastMessage) - : null; - return { - ...sharedMessageRenderOptions, - showReasoning, - showToolCalls: props.showToolCalls, - autoExpandToolCalls: Boolean(props.autoExpandToolCalls), - isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId), - onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => { - setExpansionState( - expandedToolCards, - messageId, - !(expanded ?? expandedToolCards.get(messageId) ?? false), - ); - requestUpdate(); - }, - isUserMessageExpanded: (messageId: string) => expandedUserMessages.get(messageId) ?? false, - onToggleUserMessageExpanded: (messageId: string) => { - setExpansionState(expandedUserMessages, messageId, !expandedUserMessages.get(messageId)); - requestUpdate(); - }, - loadFullAssistantMessage: props.loadFullAssistantMessage ?? undefined, - getAssistantMessageExpansion: (messageId: string) => expandedAssistantMessages.get(messageId), - onToggleAssistantMessageExpanded: toggleAssistantMessageExpanded, - isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false, - onToggleToolExpanded: toggleToolCardExpanded, - assistantName: props.assistantName, - assistantAvatar: assistantIdentity.avatar, - userId: props.userId ?? null, - userName: props.userName ?? null, - userAvatar: props.userAvatar ?? null, - showAvatarGutter: !isDirectThread, - contextWindow: threadContextWindow, - onReply: props.onSetReply - ? (target) => state.transcriptRenderContext.onSetReply?.(target) - : undefined, - resolveReplyPreview, - onResolveReply: props.replyMessageAccess?.request, - onOpenReply: (replyToId: string) => state.transcriptRenderContext.onOpenReply?.(replyToId), - replyNavigationId: props.replyMessageAccess?.navigationId, - onRewind: - rewindEntryId && props.onRewindMessage - ? () => { - void Promise.resolve(props.onRewindMessage?.(rewindEntryId)).then((rewound) => { - if (rewound) { - props.onFocusComposer?.(); - } - }); - } - : undefined, - rewindDisabled: Boolean(props.runActive || props.runWorking), - activeContinuation: activeContinuationByGroupKey.get(item.key), - turnRecap: turnRecapByGroupKey.get(item.key), - } satisfies Parameters[1]; - }; - const renderGroupItem = (item: MessageGroup) => { - return renderMessageGroup(item, renderGroupOptions(item)); - }; - // Only the working indicator shows live usage, so rows without one keep - // memoizing across usage patches. - const workingUsageKey = `usage:${props.runOutputTokens ?? ""}`; - const liveStatusSignature = (item: ChatRenderItem): string => { - if (item.kind === "stream-run") { - return item.parts.some((part) => part.kind === "reading-indicator") ? workingUsageKey : ""; - } - if (item.kind !== "group") { - return ""; - } - const continuation = activeContinuationByGroupKey.get(item.key); - const recap = turnRecapByGroupKey.get(item.key); - // Part keys stand in for the rest of the continuation: its remaining - // options mirror props that already invalidate every row through the - // shared render context. - const continuationKey = continuation - ? `${continuation.parts.map((part) => part.key).join(" ")}${workingUsageKey}` - : ""; - const recapKey = recap ? `${recap.runtimeMs}:${recap.outputTokens ?? ""}` : ""; - return `${continuationKey}|${recapKey}`; - }; - const renderItem = guardChatRenderItems(state, liveStatusSignature, (item) => { - if (item.kind === "divider") { - return renderChatDivider(item, props.onOpenSessionCheckpoints); - } - if (item.kind === "notice") { - return renderChatNotice(item); - } - if (item.kind === "stream-run") { - return renderStreamGroup(item.parts, { - ...streamGroupOptions, - questionPrompts, - planStatus: props.planStatus, - planActive: Boolean(props.runActive), - startupPhase: props.startupStatus?.phase, - waitingApproval: props.waitingApproval, - runOutputTokens: props.runOutputTokens, - }); - } - if (item.kind === "work-group") { - const workExpanded = expandedToolCards.get(item.key) ?? item.hasError; - return html` - ${renderWorkGroupSummary(item, { - expanded: workExpanded, - onToggle: () => { - setExpansionState(expandedToolCards, item.key, !workExpanded); - requestUpdate(); - }, - })} - ${workExpanded ? item.groups.map((group) => renderGroupItem(group)) : nothing} - `; - } - if (item.kind === "activity-run") { - const firstGroup = item.groups[0]; - if (!firstGroup) { - return nothing; - } - if (item.groups.length === 1) { - return renderGroupItem(firstGroup); - } - return renderActivityGroup(item.groups, renderGroupOptions(firstGroup)); - } - if (item.kind === "group") { - return renderGroupItem(item); - } - if (item.kind === "question") { - return renderStreamGroup([item], { - questionPrompts, - }); - } - return nothing; - }); - const collapsedItems = coalesceActivityRuns( - collapseCompletedTurnWork(coalesceStreamRuns(chatItems), { - sessionKey: props.sessionKey, - runWorking: Boolean(props.runWorking), - searchActive: searchFiltering, - }), - { searchActive: searchFiltering }, - ); - // Watch/settle on actual indicator visibility (not runWorking): queued - // sends show the claw before the run starts, and the recap must never - // stack under a visible working row. - const workingIndicatorVisible = chatItems.some((item) => item.kind === "reading-indicator"); - const turnRecap = resolveTurnRecap(props.sessionKey, workingIndicatorVisible, activeSession); - const transcriptItems = collapsedItems.filter((item, index) => { - if (item.kind !== "stream-run") { - return true; - } - const previous = collapsedItems[index - 1]; - const isActiveStatusRun = - item.parts.some((part) => part.kind === "reading-indicator") && - item.parts.every((part) => part.kind === "reading-indicator" || part.kind === "plan"); - if ( - previous?.kind !== "group" || - !isActiveStatusRun || - !assistantGroupCanOwnActiveRunStatus(previous) - ) { - return true; - } - // A reply and its still-running state are one turn-level presentation. - // Keeping the status in the reply avoids a second claw/assistant row. - activeContinuationByGroupKey.set(previous.key, { - parts: item.parts, - options: { - ...streamGroupOptions, - planStatus: props.planStatus, - planActive: Boolean(props.runActive), - startupPhase: props.startupStatus?.phase, - waitingApproval: props.waitingApproval, - runOutputTokens: props.runOutputTokens, - }, - }); - return false; - }); - for (const item of transcriptItems) { - if (item.kind !== "group") { - continue; - } - const senderLabel = resolveMessageGroupSenderLabel(item, { - assistantName: props.assistantName, - userId: props.userId, - userName: props.userName, - userAvatar: props.userAvatar, - }); - for (const source of item.messages) { - const sourceMessageId = persistedMessageEntryId(source.message); - const text = resolveMessageReplyText(source.message); - if (sourceMessageId && text) { - loadedReplySources.set(sourceMessageId, { - rowKey: item.key, - preview: { - messageId: source.key, - sourceMessageId, - senderLabel, - text, - }, - }); - } - } - } - transcript.syncMessageRows( - new Map([...loadedReplySources].map(([messageId, source]) => [messageId, source.rowKey])), - ); - let turnRecapOwnerKey: string | null = null; - if (turnRecap !== null) { - const lastItem = transcriptItems.at(-1); - if (lastItem?.kind === "group" && assistantGroupCanOwnActiveRunStatus(lastItem)) { - turnRecapByGroupKey.set(lastItem.key, turnRecap); - turnRecapOwnerKey = lastItem.key; - } - } - const transcriptRows: ChatTranscriptRow[] = transcriptItems.map((item) => ({ - kind: "item", - key: item.key, - item, - })); - const realtimeConversation = renderRealtimeTalkConversation(props); - if (realtimeConversation !== nothing) { - transcriptRows.push({ - kind: "content", - key: "realtime-talk", - content: realtimeConversation, - }); - } - if (turnRecap !== null && turnRecapOwnerKey === null && !isEmpty && !showLoadingSkeleton) { - transcriptRows.push({ - kind: "content", - key: "turn-recap", - content: renderTurnRecapRow(turnRecap), - }); - } - const backgroundTasks = - !props.runWorking && !isEmpty && !showLoadingSkeleton - ? renderBackgroundTasksStatusRow(props.backgroundTasks) - : nothing; - if (backgroundTasks !== nothing) { - transcriptRows.push({ - kind: "content", - key: "background-tasks", - content: backgroundTasks, - }); - } - trackTranscriptRenderDependencies(state, [ - chatItems, - locale, - expandedToolCards, - getExpansionStateVersion(expandedToolCards), - expandedUserMessages, - getExpansionStateVersion(expandedUserMessages), - assistantMessageExpansionSignature(expandedAssistantMessages), - getChatMediaRenderVersion(), - // The host minute poll requests an update; this key crosses row guard() memoization. - Math.floor(Date.now() / 60_000), - getToolTitlesVersion(), - props.sessionKey, - props.gatewayUrl, - props.boardProvider, - props.boardProvider?.canPinWidgets, - props.boardProvider?.canPinMcpApps, - props.boardProvider?.snapshot$.value.revision, - props.fullMessageAgentId, - Boolean(props.loadFullAssistantMessage), - showReasoning, - props.showToolCalls, - Boolean(props.runActive), - Boolean(props.runWorking), - props.startupStatus?.phase, - Boolean(props.waitingApproval), - props.planStatus, - props.questionPrompts, - Boolean(props.autoExpandToolCalls), - props.assistantName, - assistantIdentity.avatar, - props.userId, - props.userName, - props.userAvatar, - props.basePath, - (props.localMediaPreviewRoots ?? []).join("\u0000"), - props.assistantAttachmentAuthToken, - props.canvasPluginSurfaceUrl, - props.embedSandboxMode ?? "scripts", - props.allowExternalEmbedUrls ?? false, - threadContextWindow, - Boolean(props.onSetReply), - props.replyMessageAccess?.revision ?? 0, - props.replyMessageAccess?.navigationId ?? "", - turnRecap === null ? "" : `${turnRecap.runtimeMs}:${turnRecap.outputTokens ?? ""}`, - ]); - state.transcriptRenderContext.onSetReply = props.onSetReply; - state.transcriptRenderContext.onOpenReply = (replyToId) => { - if (loadedReplySources.has(replyToId)) { - transcript.revealMessage(replyToId); - return; - } - if (searchFiltering) { - closeChatThreadSearch(state, requestUpdate); - } - props.replyMessageAccess?.open(replyToId); - }; + const projection = projectChatTranscript(props, transcript); const transcriptContents = - showLoadingSkeleton || isEmpty + projection.showLoadingSkeleton || projection.isEmpty ? html`
${props.historyPagination ? renderHistorySentinel(props.historyPagination.loading) : nothing} - ${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} - ${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing} - ${isEmpty && state.searchOpen + ${projection.showLoadingSkeleton ? renderLoadingSkeleton() : nothing} + ${projection.isEmpty && !projection.searchOpen ? renderWelcomeState(props) : nothing} + ${projection.isEmpty && projection.searchOpen ? html`
${t("chat.thread.noMatches")}
` : nothing}
` - : transcript.render( - transcriptRows, - (row) => (row.kind === "item" ? renderItem(row.item) : row.content), - latestTranscriptAnnouncement(collapsedItems), - props.announceTranscript !== false && !state.searchOpen && !props.loading, + : projection.renderRows( props.historyPagination ? renderHistorySentinel(props.historyPagination.loading) : nothing, ); return html`
handleChatContextMenu(event, props)} - @pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)} + @contextmenu=${(event: MouseEvent) => handleTranscriptContextMenu(event, props)} + @pointerup=${(event: PointerEvent) => handleTranscriptSelection(event, props)} > `; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts new file mode 100644 index 000000000000..ee405b099f38 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -0,0 +1,197 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestTranscript } from "../chat-view.test-helpers.ts"; +import { renderChatThread } from "./chat-thread.ts"; +import { + flushDeferredRowPrune, + installTranscriptDomMocks, + observedElements, + resetTranscriptTestDom, + resizeObservers, + threadProps, + transcriptDomState, + transcriptRows, +} from "./chat-transcript.test-support.ts"; + +describe("chat transcript controller", () => { + beforeEach(installTranscriptDomMocks); + afterEach(resetTranscriptTestDom); + + it("keeps every re-stamped row observed after moving containers", async () => { + const transcript = createTestTranscript(); + const props = threadProps("pane-measure"); + const chatFace = document.body.appendChild(document.createElement("div")); + render(renderChatThread(props, transcript), chatFace); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const chatRows = transcriptRows(chatFace); + expect(chatRows.length).toBeGreaterThanOrEqual(4); + for (const row of chatRows) { + expect(observedElements.has(row)).toBe(true); + } + + // Re-stamp the same session transcript into a new container while the old + // tree is still tracked, mirroring the dashboard face-switch commit. + const dashboardDock = document.body.appendChild(document.createElement("div")); + render(renderChatThread(props, transcript), dashboardDock); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const dockRows = transcriptRows(dashboardDock); + expect(dockRows.length).toBe(chatRows.length); + for (const row of dockRows) { + expect(observedElements.has(row)).toBe(true); + } + for (const row of chatRows) { + expect(observedElements.has(row)).toBe(false); + } + }); + + it("pauses an unmeasurable restore until loading commits an empty transcript", () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-loading-scroll", "agent:main:session-a", []); + render(renderChatThread({ ...props, loading: true }, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + transcript.scrollToOffset(420); + transcript.hostUpdated(); + + expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBe(420); + + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull(); + }); + + it("settles a restored offset when loaded rows no longer overflow", () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-short-scroll", "agent:main:session-a"); + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + transcript.scrollToOffset(420); + + for (let index = 0; index <= 60; index += 1) { + transcript.hostUpdated(); + for (const frame of frames.splice(0)) { + frame(0); + } + } + + expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull(); + }); + + it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-width-remeasure"); + const renderTranscript = async () => { + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + }; + + await renderTranscript(); + transcript.hostConnected(); + await renderTranscript(); + expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)"); + + const scrollElement = container.querySelector(".chat-thread"); + expect(scrollElement).not.toBeNull(); + scrollElement!.scrollTop = 40; + scrollElement!.dispatchEvent(new Event("scroll")); + const virtualizer = ( + transcript as unknown as { + sessionVirtualizer: { + virtualizerController: { getVirtualizer: () => { isScrolling: boolean } }; + }; + } + ).sessionVirtualizer.virtualizerController.getVirtualizer(); + expect(virtualizer.isScrolling).toBe(true); + + transcriptDomState.measuredRowHeight = 180; + for (const observer of resizeObservers) { + if (scrollElement && observer.observes(scrollElement)) { + observer.emit(640, 600); + } + } + await renderTranscript(); + + expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(180px)"); + transcript.hostDisconnected(); + }); + + it.each([ + { label: "end-pinned", distanceFromEnd: 0, expectedCalls: 1 }, + { label: "scrolled away", distanceFromEnd: 100, expectedCalls: 0 }, + ])( + "$label transcript preserves its resize anchor", + async ({ distanceFromEnd, expectedCalls }) => { + transcriptDomState.measuredRowHeight = 240; + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const messages = Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message ${index}`, + timestamp: index + 1, + })); + const props = threadProps( + `pane-height-resize-${distanceFromEnd}`, + "agent:main:resize", + messages, + ); + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const scrollElement = container.querySelector(".chat-thread"); + expect(scrollElement).not.toBeNull(); + const virtualizer = ( + transcript as unknown as { + sessionVirtualizer: { + virtualizerController: { + getVirtualizer: () => { + scrollOffset: number | null; + getTotalSize: () => number; + scrollToEnd: (options?: { behavior?: ScrollBehavior }) => void; + }; + }; + }; + } + ).sessionVirtualizer.virtualizerController.getVirtualizer(); + const scrollToEnd = vi.spyOn(virtualizer, "scrollToEnd"); + const emitViewportResize = (height: number) => { + for (const observer of resizeObservers) { + if (scrollElement && observer.observes(scrollElement)) { + observer.emit(800, height); + } + } + }; + + emitViewportResize(600); + scrollToEnd.mockClear(); + expect(virtualizer.getTotalSize()).toBeGreaterThan(700); + virtualizer.scrollOffset = Math.max(0, virtualizer.getTotalSize() - 600 - distanceFromEnd); + emitViewportResize(560); + + expect(scrollToEnd).toHaveBeenCalledTimes(expectedCalls); + if (expectedCalls > 0) { + expect(scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" }); + } + transcript.hostDisconnected(); + }, + ); +}); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts new file mode 100644 index 000000000000..6981503daf28 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -0,0 +1,670 @@ +// Session-owned virtualizer lifecycle for chat transcripts. +import { VirtualizerController } from "@tanstack/lit-virtual"; +import { defaultRangeExtractor, observeElementRect } from "@tanstack/virtual-core"; +import { + html, + nothing, + type ReactiveController, + type ReactiveControllerHost, + type TemplateResult, +} from "lit"; +import { ref } from "lit/directives/ref.js"; +import { repeat } from "lit/directives/repeat.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { McpAppUnmountGate } from "../../../components/mcp-app-unmount.ts"; +import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; +import { + CHAT_TRANSCRIPT_END_THRESHOLD_PX, + getChatSessionScrollPosition, + saveChatSessionScrollPosition, + type ChatSessionScrollPosition, +} from "../scroll.ts"; + +export type TranscriptRow = + | { kind: "item"; key: string; item: T } + | { kind: "content"; key: string; content: unknown }; + +export type TranscriptAnnouncement = { + key: string; + text: string; +}; + +export type ChatTranscriptSession = { + readonly liveAnnouncementText: string; + render( + rows: readonly TranscriptRow[], + renderRow: (row: TranscriptRow) => unknown, + announcement: TranscriptAnnouncement | null, + announce: boolean, + overlay?: unknown, + ): TemplateResult; + syncMessageRows(messageRowKeysById: ReadonlyMap): void; + revealMessage(messageId: string): boolean; + setContentReady(ready: boolean): void; + handleFocusIn(event: FocusEvent): void; + handleFocusOut(event: FocusEvent): void; +}; + +const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120; +const CHAT_TRANSCRIPT_OVERSCAN = 6; +// Initial virtual rows can correct their estimates for several frames. Hold a +// restored offset for ~200ms so those corrections cannot reapply the end anchor. +const CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES = 12; +// A committed short transcript can legitimately remain at maxOffset=0. Give +// initial measurement one second before treating that zero range as final. +const CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES = 60; +function initialTranscriptRect(host: ReactiveControllerHost) { + const width = host instanceof HTMLElement ? host.clientWidth : 0; + const height = host instanceof HTMLElement ? host.clientHeight : 0; + return { + width: width || (typeof window === "undefined" ? 0 : window.innerWidth), + height: height || (typeof window === "undefined" ? 0 : window.innerHeight), + }; +} + +function transcriptScrollMargin(element: Element | null): number { + if (!(element instanceof HTMLElement) || typeof getComputedStyle !== "function") { + return 0; + } + const margin = Number.parseFloat(getComputedStyle(element).paddingTop); + return Number.isFinite(margin) ? margin : 0; +} + +function initialTranscriptScrollMargin(host: ReactiveControllerHost): number { + return host instanceof HTMLElement + ? transcriptScrollMargin(host.querySelector(".chat-thread")) + : 0; +} + +class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscriptSession { + private readonly controllers = new Set(); + private readonly virtualizerController: VirtualizerController; + private threadInnerElement: HTMLDivElement | null = null; + private connected = false; + private observedWidth: number | null = null; + private observedHeight: number | null = null; + private contentReady = false; + private pendingScrollOffset: { + offset: number; + stableFrames: number; + zeroMaxFrames: number; + onSettled?: (position: ChatSessionScrollPosition) => void; + } | null = null; + private pendingScrollFrame: number | null = null; + // Lit calls refs before newly rendered nodes are connected. Resolve the + // scroll parent lazily or a stable ref can permanently capture null. + private get scrollElement(): HTMLDivElement | null { + const parent = this.threadInnerElement?.parentElement; + return parent instanceof HTMLDivElement ? parent : null; + } + // Stable Lit refs: inline arrows change identity per render, making Lit + // re-invoke them for every visible row and re-measure each row every render. + // Lit tracks the last element per callback, so each row needs its own. + private readonly scrollElementRef = (element?: Element) => { + this.threadInnerElement = element instanceof HTMLDivElement ? element : null; + }; + private readonly measureRowRefs = new Map void>(); + private pruneDetachedRowsQueued = false; + private pendingRowMeasureFrame: number | null = null; + private measureConnectedRows(): void { + // Only width invalidation owns forced DOM reads. Ordinary row refs stay on + // TanStack's observer path so resizeItem cannot perturb scroll restoration. + const instance = this.virtualizerController.getVirtualizer(); + for (const row of this.threadInnerElement?.querySelectorAll(".chat-virtual-row") ?? + []) { + instance.resizeItem( + instance.indexFromElement(row), + row[instance.options.horizontal ? "offsetWidth" : "offsetHeight"], + ); + } + } + private queueConnectedRowMeasure(): void { + if (this.pendingRowMeasureFrame !== null) { + return; + } + this.pendingRowMeasureFrame = requestAnimationFrame(() => { + this.pendingRowMeasureFrame = null; + this.measureConnectedRows(); + }); + } + private measureRowRefFor(key: string): (element?: Element) => void { + let callback = this.measureRowRefs.get(key); + if (!callback) { + callback = (element?: Element) => { + if (element instanceof HTMLElement) { + this.virtualizerController.getVirtualizer().measureElement(element); + return; + } + // Re-stamps (e.g. the chat<->dashboard face switch) re-invoke each + // stable row ref as an (undefined, element) pair while the new subtree + // is still detached. measureElement(null) prunes every disconnected + // row, so calling it synchronously unobserves just-registered sibling + // rows and freezes their heights at the old pane width (overlapping + // bubbles). Defer until the commit lands so only removed rows prune. + if (this.pruneDetachedRowsQueued) { + return; + } + this.pruneDetachedRowsQueued = true; + queueMicrotask(() => { + this.pruneDetachedRowsQueued = false; + this.virtualizerController.getVirtualizer().measureElement(null); + }); + }; + this.measureRowRefs.set(key, callback); + } + return callback; + } + private rowKeys: readonly string[] = []; + private rowIndexesByKey = new Map(); + private messageRowKeysById = new Map(); + private focusedRowKey: string | null = null; + private announcementInitialized = false; + private announcementKey: string | null = null; + private currentAnnouncementText = ""; + private readonly mcpAppUnmountGate = new McpAppUnmountGate(this); + + constructor( + private readonly host: ReactiveControllerHost, + initialOffset: number | null = null, + onInitialOffsetSettled?: (position: ChatSessionScrollPosition) => void, + ) { + this.virtualizerController = new VirtualizerController(this, { + count: 0, + getScrollElement: () => this.scrollElement, + estimateSize: () => CHAT_TRANSCRIPT_ESTIMATED_ROW_PX, + getItemKey: () => "", + initialRect: initialTranscriptRect(host), + initialOffset: initialOffset ?? Number.MAX_SAFE_INTEGER, + scrollMargin: initialTranscriptScrollMargin(host), + anchorTo: "end", + followOnAppend: false, + observeElementRect: (instance, callback) => + observeElementRect(instance, (rect) => { + const previousHeight = this.observedHeight; + const widthChanged = this.observedWidth !== null && this.observedWidth !== rect.width; + const heightChanged = previousHeight !== null && previousHeight !== rect.height; + const scrollOffset = instance.scrollOffset; + const wasAtEndBeforeResize = + heightChanged && + this.pendingScrollOffset === null && + scrollOffset !== null && + instance.getTotalSize() - previousHeight - scrollOffset <= + CHAT_TRANSCRIPT_END_THRESHOLD_PX; + this.observedWidth = rect.width; + this.observedHeight = rect.height; + this.syncScrollMargin(instance.scrollElement); + callback(rect); + if (wasAtEndBeforeResize) { + instance.scrollToEnd({ behavior: "auto" }); + } + if (widthChanged) { + // Cached offscreen sizes belong to the old wrapping width. Reset + // them, seed current rows, then repeat after any same-commit + // re-stamp has attached and completed layout. + instance.measure(); + this.measureConnectedRows(); + this.queueConnectedRowMeasure(); + } + }), + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range); + const focused = + this.focusedRowKey === null ? undefined : this.rowIndexesByKey.get(this.focusedRowKey); + if ( + focused === undefined || + focused < 0 || + focused >= range.count || + indexes.includes(focused) + ) { + return indexes; + } + return [...indexes, focused].toSorted((left, right) => left - right); + }, + scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX, + overscan: CHAT_TRANSCRIPT_OVERSCAN, + }); + if (initialOffset !== null) { + this.pendingScrollOffset = { + offset: initialOffset, + stableFrames: 0, + zeroMaxFrames: 0, + onSettled: onInitialOffsetSettled, + }; + } + } + + get updateComplete() { + return this.host.updateComplete; + } + + get liveAnnouncementText() { + return this.currentAnnouncementText; + } + + requestUpdate = () => { + this.host.requestUpdate(); + }; + + addController(controller: ReactiveController): void { + this.controllers.add(controller); + } + + removeController(controller: ReactiveController): void { + this.controllers.delete(controller); + } + + connect(): void { + if (this.connected) { + return; + } + this.connected = true; + for (const controller of this.controllers) { + controller.hostConnected?.(); + } + if (this.pendingScrollOffset) { + this.host.requestUpdate(); + } + } + + update(): void { + for (const controller of this.controllers) { + controller.hostUpdated?.(); + } + this.applyPendingScrollOffset(); + } + + disconnect(): void { + if (this.pendingRowMeasureFrame !== null) { + cancelAnimationFrame(this.pendingRowMeasureFrame); + this.pendingRowMeasureFrame = null; + } + if (this.pendingScrollFrame !== null) { + cancelAnimationFrame(this.pendingScrollFrame); + this.pendingScrollFrame = null; + } + if (!this.connected) { + this.threadInnerElement = null; + return; + } + this.connected = false; + for (const controller of this.controllers) { + controller.hostDisconnected?.(); + } + this.threadInnerElement = null; + } + + dispose(): void { + this.disconnect(); + this.measureRowRefs.clear(); + this.rowKeys = []; + this.rowIndexesByKey.clear(); + this.messageRowKeysById.clear(); + this.focusedRowKey = null; + this.pendingScrollOffset = null; + } + + render( + rows: readonly TranscriptRow[], + renderRow: (row: TranscriptRow) => unknown, + announcement: TranscriptAnnouncement | null, + announce: boolean, + overlay: unknown = nothing, + ): TemplateResult { + this.syncRows(rows); + this.syncAnnouncement(announcement, announce); + const virtualizer = this.virtualizerController.getVirtualizer(); + const virtualRows = virtualizer.getVirtualItems(); + const nextRowKeys = new Set( + virtualRows.flatMap((virtualRow) => { + const row = rows[virtualRow.index]; + return row ? [row.key] : []; + }), + ); + const rendered = html` +
+
+ ${overlay} + ${repeat( + virtualRows, + (virtualRow) => virtualRow.key, + (virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) { + return nothing; + } + return html` +
+ ${renderRow(row)} +
+ `; + }, + )} +
+
+ `; + return this.mcpAppUnmountGate.render(JSON.stringify([...nextRowKeys]), rendered, () => + this.threadInnerElement + ? [...this.threadInnerElement.querySelectorAll(".chat-virtual-row")].filter( + (row) => !nextRowKeys.has(row.dataset.virtualRowKey ?? ""), + ) + : [], + ) as TemplateResult; + } + + scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void { + this.virtualizerController.getVirtualizer().scrollToEnd(options); + } + + scrollToOffset(offset: number): void { + if (this.scrollElement) { + this.scrollElement.scrollTop = offset; + } + this.virtualizerController.getVirtualizer().scrollToOffset(offset); + } + + syncMessageRows(messageRowKeysById: ReadonlyMap): void { + this.messageRowKeysById = new Map(messageRowKeysById); + } + + revealMessage(messageId: string): boolean { + const rowKey = this.messageRowKeysById.get(messageId); + if (!rowKey) { + return false; + } + const rowIndex = this.rowIndexesByKey.get(rowKey); + if (rowIndex === undefined) { + return false; + } + this.virtualizerController.getVirtualizer().scrollToIndex(rowIndex, { align: "center" }); + this.host.requestUpdate(); + void this.host.updateComplete.then(() => { + const bubble = [ + ...(this.threadInnerElement?.querySelectorAll(".chat-bubble") ?? []), + ].find((candidate) => candidate.dataset.entryId === messageId); + if (!bubble) { + return; + } + this.threadInnerElement + ?.querySelector(".chat-bubble--reply-target") + ?.classList.remove("chat-bubble--reply-target"); + bubble.scrollIntoView?.({ behavior: "smooth", block: "center" }); + bubble.classList.add("chat-bubble--reply-target"); + bubble.addEventListener( + "animationend", + () => bubble.classList.remove("chat-bubble--reply-target"), + { once: true }, + ); + }); + return true; + } + + getScrollOffset(): number | null { + return this.scrollElement?.scrollTop ?? null; + } + + getMaxScrollOffset(): number | null { + const scrollElement = this.scrollElement; + return scrollElement + ? Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight) + : null; + } + + setContentReady(ready: boolean): void { + this.contentReady = ready; + } + + restoreScrollOffset( + offset: number, + onSettled?: (position: ChatSessionScrollPosition) => void, + ): void { + this.pendingScrollOffset = { offset, stableFrames: 0, zeroMaxFrames: 0, onSettled }; + if (this.connected) { + this.host.requestUpdate(); + } + } + + getPendingScrollOffset(): number | null { + return this.pendingScrollOffset?.offset ?? null; + } + + handleFocusIn(event: FocusEvent): void { + this.focusedRowKey = this.rowKeyFromEvent(event); + } + + handleFocusOut(event: FocusEvent): void { + this.focusedRowKey = this.rowKeyFromEvent(event, event.relatedTarget); + } + + private rowKeyFromEvent(event: FocusEvent, target: EventTarget | null = event.target) { + if (!(target instanceof Element) || !this.scrollElement?.contains(target)) { + return null; + } + const row = target.closest(".chat-virtual-row[data-virtual-row-key]"); + if (!row || !this.scrollElement.contains(row)) { + return null; + } + return row.dataset.virtualRowKey || null; + } + + private syncAnnouncement(announcement: TranscriptAnnouncement | null, announce: boolean): void { + if (!this.announcementInitialized || !announce) { + this.announcementInitialized = true; + this.announcementKey = announcement?.key ?? null; + this.currentAnnouncementText = ""; + return; + } + if (!announcement || announcement.key === this.announcementKey) { + return; + } + this.announcementKey = announcement.key; + this.currentAnnouncementText = announcement.text; + } + + private syncRows(rows: readonly TranscriptRow[]): void { + const nextKeys = rows.map((row) => row.key); + if ( + nextKeys.length === this.rowKeys.length && + nextKeys.every((key, index) => key === this.rowKeys[index]) + ) { + return; + } + this.rowKeys = Object.freeze(nextKeys); + this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index])); + for (const key of this.measureRowRefs.keys()) { + if (!this.rowIndexesByKey.has(key)) { + this.measureRowRefs.delete(key); + } + } + const keys = this.rowKeys; + const virtualizer = this.virtualizerController.getVirtualizer(); + virtualizer.setOptions({ + ...virtualizer.options, + count: keys.length, + getItemKey: (index) => keys[index] ?? `missing:${index}`, + }); + } + + private syncScrollMargin(scrollElement: HTMLDivElement | null): void { + const scrollMargin = transcriptScrollMargin(scrollElement); + const virtualizer = this.virtualizerController.getVirtualizer(); + if (scrollMargin === virtualizer.options.scrollMargin) { + return; + } + virtualizer.setOptions({ + ...virtualizer.options, + scrollMargin, + }); + } + + private applyPendingScrollOffset(): void { + const pending = this.pendingScrollOffset; + if (!pending || !this.connected) { + return; + } + const maxOffset = this.getMaxScrollOffset(); + if (maxOffset === null) { + if (this.contentReady && this.rowKeys.length === 0) { + this.settlePendingScroll(0); + } + return; + } + if (maxOffset === 0 && pending.offset > 0) { + if (this.contentReady && this.rowKeys.length === 0) { + this.settlePendingScroll(0); + } else if (this.contentReady) { + if (pending.zeroMaxFrames >= CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES) { + this.settlePendingScroll(0); + return; + } + pending.zeroMaxFrames += 1; + this.schedulePendingScrollRetry(); + } + return; + } + pending.zeroMaxFrames = 0; + const targetOffset = Math.min(pending.offset, maxOffset); + this.scrollToOffset(targetOffset); + const currentOffset = this.getScrollOffset(); + if (currentOffset != null && Math.abs(currentOffset - targetOffset) <= 1) { + if (pending.stableFrames >= CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES) { + this.settlePendingScroll(currentOffset); + } else { + pending.stableFrames += 1; + this.schedulePendingScrollRetry(); + } + } else { + pending.stableFrames = 0; + this.schedulePendingScrollRetry(); + } + } + + private schedulePendingScrollRetry(): void { + if (!this.connected || this.pendingScrollFrame !== null) { + return; + } + this.pendingScrollFrame = requestAnimationFrame(() => { + this.pendingScrollFrame = null; + if (this.connected && this.pendingScrollOffset) { + this.host.requestUpdate(); + } + }); + } + + private settlePendingScroll(scrollTop: number): void { + const pending = this.pendingScrollOffset; + this.pendingScrollOffset = null; + if (!pending) { + return; + } + const maxScrollTop = this.getMaxScrollOffset(); + pending.onSettled?.({ + scrollTop, + anchorToEnd: + maxScrollTop === null + ? this.contentReady && this.rowKeys.length === 0 + : maxScrollTop - scrollTop <= CHAT_TRANSCRIPT_END_THRESHOLD_PX, + }); + } +} + +export class ChatTranscriptController implements ReactiveController { + private activeSessionKey: string | null = null; + private sessionVirtualizer: ChatSessionVirtualizerHost | null = null; + private connected = false; + + constructor(private readonly host: ReactiveControllerHost) { + host.addController(this); + } + + get renderedSessionKey(): string | null { + return this.activeSessionKey; + } + + renderSession( + paneId: string, + sessionKey: string, + render: (transcript: ChatTranscriptSession) => TemplateResult, + ): TemplateResult { + if ( + !this.sessionVirtualizer || + this.activeSessionKey === null || + !areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey) + ) { + this.sessionVirtualizer?.dispose(); + const savedPosition = getChatSessionScrollPosition(paneId, sessionKey); + const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? null); + this.activeSessionKey = sessionKey; + this.sessionVirtualizer = new ChatSessionVirtualizerHost( + this.host, + initialOffset, + initialOffset === null + ? undefined + : (position) => { + saveChatSessionScrollPosition(paneId, sessionKey, position); + }, + ); + if (this.connected) { + this.sessionVirtualizer.connect(); + } + } + return render(this.sessionVirtualizer); + } + + scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void { + this.sessionVirtualizer?.scrollToEnd(options); + } + + scrollToOffset(offset: number, onSettled?: (position: ChatSessionScrollPosition) => void): void { + this.sessionVirtualizer?.restoreScrollOffset(offset, onSettled); + } + + revealMessage(messageId: string): boolean { + return this.sessionVirtualizer?.revealMessage(messageId) ?? false; + } + + pendingScrollOffsetFor(sessionKey: string): number | null { + return this.activeSessionKey !== null && + areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey) + ? (this.sessionVirtualizer?.getPendingScrollOffset() ?? null) + : null; + } + + handleFocusIn(event: FocusEvent): void { + this.sessionVirtualizer?.handleFocusIn(event); + } + + handleFocusOut(event: FocusEvent): void { + this.sessionVirtualizer?.handleFocusOut(event); + } + + hostConnected(): void { + this.connected = true; + this.sessionVirtualizer?.connect(); + } + + hostUpdated(): void { + this.sessionVirtualizer?.update(); + } + + hostDisconnected(): void { + this.connected = false; + this.sessionVirtualizer?.disconnect(); + } +} diff --git a/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts b/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts new file mode 100644 index 000000000000..3cae65d308eb --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts @@ -0,0 +1,507 @@ +/* @vitest-environment jsdom */ + +import { expectDefined } from "@openclaw/normalization-core"; +import { render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardProvider } from "../../../lib/board/provider.ts"; +import { resolveAssistantAttachmentAuthToken } from "../chat-pane-state.ts"; +import { createTestChatPane } from "../chat-pane.test-support.ts"; +import * as chatThreadBuild from "../chat-thread-build.ts"; +import { + buildCachedChatItems, + getExpandedToolCards, + getExpandedUserMessages, + getExpansionStateVersion, +} from "../chat-thread.ts"; +import { createTestTranscript } from "../chat-view.test-helpers.ts"; +import { + isChatMediaResourceCurrent, + observeChatMediaResource, + releaseChatMediaResourceSubscriber, +} from "./chat-message-media.ts"; +import { resetTranscriptSession } from "./chat-thread-interactions.ts"; +import { renderChatThread } from "./chat-thread.ts"; +import { + flushDeferredRowPrune, + installTranscriptDomMocks, + resetTranscriptTestDom, + threadProps, +} from "./chat-transcript.test-support.ts"; + +describe("chat transcript invalidation", () => { + beforeEach(installTranscriptDomMocks); + afterEach(resetTranscriptTestDom); + + it("keeps built row identities across an A to B to A presentation reset", () => { + const paneId = "pane-session-items"; + const messagesA = [{ role: "assistant", content: "session A", timestamp: 1_000 }]; + const messagesB = [{ role: "assistant", content: "session B", timestamp: 2_000 }]; + const stableInputs = { + paneId, + runId: null, + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + showToolCalls: true, + }; + const buildSpy = vi.spyOn(chatThreadBuild, "buildChatItems"); + const itemsA = buildCachedChatItems({ + ...stableInputs, + sessionKey: "agent:main:session-a", + messages: messagesA, + }); + + resetTranscriptSession(paneId); + buildCachedChatItems({ + ...stableInputs, + sessionKey: "agent:main:session-b", + messages: messagesB, + }); + resetTranscriptSession(paneId); + const restoredItemsA = buildCachedChatItems({ + ...stableInputs, + sessionKey: "agent:main:session-a", + messages: messagesA, + }); + + expect(buildSpy).toHaveBeenCalledTimes(2); + expect(restoredItemsA).toBe(itemsA); + expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true); + }); + + it("rebinds guarded transcript images when the gateway rotates its auth token", async () => { + const NativeUrl = URL; + const blobUrl = `blob:transcript-media-${crypto.randomUUID()}`; + vi.stubGlobal( + "URL", + class extends NativeUrl { + static override createObjectURL = vi.fn(() => blobUrl); + static override revokeObjectURL = vi.fn(); + }, + ); + + let previousSignal: AbortSignal | undefined; + const fetchMock = vi.fn((_source: string, init?: RequestInit) => { + if (fetchMock.mock.calls.length === 1) { + return new Promise((_resolve, reject) => { + previousSignal = init?.signal ?? undefined; + previousSignal?.addEventListener( + "abort", + () => reject(new DOMException("media scope changed", "AbortError")), + { once: true }, + ); + }); + } + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["png"], { type: "image/png" }), + } as Response); + }); + vi.stubGlobal("fetch", fetchMock); + + const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`; + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const client = { + request: vi.fn(async () => null), + } as unknown as Parameters[0]["client"]; + const sessions = {} as Parameters[0]["sessions"]; + const { pane, state } = createTestChatPane({ client, sessions }); + state.hello = { + auth: { deviceToken: "test-auth-token" }, + } as typeof state.hello; + const messages = [ + { + role: "assistant", + content: [{ type: "image", url: source }], + timestamp: 1_000, + }, + ]; + const renderPane = () => { + render( + renderChatThread( + { + ...threadProps("pane-gateway-media-auth", state.sessionKey, messages), + assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state), + onRequestUpdate: renderPane, + }, + transcript, + ), + container, + ); + transcript.hostUpdated(); + }; + state.requestUpdate = renderPane; + + renderPane(); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const thumbnailSource = source.replace(/\/full$/u, "/thumbnail"); + const previousResource = observeChatMediaResource( + "managed-image", + `${thumbnailSource}::test-auth-token::`, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(previousResource.subscribers.size).toBe(1); + + pane.applyGatewaySnapshot({ + ...pane.context.gateway.snapshot, + client, + phase: "connected", + hello: { + ...pane.context.gateway.snapshot.hello, + auth: { deviceToken: "test-token" }, + } as typeof pane.context.gateway.snapshot.hello, + }); + expect(previousSignal?.aborted).toBe(true); + expect(isChatMediaResourceCurrent(previousResource)).toBe(false); + await flushDeferredRowPrune(); + + const nextResource = observeChatMediaResource( + "managed-image", + `${thumbnailSource}::test-token::`, + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe( + "Bearer test-token", + ); + expect(isChatMediaResourceCurrent(nextResource)).toBe(true); + expect(nextResource.subscribers.size).toBe(1); + expect(container.querySelector(".chat-message-image")?.src).toBe(blobUrl); + + releaseChatMediaResourceSubscriber(renderPane); + transcript.hostDisconnected(); + }); + + it("reconciles guarded local attachments when pane preview roots change", async () => { + let previousSignal: AbortSignal | undefined; + const fetchMock = vi.fn((_source: string, init?: RequestInit) => { + if (fetchMock.mock.calls.length === 1) { + return new Promise((_resolve, reject) => { + previousSignal = init?.signal ?? undefined; + previousSignal?.addEventListener( + "abort", + () => reject(new DOMException("preview roots changed", "AbortError")), + { once: true }, + ); + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + available: true, + mediaTicket: "root-restored-ticket", + mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(), + }), + } as Response); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = { + request: vi.fn(async () => null), + } as unknown as Parameters[0]["client"]; + const sessions = {} as Parameters[0]["sessions"]; + const { pane, state } = createTestChatPane({ client, sessions }); + const configPane = pane as typeof pane & { + applyApplicationConfig: (config: typeof pane.context.config.current) => void; + }; + state.hello = { + auth: { deviceToken: "test-auth-token" }, + } as typeof state.hello; + state.localMediaPreviewRoots = ["/tmp/openclaw"]; + state.embedSandboxMode = "scripts"; + state.allowExternalEmbedUrls = false; + + const source = `/tmp/openclaw/${crypto.randomUUID()}.pdf`; + const messages = [ + { + role: "assistant", + content: `Local document\nMEDIA:${source}`, + timestamp: 1_000, + }, + ]; + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const renderPane = () => { + render( + renderChatThread( + { + ...threadProps("pane-local-media-roots", state.sessionKey, messages), + assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state), + localMediaPreviewRoots: state.localMediaPreviewRoots, + onRequestUpdate: renderPane, + }, + transcript, + ), + container, + ); + transcript.hostUpdated(); + }; + state.requestUpdate = renderPane; + + renderPane(); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const previousResource = observeChatMediaResource( + "assistant-attachment", + `::test-auth-token::${source}`, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(previousResource.subscribers.size).toBe(1); + + const config = { + ...pane.context.config.current, + localMediaPreviewRoots: ["/tmp/elsewhere"], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + }; + configPane.applyApplicationConfig(config); + await flushDeferredRowPrune(); + + expect(previousSignal?.aborted).toBe(true); + expect(isChatMediaResourceCurrent(previousResource)).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + container.querySelector(".chat-assistant-attachment-card__reason")?.textContent, + ).toContain("Outside allowed folders"); + + configPane.applyApplicationConfig({ + ...config, + localMediaPreviewRoots: ["/tmp/openclaw"], + }); + await flushDeferredRowPrune(); + + const restoredResource = observeChatMediaResource( + "assistant-attachment", + `::test-auth-token::${source}`, + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe( + "Bearer test-auth-token", + ); + expect(isChatMediaResourceCurrent(restoredResource)).toBe(true); + expect(restoredResource.subscribers.size).toBe(1); + expect( + container.querySelector(".chat-assistant-attachment-card__link")?.getAttribute("href"), + ).toContain("mediaTicket=root-restored-ticket"); + + releaseChatMediaResourceSubscriber(renderPane); + transcript.hostDisconnected(); + }); + + it("updates MCP App pinning when the same provider's capability changes", async () => { + const provider = { + sessionKey: "agent:main:main", + canPinWidgets: true, + canPinMcpApps: false, + pinMcpApp: vi.fn(async () => undefined), + snapshot$: { + value: { + sessionKey: "agent:main:main", + revision: 1, + tabs: [], + widgets: [], + }, + subscribe: () => () => undefined, + }, + }; + const props = { + ...threadProps("pane-mcp-capability"), + boardProvider: provider as unknown as BoardProvider, + messages: [ + { + role: "assistant", + timestamp: 1_000, + content: [ + { type: "text", text: "Here is the dashboard app." }, + { + type: "canvas", + preview: { + kind: "canvas", + surface: "assistant_message", + render: "url", + title: "Dashboard app", + viewId: "outer-view-must-not-be-pinned", + mcpApp: { + viewId: "view-dashboard-app", + serverName: "dashboard", + toolName: "show", + uiResourceUri: "ui://dashboard/app.html", + toolCallId: "call-dashboard-app", + originSessionKey: "agent:main:main", + }, + }, + }, + ], + }, + ], + }; + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + expect(container.querySelector('[data-content-kind="mcp-app"]')).not.toBeNull(); + expect(container.querySelector("[data-pin-widget]")).toBeNull(); + + provider.canPinMcpApps = true; + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + + expect(container.querySelector("[data-pin-widget]")).not.toBeNull(); + expect(provider.snapshot$.value.revision).toBe(1); + + provider.canPinMcpApps = false; + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + + expect(container.querySelector("[data-pin-widget]")).toBeNull(); + expect(provider.snapshot$.value.revision).toBe(1); + }); + + it("keeps mounted disclosure handlers attached to recreated session expansion maps", () => { + const sessionKey = "retained-session"; + const props = { + ...threadProps("retained-pane", sessionKey, [ + { role: "user", content: "long user message ".repeat(100), timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "assistant reply" }, + { type: "toolcall", id: "retained-call", name: "browser.open" }, + ], + timestamp: 2, + }, + ]), + showToolCalls: true, + }; + const controller = createTestTranscript(); + const retainedPane = document.body.appendChild(document.createElement("div")); + render(renderChatThread(props, controller), retainedPane); + const staleTools = getExpandedToolCards(sessionKey); + const staleUsers = getExpandedUserMessages(sessionKey); + const previousToolVersion = getExpansionStateVersion(staleTools); + const previousUserVersion = getExpansionStateVersion(staleUsers); + + for (let index = 0; index < 20; index += 1) { + const alternatePane = document.body.appendChild(document.createElement("div")); + render( + renderChatThread( + { + ...props, + paneId: `alternate-pane-${index}`, + sessionKey: `alternate-session-${index}`, + }, + createTestTranscript(), + ), + alternatePane, + ); + } + + render(renderChatThread(props, controller), retainedPane); + const currentTools = getExpandedToolCards(sessionKey); + const currentUsers = getExpandedUserMessages(sessionKey); + expect(currentTools).not.toBe(staleTools); + expect(currentUsers).not.toBe(staleUsers); + expect(getExpansionStateVersion(currentTools)).toBe(previousToolVersion); + expect(getExpansionStateVersion(currentUsers)).toBe(previousUserVersion); + const toolCardId = expectDefined(currentTools.keys().next().value, "retained tool card"); + expectDefined( + retainedPane.querySelector( + ".chat-group.user .chat-message-disclosure__toggle", + ), + "mounted user disclosure", + ).click(); + expectDefined( + retainedPane.querySelector(".chat-tool-msg-summary"), + "mounted tool disclosure", + ).click(); + + expect(currentTools.get(toolCardId)).toBe(true); + expect(staleTools.get(toolCardId)).toBe(false); + expect(currentUsers.size).toBe(1); + expect(staleUsers.size).toBe(0); + + const toolVisibilitySession = "tool-visibility-session"; + const toolVisibilityProps = { + ...props, + paneId: "tool-visibility-pane", + sessionKey: toolVisibilitySession, + messages: [ + { role: "user", content: "tool visibility prompt", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "expanded-tool", + toolName: "browser.open", + content: "Expanded tool result", + timestamp: 2, + }, + { role: "assistant", content: "The first tool completed.", timestamp: 3 }, + { role: "user", content: "Show the next tool result.", timestamp: 4 }, + { + role: "toolResult", + toolCallId: "collapsed-tool", + toolName: "browser.open", + content: "Collapsed tool result", + timestamp: 5, + }, + ], + }; + const toolVisibilityController = createTestTranscript(); + const toolVisibilityPane = document.body.appendChild(document.createElement("div")); + const renderToolVisibility = (next = toolVisibilityProps) => + render(renderChatThread(next, toolVisibilityController), toolVisibilityPane); + renderToolVisibility(); + const visibilityState = getExpandedToolCards(toolVisibilitySession); + const visibilityIds = [...visibilityState.keys()].filter((key) => key.startsWith("toolmsg:")); + const expandedToolId = expectDefined(visibilityIds[0], "expanded standalone tool disclosure"); + const collapsedToolId = expectDefined(visibilityIds[1], "collapsed standalone tool disclosure"); + const disclosureButtons = () => + Array.from( + toolVisibilityPane.querySelectorAll(".chat-tool-msg-summary"), + ).filter((button) => !button.closest(".chat-tool-msg-body")); + expect(disclosureButtons()).toHaveLength(2); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "false", + "false", + ]); + expectDefined(disclosureButtons()[0], "first mounted tool disclosure").click(); + renderToolVisibility(); + expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); + renderToolVisibility(); + expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); + renderToolVisibility(); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "true", + "false", + ]); + + renderToolVisibility({ ...toolVisibilityProps, showToolCalls: false }); + expect(disclosureButtons()).toHaveLength(0); + renderToolVisibility(); + + expect(disclosureButtons()).toHaveLength(2); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "true", + "false", + ]); + expect(visibilityState.get(expandedToolId)).toBe(true); + expect(visibilityState.get(collapsedToolId)).toBe(false); + renderToolVisibility({ + ...toolVisibilityProps, + messages: toolVisibilityProps.messages.filter( + (message) => !("toolCallId" in message && message.toolCallId === "expanded-tool"), + ), + }); + expect(visibilityState.has(expandedToolId)).toBe(false); + expect(visibilityState.get(collapsedToolId)).toBe(false); + }); +}); diff --git a/ui/src/pages/chat/components/chat-transcript-projection.ts b/ui/src/pages/chat/components/chat-transcript-projection.ts new file mode 100644 index 000000000000..c02172584151 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-projection.ts @@ -0,0 +1,681 @@ +// Chat-item projection, expansion, reply hydration, and guarded row rendering. +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { html, nothing, type TemplateResult } from "lit"; +import { guard } from "lit/directives/guard.js"; +import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js"; +import { i18n } from "../../../i18n/index.ts"; +import type { MessageGroup } from "../../../lib/chat/chat-types.ts"; +import { extractTextCached } from "../../../lib/chat/message-extract.ts"; +import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalScopeConfigured, + parseAgentSessionKey, + resolveUiGlobalAliasAgentId, +} from "../../../lib/sessions/session-key.ts"; +import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts"; +import { + assistantGroupCanOwnActiveRunStatus, + assistantMessageExpansionSignature, + buildCachedChatItems, + coalesceActivityRuns, + coalesceStreamRuns, + collapseCompletedTurnWork, + getExpansionStateVersion, + getExpandedAssistantMessages, + getExpandedToolCards, + getExpandedUserMessages, + persistedMessageEntryId, + setExpansionState, + syncToolCardExpansionState, +} from "../chat-thread.ts"; +import { getToolTitlesVersion } from "../tool-titles.ts"; +import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; +import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; +import { resolveMessageGroupSenderLabel } from "./chat-message-group.ts"; +import { resolveMessageReplyText } from "./chat-message-markdown.ts"; +import { + getChatMediaRenderVersion, + renderActivityGroup, + renderMessageGroup, + renderStreamGroup, + renderWorkGroupSummary, + type MessageReplyTarget, + type StreamGroupOptions, + type StreamGroupPart, +} from "./chat-message.ts"; +import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts"; +import { + closeTranscriptSearch, + getTranscriptState, + type ChatThreadProps, + type ChatThreadState, +} from "./chat-thread-interactions.ts"; +import type { + ChatTranscriptSession, + TranscriptAnnouncement, + TranscriptRow, +} from "./chat-transcript-controller.ts"; +import { resolveAssistantDisplayAvatar } from "./chat-welcome.ts"; +import { renderTurnRecapRow } from "./chat-working-indicator.ts"; + +type ChatTranscriptProjection = { + isDirectThread: boolean; + isEmpty: boolean; + showLoadingSkeleton: boolean; + searchOpen: boolean; + renderRows: (overlay?: unknown) => TemplateResult; +}; + +type ChatRenderItem = ReturnType[number]; +const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500; + +type LoadedReplySource = { + rowKey: string; + preview: MessageReplyTarget & { sourceMessageId: string }; +}; + +function projectResolvedReplyPreview( + message: unknown, + replyToId: string, + props: Pick, +): LoadedReplySource["preview"] | undefined { + const normalized = normalizeMessage(message); + const text = resolveMessageReplyText(message); + if (!text) { + return undefined; + } + const group: MessageGroup = { + kind: "group", + key: replyToId, + role: normalized.role, + senderLabel: normalized.senderLabel, + ...(normalized.sender ? { sender: normalized.sender } : {}), + messages: [{ key: replyToId, message }], + timestamp: normalized.timestamp, + isStreaming: false, + }; + const sourceMessageId = persistedMessageEntryId(message) ?? replyToId; + return { + messageId: sourceMessageId, + sourceMessageId, + senderLabel: resolveMessageGroupSenderLabel(group, props), + text, + }; +} + +function latestTranscriptAnnouncement( + items: readonly ChatRenderItem[], +): TranscriptAnnouncement | null { + for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { + const item = items[itemIndex]; + if (!item || item.kind !== "group" || item.role.toLowerCase() !== "assistant") { + continue; + } + for (let messageIndex = item.messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = item.messages[messageIndex]?.message; + const text = extractTextCached(message)?.trim(); + if (text) { + return { + key: item.key, + text: truncateUtf16Safe(text, CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS), + }; + } + } + } + return null; +} + +function chatRenderItemGuardDependencies(item: ChatRenderItem): readonly unknown[] { + if (item.kind === "stream-run") { + return [item.key, ...item.parts]; + } + if (item.kind === "work-group") { + return [item.key, item.durationMs, item.hasError, ...item.groups]; + } + if (item.kind === "activity-run") { + return [item.key, ...item.groups]; + } + return [item]; +} + +function trackTranscriptRenderDependencies( + state: ChatThreadState, + dependencies: unknown[], +): unknown[] { + const previous = state.transcriptRenderDependencies; + const nextLength = dependencies.length - 1; + let changed = previous.length !== nextLength; + for (let index = 0; !changed && index < nextLength; index += 1) { + changed = !Object.is(previous[index], dependencies[index + 1]); + } + if (changed) { + // The first dependency is chatItems. Keep the shared context stable when + // only the live row changes, but invalidate every row for presentation changes. + state.transcriptRenderDependencies = dependencies.slice(1); + state.transcriptRenderContext = {}; + } + return dependencies; +} + +function guardChatRenderItems( + state: ChatThreadState, + // Live run status is not derivable from a row's own item identity: ownership + // is decided by sibling rows, and the usage counter ticks on run patches that + // touch nothing else. Rows showing status must re-render on both, or the + // memoized copy stacks a second claw row or freezes the token count. + liveStatus: (item: ChatRenderItem) => string, + render: (item: ChatRenderItem) => unknown, +) { + return (item: ChatRenderItem) => + guard( + [...chatRenderItemGuardDependencies(item), state.transcriptRenderContext, liveStatus(item)], + () => render(item), + ); +} + +export function projectChatTranscript( + props: ChatThreadProps, + transcript: ChatTranscriptSession, +): ChatTranscriptProjection { + const state = getTranscriptState(props.paneId); + const requestUpdate = props.onRequestUpdate ?? (() => {}); + const displayStream = props.stream ?? null; + const sessionHost = props.sessionHost ?? null; + // Equivalence, not exact match: the default session travels under alias + // keys ("main" vs "agent:main:main") depending on the caller. + const activeSession = props.sessions?.sessions?.find((row) => + areUiSessionKeysEquivalent(row.key, props.sessionKey), + ); + // Global-alias detection needs no session row: under configured global + // scope, agent::global and configured-main aliases route to the global + // stream even when the capped sessions list omits the canonical row (or it + // does not exist yet). The scope gate keeps per-sender main threads direct. + const isGlobalAliasKey = + parseAgentSessionKey(props.sessionKey)?.rest === "global" || + (sessionHost !== null && + isUiGlobalScopeConfigured(sessionHost) && + resolveUiGlobalAliasAgentId(sessionHost, props.sessionKey) !== null); + const reasoningLevel = activeSession?.reasoningLevel ?? "off"; + const showReasoning = props.showThinking && reasoningLevel !== "off"; + const assistantIdentity = { + name: props.assistantName, + avatar: resolveAssistantDisplayAvatar(props), + }; + const locale = i18n.getLocale(); + const searchFiltering = state.searchOpen && Boolean(state.searchQuery.trim()); + const chatItems = buildCachedChatItems({ + paneId: props.paneId, + sessionKey: props.sessionKey, + runId: props.runId === undefined ? (activeSession?.activeRunIds?.[0] ?? null) : props.runId, + locale, + messages: props.messages, + toolMessages: props.toolMessages, + streamSegments: props.streamSegments, + stream: displayStream, + streamStartedAt: props.streamStartedAt, + queue: props.queue, + showToolCalls: props.showToolCalls, + persistCommentary: props.persistCommentary, + runWorking: Boolean(props.runWorking), + runActive: Boolean(props.runActive), + planStatus: props.planStatus, + questionPrompts: props.questionPrompts, + loading: props.loading, + searchOpen: state.searchOpen, + searchQuery: state.searchQuery, + }); + syncToolCardExpansionState( + props.sessionKey, + chatItems, + Boolean(props.autoExpandToolCalls), + searchFiltering || !props.showToolCalls, + ); + const expandedToolCards = getExpandedToolCards(props.sessionKey); + const expandedUserMessages = getExpandedUserMessages(props.sessionKey); + const expandedAssistantMessages = getExpandedAssistantMessages(props.sessionKey); + const questionPrompts = new Map( + (props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]), + ); + const toggleToolCardExpanded = (toolCardId: string) => { + setExpansionState(expandedToolCards, toolCardId, !expandedToolCards.get(toolCardId)); + requestUpdate(); + }; + const toggleAssistantMessageExpanded = (messageId: string) => { + const current = expandedAssistantMessages.get(messageId); + if (current?.status === "loaded") { + expandedAssistantMessages.set(messageId, { + ...current, + expanded: !current.expanded, + revision: current.revision + 1, + }); + requestUpdate(); + return; + } + const loader = props.loadFullAssistantMessage; + if (!loader || current?.status === "loading") { + return; + } + const revision = (current?.revision ?? 0) + 1; + expandedAssistantMessages.set(messageId, { status: "loading", revision }); + requestUpdate(); + void loader({ + sessionKey: props.sessionKey, + ...(props.fullMessageAgentId ? { agentId: props.fullMessageAgentId } : {}), + messageId, + kind: "assistant_message", + }).then( + (result) => { + const pending = expandedAssistantMessages.get(messageId); + if (pending?.status !== "loading" || pending.revision !== revision) { + return; + } + const markdown = + result?.ok && result.message && typeof result.message === "object" + ? extractTextCached(result.message) + : null; + expandedAssistantMessages.set( + messageId, + markdown === null + ? { status: "error", revision: revision + 1 } + : { status: "loaded", expanded: true, markdown, revision: revision + 1 }, + ); + requestUpdate(); + }, + () => { + const pending = expandedAssistantMessages.get(messageId); + if (pending?.status !== "loading" || pending.revision !== revision) { + return; + } + expandedAssistantMessages.set(messageId, { status: "error", revision: revision + 1 }); + requestUpdate(); + }, + ); + }; + const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0; + const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation; + transcript.setContentReady(!props.loading); + // 1:1 sessions drop the avatar gutter entirely; group threads keep avatars + // as the always-visible identity marker. The canonical session kind decides; + // the sessions list is capped, so absent/unknown rows classify by key: + // global aliases first, then the same core key-shape helper the gateway + // uses. Message senderLabels are not a signal here: gateway sanitization + // labels 1:1 channel DM rows too. + const rowKind = activeSession?.kind; + const sessionKind = + rowKind && rowKind !== "unknown" + ? rowKind + : isGlobalAliasKey + ? "global" + : classifySessionKind(props.sessionKey); + // Only agent-solo kinds qualify: "global" aggregates every inbound context + // under session.scope="global" (including group/channel senders), so it + // keeps avatars like "group" and "unknown" do. An identity-resolving gateway + // (multi-user trusted proxy) also keeps them: several people share these + // sessions, so the author marker is signal, not decoration. + const isDirectThread = + (sessionKind === "direct" || sessionKind === "cron" || sessionKind === "spawn-child") && + !props.userId; + const showLoadingSkeleton = props.loading && chatItems.length === 0; + const threadContextWindow = + activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null; + const activeContinuationByGroupKey = new Map< + string, + { parts: StreamGroupPart[]; options: StreamGroupOptions } + >(); + const turnRecapByGroupKey = new Map(); + const loadedReplySources = new Map(); + const resolvedReplyPreviews = new Map(); + const resolveReplyPreview = (replyToId: string) => { + const loaded = loadedReplySources.get(replyToId)?.preview; + if (loaded) { + return loaded; + } + if (resolvedReplyPreviews.has(replyToId)) { + return resolvedReplyPreviews.get(replyToId); + } + const message = props.replyMessageAccess?.read(replyToId); + const preview = message ? projectResolvedReplyPreview(message, replyToId, props) : undefined; + resolvedReplyPreviews.set(replyToId, preview); + return preview; + }; + const sharedMessageRenderOptions = { + onOpenSidebar: props.onOpenSidebar, + sessionKey: props.sessionKey, + boardProvider: props.boardProvider, + agentId: props.fullMessageAgentId, + runActive: props.runActive, + onOpenWorkspaceFile: props.onOpenWorkspaceFile, + onRequestUpdate: requestUpdate, + basePath: props.basePath, + localMediaPreviewRoots: props.localMediaPreviewRoots ?? [], + assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null, + resolveArtifactDownload: props.resolveArtifactDownload, + onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, + onRequestOpenImage: props.onRequestOpenImage, + onOpenImage: props.onOpenImage, + canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, + embedSandboxMode: props.embedSandboxMode ?? "scripts", + allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, + showAssistantAvatar: false, + } satisfies StreamGroupOptions; + const streamGroupOptions = { + ...sharedMessageRenderOptions, + assistant: assistantIdentity, + } satisfies StreamGroupOptions; + const renderGroupOptions = (item: MessageGroup) => { + const lastMessage = item.messages.at(-1)?.message; + const rewindEntryId = + item.role.toLowerCase() === "user" && lastMessage + ? persistedMessageEntryId(lastMessage) + : null; + return { + ...sharedMessageRenderOptions, + showReasoning, + showToolCalls: props.showToolCalls, + autoExpandToolCalls: Boolean(props.autoExpandToolCalls), + isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId), + onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => { + setExpansionState( + expandedToolCards, + messageId, + !(expanded ?? expandedToolCards.get(messageId) ?? false), + ); + requestUpdate(); + }, + isUserMessageExpanded: (messageId: string) => expandedUserMessages.get(messageId) ?? false, + onToggleUserMessageExpanded: (messageId: string) => { + setExpansionState(expandedUserMessages, messageId, !expandedUserMessages.get(messageId)); + requestUpdate(); + }, + loadFullAssistantMessage: props.loadFullAssistantMessage ?? undefined, + getAssistantMessageExpansion: (messageId: string) => expandedAssistantMessages.get(messageId), + onToggleAssistantMessageExpanded: toggleAssistantMessageExpanded, + isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false, + onToggleToolExpanded: toggleToolCardExpanded, + assistantName: props.assistantName, + assistantAvatar: assistantIdentity.avatar, + userId: props.userId ?? null, + userName: props.userName ?? null, + userAvatar: props.userAvatar ?? null, + showAvatarGutter: !isDirectThread, + contextWindow: threadContextWindow, + onReply: props.onSetReply + ? (target) => state.transcriptRenderContext.onSetReply?.(target) + : undefined, + resolveReplyPreview, + onResolveReply: props.replyMessageAccess?.request, + onOpenReply: (replyToId: string) => state.transcriptRenderContext.onOpenReply?.(replyToId), + replyNavigationId: props.replyMessageAccess?.navigationId, + onRewind: + rewindEntryId && props.onRewindMessage + ? () => { + void Promise.resolve(props.onRewindMessage?.(rewindEntryId)).then((rewound) => { + if (rewound) { + props.onFocusComposer?.(); + } + }); + } + : undefined, + rewindDisabled: Boolean(props.runActive || props.runWorking), + activeContinuation: activeContinuationByGroupKey.get(item.key), + turnRecap: turnRecapByGroupKey.get(item.key), + } satisfies Parameters[1]; + }; + const renderGroupItem = (item: MessageGroup) => { + return renderMessageGroup(item, renderGroupOptions(item)); + }; + // Only the working indicator shows live usage, so rows without one keep + // memoizing across usage patches. + const workingUsageKey = `usage:${props.runOutputTokens ?? ""}`; + const liveStatusSignature = (item: ChatRenderItem): string => { + if (item.kind === "stream-run") { + return item.parts.some((part) => part.kind === "reading-indicator") ? workingUsageKey : ""; + } + if (item.kind !== "group") { + return ""; + } + const continuation = activeContinuationByGroupKey.get(item.key); + const recap = turnRecapByGroupKey.get(item.key); + // Part keys stand in for the rest of the continuation: its remaining + // options mirror props that already invalidate every row through the + // shared render context. + const continuationKey = continuation + ? `${continuation.parts.map((part) => part.key).join(" ")}${workingUsageKey}` + : ""; + const recapKey = recap ? `${recap.runtimeMs}:${recap.outputTokens ?? ""}` : ""; + return `${continuationKey}|${recapKey}`; + }; + const renderItem = guardChatRenderItems(state, liveStatusSignature, (item) => { + if (item.kind === "divider") { + return renderChatDivider(item, props.onOpenSessionCheckpoints); + } + if (item.kind === "notice") { + return renderChatNotice(item); + } + if (item.kind === "stream-run") { + return renderStreamGroup(item.parts, { + ...streamGroupOptions, + questionPrompts, + planStatus: props.planStatus, + planActive: Boolean(props.runActive), + startupPhase: props.startupStatus?.phase, + waitingApproval: props.waitingApproval, + runOutputTokens: props.runOutputTokens, + }); + } + if (item.kind === "work-group") { + const workExpanded = expandedToolCards.get(item.key) ?? item.hasError; + return html` + ${renderWorkGroupSummary(item, { + expanded: workExpanded, + onToggle: () => { + setExpansionState(expandedToolCards, item.key, !workExpanded); + requestUpdate(); + }, + })} + ${workExpanded ? item.groups.map((group) => renderGroupItem(group)) : nothing} + `; + } + if (item.kind === "activity-run") { + const firstGroup = item.groups[0]; + if (!firstGroup) { + return nothing; + } + if (item.groups.length === 1) { + return renderGroupItem(firstGroup); + } + return renderActivityGroup(item.groups, renderGroupOptions(firstGroup)); + } + if (item.kind === "group") { + return renderGroupItem(item); + } + if (item.kind === "question") { + return renderStreamGroup([item], { + questionPrompts, + }); + } + return nothing; + }); + const collapsedItems = coalesceActivityRuns( + collapseCompletedTurnWork(coalesceStreamRuns(chatItems), { + sessionKey: props.sessionKey, + runWorking: Boolean(props.runWorking), + searchActive: searchFiltering, + }), + { searchActive: searchFiltering }, + ); + // Watch/settle on actual indicator visibility (not runWorking): queued + // sends show the claw before the run starts, and the recap must never + // stack under a visible working row. + const workingIndicatorVisible = chatItems.some((item) => item.kind === "reading-indicator"); + const turnRecap = resolveTurnRecap(props.sessionKey, workingIndicatorVisible, activeSession); + const transcriptItems = collapsedItems.filter((item, index) => { + if (item.kind !== "stream-run") { + return true; + } + const previous = collapsedItems[index - 1]; + const isActiveStatusRun = + item.parts.some((part) => part.kind === "reading-indicator") && + item.parts.every((part) => part.kind === "reading-indicator" || part.kind === "plan"); + if ( + previous?.kind !== "group" || + !isActiveStatusRun || + !assistantGroupCanOwnActiveRunStatus(previous) + ) { + return true; + } + // A reply and its still-running state are one turn-level presentation. + // Keeping the status in the reply avoids a second claw/assistant row. + activeContinuationByGroupKey.set(previous.key, { + parts: item.parts, + options: { + ...streamGroupOptions, + planStatus: props.planStatus, + planActive: Boolean(props.runActive), + startupPhase: props.startupStatus?.phase, + waitingApproval: props.waitingApproval, + runOutputTokens: props.runOutputTokens, + }, + }); + return false; + }); + for (const item of transcriptItems) { + if (item.kind !== "group") { + continue; + } + const senderLabel = resolveMessageGroupSenderLabel(item, { + assistantName: props.assistantName, + userId: props.userId, + userName: props.userName, + userAvatar: props.userAvatar, + }); + for (const source of item.messages) { + const sourceMessageId = persistedMessageEntryId(source.message); + const text = resolveMessageReplyText(source.message); + if (sourceMessageId && text) { + loadedReplySources.set(sourceMessageId, { + rowKey: item.key, + preview: { + messageId: source.key, + sourceMessageId, + senderLabel, + text, + }, + }); + } + } + } + transcript.syncMessageRows( + new Map([...loadedReplySources].map(([messageId, source]) => [messageId, source.rowKey])), + ); + let turnRecapOwnerKey: string | null = null; + if (turnRecap !== null) { + const lastItem = transcriptItems.at(-1); + if (lastItem?.kind === "group" && assistantGroupCanOwnActiveRunStatus(lastItem)) { + turnRecapByGroupKey.set(lastItem.key, turnRecap); + turnRecapOwnerKey = lastItem.key; + } + } + const transcriptRows: TranscriptRow[] = transcriptItems.map((item) => ({ + kind: "item", + key: item.key, + item, + })); + const realtimeConversation = renderRealtimeTalkConversation(props); + if (realtimeConversation !== nothing) { + transcriptRows.push({ + kind: "content", + key: "realtime-talk", + content: realtimeConversation, + }); + } + if (turnRecap !== null && turnRecapOwnerKey === null && !isEmpty && !showLoadingSkeleton) { + transcriptRows.push({ + kind: "content", + key: "turn-recap", + content: renderTurnRecapRow(turnRecap), + }); + } + const backgroundTasks = + !props.runWorking && !isEmpty && !showLoadingSkeleton + ? renderBackgroundTasksStatusRow(props.backgroundTasks) + : nothing; + if (backgroundTasks !== nothing) { + transcriptRows.push({ + kind: "content", + key: "background-tasks", + content: backgroundTasks, + }); + } + trackTranscriptRenderDependencies(state, [ + chatItems, + locale, + expandedToolCards, + getExpansionStateVersion(expandedToolCards), + expandedUserMessages, + getExpansionStateVersion(expandedUserMessages), + assistantMessageExpansionSignature(expandedAssistantMessages), + getChatMediaRenderVersion(), + // The host minute poll requests an update; this key crosses row guard() memoization. + Math.floor(Date.now() / 60_000), + getToolTitlesVersion(), + props.sessionKey, + props.boardProvider, + props.boardProvider?.canPinWidgets, + props.boardProvider?.canPinMcpApps, + props.boardProvider?.snapshot$.value.revision, + props.fullMessageAgentId, + Boolean(props.loadFullAssistantMessage), + showReasoning, + props.showToolCalls, + Boolean(props.runActive), + Boolean(props.runWorking), + props.startupStatus?.phase, + Boolean(props.waitingApproval), + props.planStatus, + props.questionPrompts, + Boolean(props.autoExpandToolCalls), + props.assistantName, + assistantIdentity.avatar, + props.userId, + props.userName, + props.userAvatar, + props.basePath, + (props.localMediaPreviewRoots ?? []).join("\u0000"), + props.assistantAttachmentAuthToken, + props.canvasPluginSurfaceUrl, + props.embedSandboxMode ?? "scripts", + props.allowExternalEmbedUrls ?? false, + threadContextWindow, + Boolean(props.onSetReply), + props.replyMessageAccess?.revision ?? 0, + props.replyMessageAccess?.navigationId ?? "", + turnRecap === null ? "" : `${turnRecap.runtimeMs}:${turnRecap.outputTokens ?? ""}`, + ]); + state.transcriptRenderContext.onSetReply = props.onSetReply; + state.transcriptRenderContext.onOpenReply = (replyToId) => { + if (loadedReplySources.has(replyToId)) { + transcript.revealMessage(replyToId); + return; + } + if (searchFiltering) { + closeTranscriptSearch(state, requestUpdate); + } + props.replyMessageAccess?.open(replyToId); + }; + return { + isDirectThread, + isEmpty, + showLoadingSkeleton, + searchOpen: state.searchOpen, + renderRows: (overlay: unknown = nothing) => + transcript.render( + transcriptRows, + (row) => (row.kind === "item" ? renderItem(row.item) : row.content), + latestTranscriptAnnouncement(collapsedItems), + props.announceTranscript !== false && !state.searchOpen && !props.loading, + overlay, + ), + }; +} diff --git a/ui/src/pages/chat/components/chat-transcript-render.test.ts b/ui/src/pages/chat/components/chat-transcript-render.test.ts new file mode 100644 index 000000000000..edf638f297af --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-render.test.ts @@ -0,0 +1,271 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestTranscript } from "../chat-view.test-helpers.ts"; +import { renderTranscriptSearch, toggleTranscriptSearch } from "./chat-thread-interactions.ts"; +import { renderChatThread } from "./chat-thread.ts"; +import { + flushDeferredRowPrune, + installTranscriptDomMocks, + resetTranscriptTestDom, + threadProps, +} from "./chat-transcript.test-support.ts"; + +describe("chat transcript rendering", () => { + beforeEach(installTranscriptDomMocks); + afterEach(resetTranscriptTestDom); + + it("resolves persisted replies to their source and highlights it on click", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-reply-preview", "agent:main:main", [ + { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }, + { + role: "user", + content: "Follow up", + __openclaw: { id: "reply-message", replyToId: "source-message" }, + timestamp: 2_000, + }, + ]); + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Molty"); + expect(preview?.textContent).toContain("The original answer"); + expect(preview?.textContent).not.toContain("source-message"); + + preview?.click(); + await Promise.resolve(); + + const sourceBubble = [...container.querySelectorAll(".chat-bubble")].find( + (bubble) => bubble.dataset.entryId === "source-message", + ); + expect(sourceBubble?.classList.contains("chat-bubble--reply-target")).toBe(true); + transcript.hostDisconnected(); + }); + + it("hydrates an unloaded reply preview without inserting its source row", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + let resolvedMessage: unknown = undefined; + const request = vi.fn(); + const open = vi.fn(); + const props = { + ...threadProps("pane-reply-hydration", "agent:main:main", [ + { + role: "user", + content: "Follow up", + __openclaw: { id: "reply-message", replyToId: "source-message" }, + timestamp: 2_000, + }, + ]), + replyMessageAccess: { + revision: 0, + navigationId: null, + read: () => resolvedMessage, + request, + open, + }, + }; + const rerender = () => { + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + }; + rerender(); + transcript.hostConnected(); + await flushDeferredRowPrune(); + + expect(request).toHaveBeenCalledWith("source-message"); + expect(container.querySelector("[data-entry-id='source-message']")).toBeNull(); + + resolvedMessage = { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }; + props.replyMessageAccess.revision += 1; + rerender(); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Molty"); + expect(preview?.textContent).toContain("The original answer"); + preview?.click(); + expect(open).toHaveBeenCalledWith("source-message"); + transcript.hostDisconnected(); + }); + + it("clears search before navigating to a filtered reply target", async () => { + const transcript = createTestTranscript(); + const searchContainer = document.body.appendChild(document.createElement("div")); + const threadContainer = document.body.appendChild(document.createElement("div")); + const open = vi.fn(); + const paneId = "pane-filtered-reply-navigation"; + const props = { + ...threadProps(paneId, "agent:main:main", [ + { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }, + { + role: "user", + content: "Follow up", + __openclaw: { + id: "reply-message", + replyToId: "source-message", + replyToPreview: { text: "The original answer", senderLabel: "Molty" }, + }, + timestamp: 2_000, + }, + ]), + replyMessageAccess: { + revision: 0, + navigationId: null, + read: () => undefined, + request: vi.fn(), + open, + }, + }; + const rerender = () => { + render(renderTranscriptSearch(paneId, rerender), searchContainer); + render( + renderChatThread({ ...props, onRequestUpdate: rerender }, transcript), + threadContainer, + ); + transcript.hostUpdated(); + }; + toggleTranscriptSearch(paneId, rerender); + rerender(); + transcript.hostConnected(); + const input = searchContainer.querySelector("input"); + expect(input).not.toBeNull(); + input!.value = "Follow up"; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await flushDeferredRowPrune(); + + expect(threadContainer.querySelector("[data-entry-id='source-message']")).toBeNull(); + const preview = threadContainer.querySelector( + ".chat-reply-preview--message", + ); + expect(preview).not.toBeNull(); + preview!.click(); + + expect(open).toHaveBeenCalledWith("source-message"); + expect(searchContainer.querySelector("input")).toBeNull(); + transcript.hostDisconnected(); + }); + + it("loads a truncated assistant message once and keeps the full text visible", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const loadFullAssistantMessage = vi.fn().mockResolvedValue({ + ok: true, + message: { role: "assistant", content: "Complete assistant content." }, + }); + function rerender() { + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + } + const props = { + ...threadProps("pane-assistant-expand", "agent:work:main", [ + { + role: "assistant", + content: "Preview\n...(truncated)...", + __openclaw: { id: "assistant-full-1" }, + timestamp: 1_000, + }, + ]), + fullMessageAgentId: "work", + loadFullAssistantMessage, + onRequestUpdate: rerender, + }; + rerender(); + transcript.hostConnected(); + transcript.hostUpdated(); + + await vi.waitFor(() => expect(container.textContent).toContain("Complete assistant content.")); + expect(loadFullAssistantMessage).toHaveBeenCalledOnce(); + expect(loadFullAssistantMessage).toHaveBeenCalledWith({ + sessionKey: "agent:work:main", + agentId: "work", + messageId: "assistant-full-1", + kind: "assistant_message", + }); + + expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull(); + expect(container.textContent).toContain("Complete assistant content."); + expect(loadFullAssistantMessage).toHaveBeenCalledOnce(); + transcript.hostDisconnected(); + }); + + it("keeps transport-cut assistant text as received when full content is unavailable", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const loadFullAssistantMessage = vi.fn().mockRejectedValue(new Error("offline")); + function rerender() { + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + } + const props = { + ...threadProps("pane-assistant-retry", "agent:main:main", [ + { + role: "assistant", + content: "Preview\n...(truncated)...", + __openclaw: { id: "assistant-retry-1" }, + timestamp: 1_000, + }, + ]), + loadFullAssistantMessage, + onRequestUpdate: rerender, + }; + rerender(); + transcript.hostConnected(); + transcript.hostUpdated(); + + await vi.waitFor(() => expect(loadFullAssistantMessage).toHaveBeenCalledOnce()); + expect(container.textContent).toContain("Preview"); + expect(container.textContent).toContain("...(truncated)..."); + expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull(); + transcript.hostDisconnected(); + }); + + it.each(["Enter", " "])("opens focused transcript file links with %j", async (key) => { + const transcript = createTestTranscript(); + const onOpenWorkspaceFile = vi.fn(); + const onHistoryIntent = vi.fn(); + const container = document.body.appendChild(document.createElement("div")); + const props = { + ...threadProps("pane-file-link", "agent:main:main", [ + { role: "assistant", content: "Inspect `src/chat.ts:17`", timestamp: 1_000 }, + ]), + onOpenWorkspaceFile, + onHistoryIntent, + }; + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const link = container.querySelector("a.markdown-file-link"); + link?.focus(); + expect(document.activeElement).toBe(link); + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + link?.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(onOpenWorkspaceFile).toHaveBeenCalledWith({ path: "src/chat.ts", line: 17 }); + expect(onHistoryIntent).not.toHaveBeenCalled(); + transcript.hostDisconnected(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-transcript.test-support.ts b/ui/src/pages/chat/components/chat-transcript.test-support.ts new file mode 100644 index 000000000000..64b6e43e50ff --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript.test-support.ts @@ -0,0 +1,121 @@ +import { vi } from "vitest"; +import { resetChatThreadState } from "../chat-thread.ts"; +import { resetThreadPresentation } from "./chat-thread-interactions.ts"; + +export const observedElements = new Set(); +export const resizeObservers = new Set(); +export const transcriptDomState = { measuredRowHeight: 100 }; + +class RecordingResizeObserver implements ResizeObserver { + private readonly targets = new Set(); + + constructor(private readonly callback: ResizeObserverCallback) { + resizeObservers.add(this); + } + + observe(target: Element): void { + this.targets.add(target); + observedElements.add(target); + } + + unobserve(target: Element): void { + this.targets.delete(target); + observedElements.delete(target); + } + + disconnect(): void { + for (const target of this.targets) { + observedElements.delete(target); + } + this.targets.clear(); + resizeObservers.delete(this); + } + + emit(width: number, height: number): void { + const entries = [...this.targets].map( + (target) => + ({ + target, + borderBoxSize: [{ inlineSize: width, blockSize: height }], + }) as unknown as ResizeObserverEntry, + ); + if (entries.length > 0) { + this.callback(entries, this); + } + } + + observes(target: Element): boolean { + return this.targets.has(target); + } +} + +const defaultMessages = [ + { role: "user", content: "message one", timestamp: 1_000 }, + { role: "assistant", content: "reply one", timestamp: 2_000 }, + { role: "user", content: "message two", timestamp: 3_000 }, + { role: "assistant", content: "reply two", timestamp: 4_000 }, +]; + +export function threadProps( + paneId: string, + sessionKey = "agent:main:main", + messages: unknown[] = defaultMessages, +) { + return { + paneId, + sessionKey, + loading: false, + messages, + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + queue: [], + showThinking: false, + showToolCalls: false, + sessions: null, + assistantName: "Molty", + assistantAvatar: null, + onDraftChange: () => {}, + onSend: () => {}, + }; +} + +export function transcriptRows(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(".chat-virtual-row")]; +} + +export async function flushDeferredRowPrune(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +export function installTranscriptDomMocks(): void { + observedElements.clear(); + resizeObservers.clear(); + transcriptDomState.measuredRowHeight = 100; + vi.stubGlobal("ResizeObserver", RecordingResizeObserver); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + () => transcriptDomState.measuredRowHeight, + ); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); +} + +export function resetTranscriptTestDom(): void { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetThreadPresentation(); + resetChatThreadState(); + document.body.replaceChildren(); +} diff --git a/ui/src/pages/chat/persisted-set.ts b/ui/src/pages/chat/persisted-set.ts deleted file mode 100644 index f652ef37d1eb..000000000000 --- a/ui/src/pages/chat/persisted-set.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getSafeLocalStorage } from "../../local-storage.ts"; - -export class PersistedSet { - protected values = new Set(); - - constructor( - private readonly key: string, - isValue: (value: unknown) => value is T, - ) { - try { - const parsed: unknown = JSON.parse(getSafeLocalStorage()?.getItem(key) ?? ""); - if (Array.isArray(parsed)) { - this.values = new Set(parsed.filter(isValue)); - } - } catch { - // Storage is optional and corrupt entries are ignored. - } - } - - has(value: T): boolean { - return this.values.has(value); - } - - protected add(value: T): void { - this.values.add(value); - this.save(); - } - - protected remove(value: T): void { - this.values.delete(value); - this.save(); - } - - clear(): void { - this.values.clear(); - this.save(); - } - - private save(): void { - try { - getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this.values])); - } catch { - // Storage is optional. - } - } -} diff --git a/ui/src/pages/chat/pinned-messages.ts b/ui/src/pages/chat/pinned-messages.ts deleted file mode 100644 index e16c3a299504..000000000000 --- a/ui/src/pages/chat/pinned-messages.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Control UI chat module implements pinned messages behavior. -import { PersistedSet } from "./persisted-set.ts"; - -const PREFIX = "openclaw:pinned:"; - -export class PinnedMessages extends PersistedSet { - constructor(sessionKey: string) { - super(PREFIX + sessionKey, (value): value is number => typeof value === "number"); - } - - get indices(): Set { - return this.values; - } - - pin(index: number): void { - this.add(index); - } - - unpin(index: number): void { - this.remove(index); - } - - toggle(index: number): void { - if (this.has(index)) { - this.unpin(index); - } else { - this.pin(index); - } - } -} From e30df720459b2f31fe035ab05f731ae371b133dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:50:56 -0700 Subject: [PATCH 016/165] fix(worker): keep source bundles npm-installable (#122430) --- packages/ai/package.json | 4 +- packages/ai/src/package-dependencies.test.ts | 5 +- pnpm-lock.yaml | 7 +- .../worker-environments/bundle.test.ts | 71 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index b7748a8b048c..1af4b08ceaa3 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -101,11 +101,13 @@ "@anthropic-ai/sdk": "0.115.0", "@google/genai": "2.13.0", "@mistralai/mistralai": "2.5.0", - "@openclaw/normalization-core": "workspace:*", "openai": "6.49.0", "partial-json": "0.1.7", "typebox": "1.3.6" }, + "devDependencies": { + "@openclaw/normalization-core": "workspace:*" + }, "engines": { "node": ">=22.19.0" }, diff --git a/packages/ai/src/package-dependencies.test.ts b/packages/ai/src/package-dependencies.test.ts index 3fccc9c2206f..4fd2ab7aba6f 100644 --- a/packages/ai/src/package-dependencies.test.ts +++ b/packages/ai/src/package-dependencies.test.ts @@ -44,7 +44,7 @@ async function productionImportsPackage(packageName: string): Promise { } describe("@openclaw/ai source dependency contract", () => { - it("declares normalization-core while production source imports it", async () => { + it("declares bundled normalization-core imports as a workspace dev dependency", async () => { const manifest = JSON.parse( await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"), ) as { @@ -53,6 +53,7 @@ describe("@openclaw/ai source dependency contract", () => { }; expect(await productionImportsPackage("@openclaw/normalization-core")).toBe(true); - expect(manifest.dependencies?.["@openclaw/normalization-core"]).toBe("workspace:*"); + expect(manifest.dependencies?.["@openclaw/normalization-core"]).toBeUndefined(); + expect(manifest.devDependencies?.["@openclaw/normalization-core"]).toBe("workspace:*"); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04deda2564f5..79f8a00f8f09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2167,9 +2167,6 @@ importers: '@mistralai/mistralai': specifier: 2.5.0 version: 2.5.0(@opentelemetry/api@1.9.1) - '@openclaw/normalization-core': - specifier: workspace:* - version: link:../normalization-core openai: specifier: 6.49.0 version: 6.49.0(@aws-sdk/credential-provider-node@3.972.72)(@smithy/hash-node@4.4.14)(@smithy/signature-v4@5.6.10)(ws@8.21.1)(zod@4.4.3) @@ -2179,6 +2176,10 @@ importers: typebox: specifier: 1.3.6 version: 1.3.6 + devDependencies: + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core packages/gateway-client: dependencies: diff --git a/src/gateway/worker-environments/bundle.test.ts b/src/gateway/worker-environments/bundle.test.ts index 34a48fe75ce5..edfad23d64b2 100644 --- a/src/gateway/worker-environments/bundle.test.ts +++ b/src/gateway/worker-environments/bundle.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as tar from "tar"; import { describe, expect, it, vi } from "vitest"; +import { runCommandWithTimeout } from "../../process/exec.js"; import { withTestDir } from "../../test-helpers/temp-dir.js"; import { createWorkerBundleProducer, @@ -246,6 +247,76 @@ describe("worker bundle producer", () => { }); }); + it("installs a source bundle when the AI workspace import is bundled", async () => { + await withTestDir({ prefix: "openclaw-worker-bundle-npm-install-" }, async (root) => { + const repoRoot = path.resolve(import.meta.dirname, "../../.."); + const aiManifest = JSON.parse( + await fs.readFile(path.join(repoRoot, "packages/ai/package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const dependencyFields = (["dependencies", "devDependencies"] as const).filter( + (field) => aiManifest[field]?.["@openclaw/normalization-core"] !== undefined, + ); + if (dependencyFields.length !== 1) { + throw new Error( + "@openclaw/ai must classify normalization-core in exactly one dependency field", + ); + } + const dependencyField = dependencyFields[0]!; + const normalizationCoreSpec = aiManifest[dependencyField]?.["@openclaw/normalization-core"]; + if (!normalizationCoreSpec?.startsWith("workspace:")) { + throw new Error("@openclaw/ai must use a workspace normalization-core dependency"); + } + const packageRoot = path.join(root, "package"); + await writeFixture(packageRoot, [["dist/entry.js", 'import "@openclaw/ai";\nexport {};\n']]); + await fs.writeFile( + path.join(packageRoot, "package.json"), + `${JSON.stringify({ + name: "openclaw", + version: "1.2.3", + type: "module", + files: ["dist/"], + dependencies: { "@openclaw/ai": "workspace:*" }, + })}\n`, + "utf8", + ); + const vendorSource = path.join(packageRoot, "node_modules/@openclaw/ai"); + await fs.mkdir(path.join(vendorSource, "dist"), { recursive: true }); + await fs.writeFile( + path.join(vendorSource, "package.json"), + `${JSON.stringify({ + name: "@openclaw/ai", + version: "1.2.3", + type: "module", + main: "./dist/index.js", + [dependencyField]: { "@openclaw/normalization-core": normalizationCoreSpec }, + })}\n`, + "utf8", + ); + await fs.writeFile(path.join(vendorSource, "dist/index.js"), "export {};\n", "utf8"); + + const bundle = await createWorkerBundleProducer({ + packageRoot, + cacheDir: path.join(root, "cache"), + }).prepare(); + const extractRoot = path.join(root, "extract"); + await fs.mkdir(extractRoot); + await tar.extract({ file: bundle.tarballPath, cwd: extractRoot }); + + const install = await runCommandWithTimeout( + ["npm", "install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], + { + cwd: extractRoot, + env: { NPM_CONFIG_CACHE: path.join(root, "npm-cache") }, + timeoutMs: 30_000, + }, + ); + expect(install.code, install.stderr).toBe(0); + }); + }); + it("fails closed when a dist-referenced workspace package is not installed", async () => { await withTestDir({ prefix: "openclaw-worker-bundle-vendor-missing-" }, async (root) => { const packageRoot = path.join(root, "package"); From 141f943a411cfbf5d1a91968652570d484d2ff93 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 10:25:47 +0530 Subject: [PATCH 017/165] fix(delivery): clear resolved ambiguity notices (#122438) Clear stale uncertainty debt when the same final reaches an authoritative terminal outcome, preventing false notices on the next inbound turn. Co-authored-by: Ayaan Zaidi --- src/infra/outbound/delivery-completion.test.ts | 2 +- src/infra/outbound/delivery-completion.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/infra/outbound/delivery-completion.test.ts b/src/infra/outbound/delivery-completion.test.ts index 0bbcbddbae49..fabeae0e79bf 100644 --- a/src/infra/outbound/delivery-completion.test.ts +++ b/src/infra/outbound/delivery-completion.test.ts @@ -136,7 +136,7 @@ describe("pending-final delivery completion", () => { it("does not owe a notice for the pre-dispatch claim or terminal outcomes", async () => { await installContextOnPendingFinal(); - // prepared -> unknown is the pre-I/O claim on every healthy send. + await settlePendingFinalDelivery(completion, "queued", ["prepared"]); await settlePendingFinalDelivery(completion, "unknown", ["prepared", "queued"]); await settlePendingFinalDelivery(completion, "delivered"); diff --git a/src/infra/outbound/delivery-completion.ts b/src/infra/outbound/delivery-completion.ts index 6cce7418562d..78a13b0241db 100644 --- a/src/infra/outbound/delivery-completion.ts +++ b/src/infra/outbound/delivery-completion.ts @@ -94,9 +94,6 @@ export async function settlePendingFinalDelivery( current === "suppressed" || (current === "unknown" && state === "unknown"); settled = terminal ? current : state; - // Unknown affirmed after a claimed send is ambiguity the user must hear - // about: record durable notice debt for the next same-route turn. The - // prepared->unknown transition is the pre-I/O claim and never owes one. const pending = internalEntry.pendingFinalDelivery; const existingNotice = internalEntry.pendingDeliveryNotice; const owedNotice = @@ -115,7 +112,13 @@ export async function settlePendingFinalDelivery( }, } : undefined; - if (settled === current && !owedNotice) { + const clearsNotice = + settled !== "queued" && + settled !== "unknown" && + existingNotice?.intentId === pending.intentId; + // The pre-I/O claim preserves crash-window ambiguity. Any authoritative + // fate for that intent must clear debt before a later turn can surface it. + if (settled === current && !owedNotice && !clearsNotice) { return null; } wakeRecovery = @@ -135,7 +138,7 @@ export async function settlePendingFinalDelivery( ...internalEntry.pendingFinalDelivery, deliveries: deliveries.with(index, { id: completion.deliveryId, state: settled }), }, - ...owedNotice, + ...(clearsNotice ? { pendingDeliveryNotice: undefined } : owedNotice), updatedAt: Date.now(), }; }, From 7ecad45a7dde8fc58ef3dfd543f7a646f0695a37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 21:57:46 -0700 Subject: [PATCH 018/165] refactor(context-engine): retire legacy host param default (#122434) --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-runtime.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../config-mutation.json | 2 +- .../config-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../model-session-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-auth.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/concepts/context-engine.md | 16 ++++----- docs/plugins/sdk-migration.md | 18 +++++----- docs/plugins/sdk-overview.md | 8 ++--- .../host-param-projection.test.ts | 36 +++---------------- src/context-engine/registry.ts | 12 +------ src/plugins/compat/registry-records.ts | 11 +++--- src/plugins/compat/registry.test.ts | 10 +++--- 28 files changed, 57 insertions(+), 96 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 57dac44fd419..ba22e9cb9f50 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"606504bb06b321f5a4fef507f322c297a9295dccdc2e5996ac64603049113408","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"48dad42d04caeb62f04f9dbe32b4562ecef695e9f0c97fb891040408400f37bd","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 2a8b60ee0264..69b4ae7e72f2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"40d137a31b2ac9f1da776aafcc77b54422b1610c91fe2d6594068e1f7285e91b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"3d1c84bcc585b57a93054889e16fb3be02ff4d599d7d2a0566df6f463f2254a8","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 61def1e61738..ce3ba2b55349 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"6667d863dee0c991c58196b0a77fa812fc1800fca9885c866abd04f3df1a03ce","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"fac71b2c80db2874419a589646fd560e8a8ad43dba93f5a6d26a73c171a0b4ba","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 156c909f3056..fdf18fa93eb5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"6257c43a60153049dac1af0d828435081f10f6bcd2554e2a67d1c8adb6df973c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"4245891bae7517cf001f637e80d1d813c11385d695eebbd69a61258e83399c4e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 876806d33a11..7dd5ca16f266 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"88113b5ae8119ca780a3d93b0e033c87d0c387b21a29b72e72b375f5c77f08b3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"f719599ed89a658109698bc1ef1309e1709e5ae3385b7ce3f1c583343ea23b33","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 12d12f0322ff..86450cb57afe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"a85b4bbcd416a76bde8d58af34fab58bdb416a1b2c8476b46a3ffd8540754e04","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"d531b32d0544c42f375f628bd7a6b3d3582a91078db37bc048af7c3f23a9e59f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 051fa3974425..b8ae76a44c49 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"12e07b9ba7b5b35c1f4e0f4510a073adac00671d292a72700025c86376db22b0","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"40b25681a1ee102cf1d2d5b23a29f96e2501ea521404deed59303e59c35089ec","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 41cf20d4e926..ff23b7afdb69 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"f36ace33d1e16649ed888032751d5db67e826cdc59f06732a5c13b5185f971ed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"67884fcf5fc91b8b40a3c83e83080e69f54f7750cdd780368af63363f84ddc40","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index 9106b41a163f..f7ad9d6ff24a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"b70eb302db749674e237eb2d740dacca2c20f883cd7f0581299cf010d3a71863","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"a7c63538d37122bfe240f75944916a3f01806f7fb7de3ecb976c6fd3f5dcd72e","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index 7123ed1c0dbf..6cc903f9d13d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"4339bf4856bafcbf691b25df94a564a9b7bde09c041b9c0b31df3636a6e58462","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"3f128cc41bb2e44b40402774acd3a967501c72ae5d7b2f9d149a262cc0d5a2da","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 775825f24dd0..12f84c8b1167 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"7451e2a5ffc615f6e8fedebbf4327caf3a64a30973828ca776ca8e298052eebf","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"ab54be164a2b0affece4d6c196ee64ae9259cac072d4ac6c32dd27daf4941565","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 4be0b091d72d..b8fce271f0c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"726e1cc6c7c0463333587b25908a0b4274c9ca2e1c7d696ab7af5cb392aeca07","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"21823a6741645b81797b4be6bf16a2d067e7e2a007177fbc5c7a6cab98b08027","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index d84bd6799495..430076fe058e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"da9273b6213137fdfc3c2f0f22de681c7bd664dfb4fd0fd0f7636b70cad9835b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"80686774b5023519466038e4a20604d9efce462b0cd2915ab5597fb1087ef8af","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d024143ffc6d..539f07550335 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"2328445ee010703050ecce3ea4b823881172b1da8ffd061cb149a7324ac3b967","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"9eb227dd27a3d08b868b1144bd64e1ff22da707af353699b9eb0f6438d4d98c1","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index de80d5630d4c..e1e954a02924 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"b1b9afd14967ec92dad01bc0a00997b578b9c4ca0f8601597fc58b503fc13606","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"ecb40006fc5ea974a3e31d0782c86e50c054e19164f864a564bb6f3f3d00dae9","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a7e49f33706f..ecfbe38df1a8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"cd50a67407a27b36d6f3e22af00940d1031bfe907fc15be5a72187e68f04d822","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"696f33ed5d8f059b82ff4159183b80f7e4af086abb2383bae5c02d909427fe43","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 79ea9f3b1493..3b669bec3c96 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"ba7ea5cfd334e44f5c5c2571a2161961a44f51e64e45074917fab3e69ac7652f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"411769ac67fd1a5b0ea4ada41b45678688e86194d03adc8d8efcad65c5f0c38c","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index e0d55cfede16..11124ace8d6d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"7233a8bb04605b4022cb18569b93f062d4f7986ae5ab5dd09e6c655984b56542","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"d56a974704a97ae438b03ba82aa84314e77bf65a7e7dd9096fd2ab2bc8cbcbec","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 8dbf79d536d2..22f4ce4ef442 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"6f6b852b66e41c6f2c15b617bc00148efbafc2d737049b19b45fe3b25eebb9fe","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"6539e277a49a27344f2844bcce06f609c5e463bb5a9be9eb1532994f9fec6479","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index e7cf05ffec50..06bfdb667c76 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"0686a3b02b9bae23e46af30fdddf46487db53ec6ffe0833ce9cf298aded54362","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"9d1c23c498e989db4592749e735d0a09fc41ffeb86ab66f70b6894a2161e1212","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 3e29106b8dd2..e25b523b4985 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"80e15361b1e42280548074fc349fd32a45b55dd622c33bc37a0e2db963852790","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"d0b42cb31d5b2e84acb3a63ca79fdd2bc988bde3505de54dc07dd85bb76fd60c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 03ca332eb3fc..674dd5223887 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -213,13 +213,13 @@ Required members: | `assemble(params)` | Method | Build context for a model run (returns `AssembleResult`) | | `compact(params)` | Method | Summarize/reduce context | -Set `info.acceptedHostParams` to the host-added lifecycle fields the engine -accepts. Current keys are `sessionKey`, `prompt`, `runtimeSettings`, +Set `info.acceptedHostParams` to restrict the host-added lifecycle fields the +engine receives. Current keys are `sessionKey`, `prompt`, `runtimeSettings`, `sessionTarget`, and `runtimeContext`. OpenClaw intersects the declaration with the fields available for each lifecycle method, so undeclared or unknown keys -are never injected. Engines without this declaration receive the pre-host-field -legacy parameter set through 2026-08-12; after that date, undeclared engines -receive every current host field. +are never injected. Engines without this declaration receive every current +host field; declare an explicit list, including `[]`, when the engine validates +a narrower input shape. For durable admitted turns, declare both transcript semantics: @@ -308,9 +308,9 @@ rendered directly to users and does not create a dedicated reporting surface. - `diagnostics`: closed fallback and degraded reason codes when known Fields that can be unknown are represented as `null`; discriminator fields such -as runtime mode and selection source remain non-nullable. Engines that accept -`runtimeSettings` must include it in `info.acceptedHostParams` during the -compatibility window. +as runtime mode and selection source remain non-nullable. Engines that restrict +host parameters and accept `runtimeSettings` must include it in +`info.acceptedHostParams`. ### Host requirements diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 6cd730ef1aaf..98f5be775480 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -198,14 +198,14 @@ artifact reader count is zero. Audit the current migration queue with `pnpm plugins:boundary-report`: -| Flag | Effect | -| ------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `--summary` (or `pnpm plugins:boundary-report:summary`) | Compact counts instead of full detail. | -| `--json` | Machine-readable report. | -| `--owner ` | Filter to one plugin or compatibility owner. | -| `--fail-on-cross-owner` | Exit non-zero on cross-owner reserved SDK imports. | -| `--fail-on-eligible-compat` | Exit non-zero when a deprecated compat record's `removeAfter` date has passed. | -| `--fail-on-unclassified-unused-reserved` | Exit non-zero on unused reserved SDK shims. | +| Flag | Effect | +| ------------------------------------------------------- | -------------------------------------------------------------------------- | +| `--summary` (or `pnpm plugins:boundary-report:summary`) | Compact counts instead of full detail. | +| `--json` | Machine-readable report. | +| `--owner ` | Filter to one plugin or compatibility owner. | +| `--fail-on-cross-owner` | Exit non-zero on cross-owner reserved SDK imports. | +| `--fail-on-eligible-compat` | Exit non-zero on or after a deprecated compat record's `removeAfter` date. | +| `--fail-on-unclassified-unused-reserved` | Exit non-zero on unused reserved SDK shims. | `pnpm plugins:boundary-report:ci` runs with all three fail flags. Deprecated records normally have an explicit `removeAfter` date. A contract tied to a @@ -1069,7 +1069,7 @@ apps own device capture/playback UX. | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Now** | Warning-capable deprecated surfaces emit runtime warnings; repository guards reject deprecated SDK imports from core and bundled plugins. | | **Pending owner decision** | Records without `removeAfter` or `removalGate` remain deprecated and ineligible until their owner publishes a gate. | -| **Each compat record's `removeAfter` date** | That dated surface becomes eligible for removal; `pnpm plugins:boundary-report --fail-on-eligible-compat` fails CI once the date passes. | +| **Each compat record's `removeAfter` date** | That dated surface becomes eligible for removal; `pnpm plugins:boundary-report --fail-on-eligible-compat` fails CI on or after that date. | | **Next Plugin SDK major** | `inbound-reply-dispatch` reaches its explicit `next-plugin-sdk-major` gate; it is not date-eligible before that version boundary. | The remaining public SDK subpaths below have registry-backed removal windows. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index f674421fe98c..f14cff72dc0f 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -619,10 +619,10 @@ For an end-to-end authoring guide, see ### Exclusive slots -| Method | What it registers | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api.registerContextEngine(id, factory)` | Context engine (one active at a time). Declare accepted host-added lifecycle fields with `info.acceptedHostParams`; undeclared engines receive the legacy field set through 2026-08-12, then receive all current host fields. | -| `api.registerMemoryCapability(capability)` | Unified memory capability | +| Method | What it registers | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api.registerContextEngine(id, factory)` | Context engine (one active at a time). Use `info.acceptedHostParams` to restrict accepted host-added lifecycle fields; undeclared engines receive all current host fields. | +| `api.registerMemoryCapability(capability)` | Unified memory capability | To participate in durable admitted turns, context engines must declare `currentTurnFence: "before-current-turn-entry-v1"` and diff --git a/src/context-engine/host-param-projection.test.ts b/src/context-engine/host-param-projection.test.ts index 2945e43a87a6..8aefbad0c59a 100644 --- a/src/context-engine/host-param-projection.test.ts +++ b/src/context-engine/host-param-projection.test.ts @@ -128,27 +128,6 @@ describe("context-engine host parameter projection", () => { }); }); - it("uses the legacy parameter set for undeclared engines during the window", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); - const assembleCalls: Array> = []; - const compactCalls: Array> = []; - const engineId = registerProbeEngine({ assembleCalls, compactCalls }); - - await invokeHostParamMethods( - await resolveContextEngine({ plugins: { slots: { contextEngine: engineId } } }), - ); - - for (const call of [...assembleCalls, ...compactCalls]) { - expect(call).not.toHaveProperty("sessionKey"); - expect(call).not.toHaveProperty("runtimeSettings"); - } - expect(assembleCalls[0]).not.toHaveProperty("prompt"); - expect(compactCalls[0]).not.toHaveProperty("sessionTarget"); - expect(compactCalls[0]).not.toHaveProperty("runtimeContext"); - expect(compactCalls[0]).toHaveProperty("sessionId", "session-1"); - }); - it("projects host parameters on fresh logical-turn engines", async () => { const assembleCalls: Array> = []; const compactCalls: Array> = []; @@ -234,9 +213,7 @@ describe("context-engine host parameter projection", () => { ]); }); - it("passes every host parameter to fresh undeclared engines after the window", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-13T00:00:00Z")); + it("passes every host parameter to fresh undeclared engines", async () => { const assembleCalls: Array> = []; const compactCalls: Array> = []; const engineId = registerProbeEngine({ assembleCalls, compactCalls }); @@ -265,15 +242,12 @@ describe("context-engine host parameter projection", () => { ]); }); - it("switches undeclared engines to full parameters after the window", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); + it("passes every host parameter to resolved undeclared engines", async () => { const assembleCalls: Array> = []; const compactCalls: Array> = []; const engineId = registerProbeEngine({ assembleCalls, compactCalls }); const engine = await resolveContextEngine({ plugins: { slots: { contextEngine: engineId } } }); - vi.setSystemTime(new Date("2026-08-13T00:00:00Z")); await invokeHostParamMethods(engine); expect(assembleCalls[0]).toMatchObject({ @@ -317,15 +291,13 @@ describe("context-engine host parameter projection", () => { }); it("does not mutate frozen engines reused by a factory", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); const engineId = `host-param-frozen-${++engineCounter}`; const assemble = vi.fn(async (params) => ({ messages: params.messages, estimatedTokens: 0, })); class FrozenProbeEngine implements ContextEngine { - readonly #info = { id: engineId, name: "Frozen Probe" }; + readonly #info = { id: engineId, name: "Frozen Probe", acceptedHostParams: [] }; get info() { return this.#info; @@ -346,7 +318,7 @@ describe("context-engine host parameter projection", () => { const first = await resolveContextEngine({ plugins: { slots: { contextEngine: engineId } } }); const second = await resolveContextEngine({ plugins: { slots: { contextEngine: engineId } } }); - expect(first.info).toEqual({ id: engineId, name: "Frozen Probe" }); + expect(first.info).toEqual({ id: engineId, name: "Frozen Probe", acceptedHostParams: [] }); await first.assemble({ sessionId: "session-1", sessionKey: "first", messages: [message] }); await second.assemble({ sessionId: "session-2", sessionKey: "second", messages: [message] }); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index b499974bc010..26508562e5a9 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -2,7 +2,6 @@ import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import type { OpenClawConfig } from "../config/types.js"; import { createAbortError } from "../infra/abort-signal.js"; -import { getPluginCompatRecord } from "../plugins/compat/registry.js"; import type { ContextEngineFactory, ContextEngineFactoryContext, @@ -55,20 +54,11 @@ type ResolvedContextEngineMetadata = { }; const resolvedEngineMetadata = new WeakMap(); -const legacyHostParamDefaultRemoveAfter = getPluginCompatRecord( - "context-engine-legacy-host-param-default", -).removeAfter; - function projectContextEngineHostParams( engine: ContextEngine, params: Record, ): Record { - // Removal(2026-08-12): undeclared engines get full params. - // Contract: context-engine-legacy-host-param-default. - const useLegacyDefault = - legacyHostParamDefaultRemoveAfter !== undefined && - new Date().toISOString().slice(0, 10) <= legacyHostParamDefaultRemoveAfter; - const accepted = engine.info.acceptedHostParams ?? (useLegacyDefault ? [] : undefined); + const accepted = engine.info.acceptedHostParams; if (!accepted) { return params; } diff --git a/src/plugins/compat/registry-records.ts b/src/plugins/compat/registry-records.ts index 52417921850a..333555f81311 100644 --- a/src/plugins/compat/registry-records.ts +++ b/src/plugins/compat/registry-records.ts @@ -13,18 +13,17 @@ export const PLUGIN_COMPAT_RECORDS = [ MEDIA_LEGACY_PROJECTION_COMPAT_RECORD, { code: "context-engine-legacy-host-param-default", - status: "deprecated", + status: "removed", owner: "sdk", introduced: "2026-07-29", - deprecated: "2026-07-29", - warningStarts: "2026-07-29", - removeAfter: "2026-08-12", replacement: - "declare `ContextEngineInfo.acceptedHostParams`; full host params after the window", + "`ContextEngineInfo.acceptedHostParams` for restricted projection; omitted declarations receive full host params", docsPath: "/concepts/context-engine#the-contextengine-interface", surfaces: ["ContextEngineInfo.acceptedHostParams and undeclared-engine default projection"], - diagnostics: ["plugin compatibility registry and dated runtime removal marker"], + diagnostics: ["plugin compatibility registry and context engine guide"], tests: ["src/context-engine/host-param-projection.test.ts"], + releaseNote: + "The undeclared context-engine host-parameter compatibility default was removed; engines without `acceptedHostParams` now receive all current host fields.", }, { code: "removed-global-api-provider-publication", diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index b728f341abe8..cc8ac652cc36 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -176,17 +176,17 @@ describe("plugin compatibility registry", () => { ); }); - it("tracks the context-engine legacy host-param default through its two-week window", () => { + it("keeps the removed context-engine host-param default as a migration tombstone", () => { const record = listPluginCompatRecords().find( (candidate) => candidate.code === "context-engine-legacy-host-param-default", ); expect(record).toMatchObject({ - status: "deprecated", - deprecated: "2026-07-29", - warningStarts: "2026-07-29", - removeAfter: "2026-08-12", + status: "removed", + replacement: + "`ContextEngineInfo.acceptedHostParams` for restricted projection; omitted declarations receive full host params", }); + expect(record?.removeAfter).toBeUndefined(); }); it("keeps deprecated explicit target parser calls inside compatibility shims", () => { From dac940bf3a528334647dd69a8768545ba38949b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:06:53 -0700 Subject: [PATCH 019/165] refactor(openai): split realtime voice by layer (#122414) * refactor(openai): split realtime voice by layer * refactor(openai): delete dead realtime auth branches * chore(lint): ratchet max-lines baseline after realtime-voice split * style(openai): prune split leftovers --- config/max-lines-baseline.txt | 2 - .../realtime-voice-bridge-connection.test.ts | 748 +++ .../realtime-voice-bridge-events.test.ts | 678 +++ .../realtime-voice-bridge-reconnect.test.ts | 560 +++ .../realtime-voice-bridge-tools.test.ts | 697 +++ extensions/openai/realtime-voice-bridge.ts | 700 +++ .../realtime-voice-browser-auth.test.ts | 762 +++ extensions/openai/realtime-voice-events.ts | 402 ++ extensions/openai/realtime-voice-protocol.ts | 417 ++ .../realtime-voice-provider-routing.test.ts | 756 +++ .../openai/realtime-voice-provider.test.ts | 4259 ----------------- extensions/openai/realtime-voice-provider.ts | 2105 +------- .../realtime-voice-response-control.test.ts | 648 +++ .../openai/realtime-voice-session-policy.ts | 643 +++ .../openai/realtime-voice-test-support.ts | 318 ++ 15 files changed, 7360 insertions(+), 6335 deletions(-) create mode 100644 extensions/openai/realtime-voice-bridge-connection.test.ts create mode 100644 extensions/openai/realtime-voice-bridge-events.test.ts create mode 100644 extensions/openai/realtime-voice-bridge-reconnect.test.ts create mode 100644 extensions/openai/realtime-voice-bridge-tools.test.ts create mode 100644 extensions/openai/realtime-voice-bridge.ts create mode 100644 extensions/openai/realtime-voice-browser-auth.test.ts create mode 100644 extensions/openai/realtime-voice-events.ts create mode 100644 extensions/openai/realtime-voice-protocol.ts create mode 100644 extensions/openai/realtime-voice-provider-routing.test.ts delete mode 100644 extensions/openai/realtime-voice-provider.test.ts create mode 100644 extensions/openai/realtime-voice-response-control.test.ts create mode 100644 extensions/openai/realtime-voice-session-policy.ts create mode 100644 extensions/openai/realtime-voice-test-support.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index ebe7313541c6..73252121c10b 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -182,8 +182,6 @@ extensions/openai/image-generation-provider.test.ts extensions/openai/image-generation-provider.ts extensions/openai/openai-provider.test.ts extensions/openai/openai-provider.ts -extensions/openai/realtime-voice-provider.test.ts -extensions/openai/realtime-voice-provider.ts extensions/openrouter/index.test.ts extensions/openshell/src/backend.ts extensions/openshell/src/openshell-core.test.ts diff --git a/extensions/openai/realtime-voice-bridge-connection.test.ts b/extensions/openai/realtime-voice-bridge-connection.test.ts new file mode 100644 index 000000000000..0d0ab8002813 --- /dev/null +++ b/extensions/openai/realtime-voice-bridge-connection.test.ts @@ -0,0 +1,748 @@ +// Openai tests cover realtime voice provider plugin behavior. +import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1"); + +function readInternalRealtimeVoiceProviderApi(provider: object) { + return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as { + isBrowserSessionConfigured: (ctx: { + cfg?: object; + providerConfig: Record; + agentId?: string; + }) => boolean; + isGatewayRelayConfigured: (ctx: { + cfg?: object; + providerConfig: Record; + agentId?: string; + }) => boolean | undefined; + resolveBrowserSessionCapabilities: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + }) => { + handlesAgentConsult?: boolean; + supportsToolCalls?: boolean; + supportsVideoFrames?: boolean; + supportsGatewayControl?: boolean; + transports?: string[]; + }; + resolveGatewayRelayCapabilities: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + }) => { + handlesAgentConsult?: boolean; + supportsToolCalls?: boolean; + transports?: string[]; + }; + validateGatewayRelayLaunch: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + autoRespondToAudio?: boolean; + }) => string | undefined; + cancelBrowserSession: (request: Record, session: object) => Promise; + }; +} + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + parseSent, + createNativeBridge, + requireSocket, + beginBridgeConnection, + openSocket, + emitServerEvent, + emitSessionUpdated, + connectReadyBridge, + expectedResponseCreateEvent, + requireRecord, + requireNestedRecord, + expectRecordFields, + requireSession, + createRealtimeTool, + createUnreadableToolName, + createMalformedToolName, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice bridge connection", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("adds OpenClaw attribution headers to native realtime websocket requests", () => { + vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "test-api-key-test" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + bridge.close(); + + const socket = FakeWebSocket.instances[0]; + const options = socket?.args[1] as + | { headers?: Record; maxPayload?: number } + | undefined; + expectRecordFields(options?.headers, "websocket headers", { + originator: "openclaw", + version: "2026.3.22", + "User-Agent": "openclaw/2026.3.22", + }); + expect(options?.headers).not.toHaveProperty("OpenAI-Beta"); + expect(options?.maxPayload).toBe(16 * 1024 * 1024); + }); + + it("sends one shared GA policy and waits for session.updated on an attached sideband", async () => { + const createBrowserSession = vi.fn( + async (_request: unknown, _auth: unknown) => + ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "gateway-token", + offerUrl: "/plugins/openai/realtime/calls", + }) as const, + ); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { handlesAgentConsult: true as const }, + createBrowserSession, + cancelBrowserSession: vi.fn(async () => undefined), + }, + }); + const bindBridge = vi.fn(); + const onEvent = vi.fn(); + const onReady = vi.fn(); + const cfg = {} as never; + + expect( + readInternalRealtimeVoiceProviderApi(provider).resolveBrowserSessionCapabilities({ + cfg, + providerConfig: { apiKey: "test-api-key-platform" }, + model: "gpt-realtime-2.1", + }), + ).toMatchObject({ supportsGatewayControl: true }); + await expect( + provider.createBrowserSession?.({ + cfg, + providerConfig: { apiKey: "test-api-key-platform" }, + instructions: "Stay concise.", + model: "gpt-realtime-2.1", + prefixPaddingMs: 420, + reasoningEffort: "medium", + silenceDurationMs: 650, + tools: [createRealtimeTool("openclaw_agent_consult")], + vadThreshold: 0.7, + voice: "marin", + gatewayControl: { bindBridge, onEvent, onReady }, + }), + ).resolves.toMatchObject({ + clientSecret: "gateway-token", + offerUrl: "/plugins/openai/realtime/calls", + }); + const brokerRequest = requireRecord(createBrowserSession.mock.calls[0]?.[0], "broker request"); + expect(createBrowserSession.mock.calls[0]?.[1]).toEqual({ + type: "api-key", + token: "test-api-key-platform", + }); + const gaSideband = requireRecord(brokerRequest.gaSideband, "GA sideband request"); + expect(gaSideband.session).toMatchObject({ + type: "realtime", + instructions: "Stay concise.", + model: "gpt-realtime-2.1", + output_modalities: ["audio"], + reasoning: { effort: "medium" }, + tool_choice: "auto", + audio: { + input: { + format: { type: "audio/pcm", rate: 24000 }, + noise_reduction: { type: "near_field" }, + turn_detection: { + type: "server_vad", + threshold: 0.7, + prefix_padding_ms: 420, + silence_duration_ms: 650, + create_response: true, + interrupt_response: true, + }, + }, + output: { format: { type: "audio/pcm", rate: 24000 }, voice: "marin" }, + }, + }); + const createBridge = gaSideband.createBridge as (params: { + apiKey: string; + callId: string; + onTerminal: () => void; + }) => RealtimeVoiceBridge; + const bridge = createBridge({ + apiKey: "test-api-key-platform", + callId: "rtc_gateway", + onTerminal: vi.fn(), + }); + expect(bindBridge).toHaveBeenCalledWith(bridge); + const { connecting, socket } = beginBridgeConnection(bridge); + let connectResolved = false; + void connecting.then(() => { + connectResolved = true; + }); + expect(socket.args[0]).toBe("wss://api.openai.com/v1/realtime?call_id=rtc_gateway"); + openSocket(socket); + await Promise.resolve(); + const sessionUpdates = parseSent(socket).filter((event) => event.type === "session.update"); + expect(sessionUpdates).toHaveLength(1); + expect(sessionUpdates[0]?.session).toEqual(gaSideband.session); + emitServerEvent(socket, { + type: "session.created", + session: { type: "realtime", tools: [{ type: "function" }], tool_choice: "auto" }, + }); + await Promise.resolve(); + expect(connectResolved).toBe(false); + expect(onReady).not.toHaveBeenCalled(); + expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength(1); + emitSessionUpdated(socket); + await connecting; + expect(connectResolved).toBe(true); + expect(onReady).toHaveBeenCalledOnce(); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "session.created", + detail: "tools=1 toolChoice=auto", + }); + bridge.close(); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("waits for session.updated before draining audio and firing onReady", async () => { + const onReady = vi.fn(); + const bridge = createNativeBridge({ + instructions: "Be helpful.", + language: "de", + onReady, + }); + const { connecting, socket } = beginBridgeConnection(bridge); + let connectResolved = false; + void connecting.then(() => { + connectResolved = true; + }); + + openSocket(socket); + await Promise.resolve(); + + bridge.sendAudio(Buffer.from("before-ready")); + emitServerEvent(socket, { type: "session.created" }); + + expect(connectResolved).toBe(false); + expect(onReady).not.toHaveBeenCalled(); + expect(parseSent(socket).map((event) => event.type)).toEqual(["session.update"]); + const session = requireSession(socket); + expectRecordFields(session, "session", { + type: "realtime", + model: "gpt-realtime-2.1", + output_modalities: ["audio"], + }); + const inputAudio = requireNestedRecord(session, ["audio", "input"]); + expectRecordFields(inputAudio, "session audio input", { + format: { type: "audio/pcmu" }, + noise_reduction: null, + transcription: { model: "gpt-4o-mini-transcribe", language: "de" }, + }); + expect(requireNestedRecord(session, ["audio", "output"])).toEqual({ + format: { type: "audio/pcmu" }, + voice: "alloy", + }); + expect(session).not.toHaveProperty("temperature"); + expect(bridge.isConnected()).toBe(false); + + emitSessionUpdated(socket); + await connecting; + + expect(connectResolved).toBe(true); + expect(onReady).toHaveBeenCalledTimes(1); + expect(parseSent(socket).map((event) => event.type)).toEqual([ + "session.update", + "input_audio_buffer.append", + ]); + expect(bridge.isConnected()).toBe(true); + }); + + it("bounds queued audio by aggregate bytes before session readiness", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); + await Promise.resolve(); + + bridge.sendAudio(Buffer.alloc(512 * 1024, 0x01)); + bridge.sendAudio(Buffer.alloc(512 * 1024, 0x02)); + bridge.sendAudio(Buffer.from("overflow")); + emitSessionUpdated(socket); + await connecting; + + const audioEvents = parseSent(socket).filter( + (event) => event.type === "input_audio_buffer.append", + ); + expect(audioEvents).toHaveLength(2); + expect( + audioEvents.map((event) => Buffer.from(String(event.audio), "base64").byteLength), + ).toEqual([512 * 1024, 512 * 1024]); + bridge.close(); + }); + + it("discards audio closed before the first connection and reconnects fresh", async () => { + const onClose = vi.fn(); + const bridge = createNativeBridge({ 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, socket } = beginBridgeConnection(bridge); + openSocket(socket); + emitSessionUpdated(socket); + 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 bridge = createNativeBridge(); + const { connecting: firstConnect, socket: firstSocket } = beginBridgeConnection(bridge); + openSocket(firstSocket); + await Promise.resolve(); + + bridge.sendAudio(Buffer.from("queued-before-close")); + bridge.close(); + await firstConnect; + bridge.sendAudio(Buffer.from("sent-after-close")); + + const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); + await reconnecting; + + expect( + parseSent(secondSocket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + bridge.close(); + }); + + it("shares an in-flight connection until session readiness", async () => { + const onReady = vi.fn(); + const bridge = createNativeBridge({ onReady }); + const firstConnect = bridge.connect(); + const secondConnect = bridge.connect(); + const socket = requireSocket(); + + expect(FakeWebSocket.instances).toHaveLength(1); + openSocket(socket); + emitSessionUpdated(socket); + + await Promise.all([firstConnect, secondConnect]); + expect(onReady).toHaveBeenCalledOnce(); + bridge.close(); + }); + + it("fails terminally when the readiness callback throws", async () => { + vi.useFakeTimers(); + const readyError = new Error("readiness callback failed"); + const onClose = vi.fn(); + const onError = vi.fn(); + const onReady = vi.fn(() => { + throw readyError; + }); + const bridge = createNativeBridge({ onClose, onError, onReady }); + const { connecting, socket } = beginBridgeConnection(bridge); + let connectError: unknown; + const observedConnect = connecting.catch((error: unknown) => { + connectError = error; + }); + + openSocket(socket); + bridge.sendAudio(Buffer.from("queued-before-ready")); + emitSessionUpdated(socket); + await vi.advanceTimersByTimeAsync(0); + const immediateConnectError = connectError; + + bridge.close(); + await observedConnect; + + expect(immediateConnectError).toBe(readyError); + expect(onReady).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(readyError); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("error"); + expect(socket.closed).toBe(true); + expect(bridge.isConnected()).toBe(false); + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + emitSessionUpdated(socket); + await expect(bridge.connect()).rejects.toBe(readyError); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onReady).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("omits unsupported OpenAI tool names from GA session updates", async () => { + const bridge = createNativeBridge({ + tools: [ + createRealtimeTool("1_lookup"), + createRealtimeTool("calendar.lookup:next"), + createRealtimeTool("bad/name"), + createRealtimeTool("x".repeat(65)), + createMalformedToolName(null), + createMalformedToolName(42), + createUnreadableToolName(), + ], + }); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + + const tools = requireSession(socket).tools as Array<{ name?: string }>; + expect(tools.map((tool) => tool.name)).toEqual(["1_lookup", "x".repeat(65)]); + emitSessionUpdated(socket); + await connecting; + }); + + it("keeps Azure deployment bridges on deployment-compatible session payloads", async () => { + const bridge = createNativeBridge({ + providerConfig: { + apiKey: "test-api-key-test", + azureEndpoint: "https://example.openai.azure.com/", + azureDeployment: "realtime-prod", + azureApiVersion: "2024-10-01-preview", + voice: "verse", + }, + audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + instructions: "Be helpful.", + tools: [ + createRealtimeTool("1_lookup"), + createRealtimeTool("calendar.lookup:next"), + createRealtimeTool("x".repeat(65)), + ], + }); + const { connecting, socket } = beginBridgeConnection(bridge); + + expect(socket.args[0]).toBe( + "wss://example.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=realtime-prod", + ); + + openSocket(socket); + await Promise.resolve(); + + const session = requireSession(socket); + expectRecordFields(session, "session", { + modalities: ["text", "audio"], + instructions: "Be helpful.", + voice: "verse", + input_audio_format: "pcm16", + output_audio_format: "pcm16", + input_audio_transcription: { model: "whisper-1" }, + temperature: 0.8, + }); + expectRecordFields( + requireRecord(session.turn_detection, "session turn detection"), + "turn detection", + { + create_response: true, + }, + ); + expect(session).not.toHaveProperty("type"); + expect(session).not.toHaveProperty("audio"); + const tools = session.tools as Array<{ name?: string }>; + expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); + + emitSessionUpdated(socket); + await connecting; + + bridge.triggerGreeting?.("Say hello."); + expect(parseSent(socket).slice(-2)).toEqual([ + { + type: "session.update", + session: { + turn_detection: { + type: "server_vad", + threshold: 0.5, + prefix_padding_ms: 300, + silence_duration_ms: 500, + create_response: false, + }, + }, + }, + expectedResponseCreateEvent(), + ]); + + emitServerEvent(socket, { type: "response.done" }); + expect(parseSent(socket).at(-1)).toEqual({ + type: "session.update", + session: { + turn_detection: { + type: "server_vad", + threshold: 0.5, + prefix_padding_ms: 300, + silence_duration_ms: 500, + create_response: true, + }, + }, + }); + }); + + it("rejects connection when session configuration fails before readiness", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: "invalid realtime session" }, + }), + ), + ); + + await expect(connecting).rejects.toThrow("invalid realtime session"); + expect(bridge.isConnected()).toBe(false); + }); + + it("rejects connection when the socket closes before session readiness", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + socket.close(1006, "session closed"); + + await expect(connecting).rejects.toThrow("OpenAI realtime connection closed before ready"); + expect(bridge.isConnected()).toBe(false); + }); + + it("bounds sideband frames received before session readiness", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); + const frame = Buffer.from( + JSON.stringify({ type: "session.created", padding: "x".repeat(600 * 1024) }), + ); + + socket.emit("message", frame); + socket.emit("message", frame); + + await expect(connecting).rejects.toThrow("sideband startup buffer exceeded"); + expect(bridge.isConnected()).toBe(false); + }); + + it("does not report startup timeout shutdown as a clean close", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); + + const timeoutAssertion = expect(connecting).rejects.toThrow( + "OpenAI realtime connection timeout", + ); + await vi.advanceTimersByTimeAsync(10_000); + await timeoutAssertion; + expect(socket.terminated).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(bridge.isConnected()).toBe(false); + }); + + it("can disable automatic audio turn responses for agent-routed voice loops", async () => { + const bridge = createNativeBridge({ + autoRespondToAudio: false, + }); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + emitSessionUpdated(socket); + await connecting; + + expectRecordFields( + requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), + "turn detection", + { + create_response: false, + interrupt_response: false, + }, + ); + }); + + it("can disable realtime response interruption while keeping audio responses enabled", async () => { + const bridge = createNativeBridge({ + autoRespondToAudio: true, + interruptResponseOnInputAudio: false, + }); + const socket = await connectReadyBridge(bridge); + + expectRecordFields( + requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), + "turn detection", + { + create_response: true, + interrupt_response: false, + }, + ); + }); + + it("can request PCM16 24 kHz realtime audio for Chrome command-pair bridges", async () => { + const bridge = createNativeBridge({ + audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + }); + const socket = await connectReadyBridge(bridge); + + const session = requireSession(socket); + expect(requireNestedRecord(session, ["audio", "input", "format"])).toEqual({ + type: "audio/pcm", + rate: 24000, + }); + expect(requireNestedRecord(session, ["audio", "output", "format"])).toEqual({ + type: "audio/pcm", + rate: 24000, + }); + }); + + it("settles cleanly when closed before the websocket opens", async () => { + const onClose = vi.fn(); + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); + + bridge.close(); + bridge.close(); + + await expect(connecting).resolves.toBeUndefined(); + expect(socket.closed).toBe(true); + expect(socket.terminated).toBe(false); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); +}); diff --git a/extensions/openai/realtime-voice-bridge-events.test.ts b/extensions/openai/realtime-voice-bridge-events.test.ts new file mode 100644 index 000000000000..c8f37527210c --- /dev/null +++ b/extensions/openai/realtime-voice-bridge-events.test.ts @@ -0,0 +1,678 @@ +// Openai tests cover realtime voice provider plugin behavior. +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + parseSent, + createNativeBridge, + connectReadyBridge, + expectedResponseCancelEvent, + hasSentEventType, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice bridge events", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("does not locally clear playback on speech-start events when input interruption is disabled", async () => { + const onAudio = vi.fn(); + const onClearAudio = vi.fn(); + const bridge = createNativeBridge({ + autoRespondToAudio: true, + interruptResponseOnInputAudio: false, + onAudio, + onClearAudio, + }); + const socket = await connectReadyBridge(bridge); + + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "input_audio_buffer.speech_started" })), + ); + + expect(onAudio).toHaveBeenCalledTimes(1); + expect(onClearAudio).not.toHaveBeenCalled(); + expect(hasSentEventType(socket, "response.cancel")).toBe(false); + expect(hasSentEventType(socket, "conversation.item.truncate")).toBe(false); + }); + + it("keeps assistant playback active on server VAD when automatic audio responses are disabled", async () => { + const onAudio = vi.fn(); + const onClearAudio = vi.fn(); + const bridge = createNativeBridge({ + autoRespondToAudio: false, + onAudio, + onClearAudio, + }); + const socket = await connectReadyBridge(bridge); + + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "input_audio_buffer.speech_started" })), + ); + + expect(onAudio).toHaveBeenCalledTimes(1); + expect(onClearAudio).not.toHaveBeenCalled(); + expect(hasSentEventType(socket, "response.cancel")).toBe(false); + expect(hasSentEventType(socket, "conversation.item.truncate")).toBe(false); + }); + + it("truncates externally interrupted playback after an immediate mark acknowledgement", async () => { + const onAudio = vi.fn(); + const onClearAudio = vi.fn(); + const bridge = createNativeBridge({ + onAudio, + onClearAudio, + onMark: () => bridge.acknowledgeMark(), + }); + const socket = await connectReadyBridge(bridge); + + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + bridge.setMediaTimestamp(1300); + + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + + expect(onAudio).toHaveBeenCalledTimes(1); + expect(onClearAudio).toHaveBeenCalledWith("barge-in"); + expect(parseSent(socket).slice(-2)).toEqual([ + expectedResponseCancelEvent(), + { + type: "conversation.item.truncate", + item_id: "item_1", + content_index: 0, + audio_end_ms: 300, + }, + ]); + }); + + it("preserves FIFO playback acknowledgements after sustained output", async () => { + const onClearAudio = vi.fn(); + const onMark = vi.fn(); + const bridge = createNativeBridge({ + onClearAudio, + onMark, + }); + const socket = await connectReadyBridge(bridge); + + bridge.setMediaTimestamp(1000); + for (let index = 0; index < 300; index += 1) { + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + } + + const marks = onMark.mock.calls.map(([markName]) => String(markName)); + expect(marks).toHaveLength(300); + for (let index = 0; index < 299; index += 1) { + bridge.acknowledgeMark(); + } + bridge.setMediaTimestamp(1300); + bridge.handleBargeIn?.(); + + expect(parseSent(socket).slice(-1)).toEqual([ + { + type: "conversation.item.truncate", + item_id: "item_1", + content_index: 0, + audio_end_ms: 300, + }, + ]); + expect(onClearAudio).toHaveBeenCalledWith("barge-in"); + + for (let index = 0; index < 300; index += 1) { + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + } + const latestMark = onMark.mock.calls.at(-1)?.[0]; + if (typeof latestMark !== "string") { + throw new Error("expected a playback mark"); + } + bridge.acknowledgeMark(latestMark); + bridge.setMediaTimestamp(1600); + bridge.handleBargeIn?.(); + + expect( + parseSent(socket).filter((event) => event.type === "conversation.item.truncate"), + ).toHaveLength(1); + bridge.close(); + }); + + it("treats a later named mark as cumulative playback progress", async () => { + const onMark = vi.fn(); + const bridge = createNativeBridge({ onMark }); + const socket = await connectReadyBridge(bridge); + + bridge.setMediaTimestamp(1000); + for (let index = 0; index < 3; index += 1) { + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + } + const marks = onMark.mock.calls.map(([markName]) => String(markName)); + expect(marks).toHaveLength(3); + + bridge.acknowledgeMark(marks[2]); + bridge.acknowledgeMark(marks[0]); + bridge.acknowledgeMark(marks[1]); + bridge.setMediaTimestamp(1300); + bridge.handleBargeIn?.(); + + expect( + parseSent(socket).filter((event) => event.type === "conversation.item.truncate"), + ).toHaveLength(0); + bridge.close(); + }); + + it("forwards current realtime output audio events", async () => { + const onAudio = vi.fn(); + const onTranscript = vi.fn(); + const bridge = createNativeBridge({ + onAudio, + onTranscript, + }); + const socket = await connectReadyBridge(bridge); + + const audio = Buffer.from("assistant audio"); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.output_audio.delta", + item_id: "item_1", + delta: audio.toString("base64"), + }), + ), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.output_audio_transcript.done", + transcript: "hello from current realtime events", + }), + ), + ); + + expect(onAudio).toHaveBeenCalledWith(audio); + expect(onTranscript).toHaveBeenCalledWith( + "assistant", + "hello from current realtime events", + true, + ); + }); + + it("surfaces input transcription failures with their provider error details", async () => { + const onError = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onError, onEvent }); + const socket = await connectReadyBridge(bridge); + + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "conversation.item.input_audio_transcription.failed", + item_id: "item_speech", + error: { code: "decoder_failure", message: "speech decoder exploded" }, + }), + ), + ); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "speech decoder exploded" }), + ); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "conversation.item.input_audio_transcription.failed", + itemId: "item_speech", + detail: "speech decoder exploded", + }); + }); + + it("preserves corrected final text from legacy realtime text events", async () => { + const onTranscript = vi.fn(); + const bridge = createNativeBridge({ onTranscript }); + const socket = await connectReadyBridge(bridge); + + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.text.delta", delta: "draft assistant" })), + ); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.text.done", text: "corrected assistant" })), + ); + + expect(onTranscript.mock.calls).toEqual([ + ["assistant", "draft assistant", false], + ["assistant", "corrected assistant", true], + ]); + }); + + it.each([ + ["invalid alphabet", "not-base64!"], + ["non-canonical pad bits", "ZE=="], + ])("terminates the session for %s in output audio", async (_scenario, delta) => { + const onAudio = vi.fn(); + const onError = vi.fn(); + const onClose = vi.fn(); + const bridge = createNativeBridge({ + onAudio, + onError, + onClose, + }); + const socket = await connectReadyBridge(bridge); + + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.output_audio.delta", + item_id: "item_1", + delta, + }), + ), + ); + + expect(onAudio).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "OpenAI realtime stream returned malformed base64 audio data", + }), + ); + expect(onClose).toHaveBeenCalledWith("error"); + expect(socket.closed).toBe(true); + await expect(bridge.connect()).rejects.toThrow( + "OpenAI realtime stream returned malformed base64 audio data", + ); + }); + + it("forwards Codex-compatible legacy realtime audio and transcript events", async () => { + const onAudio = vi.fn(); + const onTranscript = vi.fn(); + const bridge = createNativeBridge({ + onAudio, + onTranscript, + }); + const socket = await connectReadyBridge(bridge); + + const audio = Buffer.from("legacy assistant audio"); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "conversation.output_audio.delta", + data: audio.toString("base64"), + sample_rate: 24000, + channels: 1, + }), + ), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "conversation.input_transcript.delta", + delta: "partial user", + }), + ), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "conversation.output_transcript.delta", + delta: "partial assistant", + }), + ), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.output_text.done", + text: "final assistant text", + }), + ), + ); + + expect(onAudio).toHaveBeenCalledWith(audio); + expect(onTranscript).toHaveBeenCalledWith("user", "partial user", false); + expect(onTranscript).toHaveBeenCalledWith("assistant", "partial assistant", false); + expect(onTranscript).toHaveBeenCalledWith("assistant", "final assistant text", true); + }); + + it("does not send duplicate response.cancel while cancellation is pending", async () => { + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + bridge.setMediaTimestamp(1300); + + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + + expect(parseSent(socket).filter((event) => event.type === "response.cancel")).toHaveLength(1); + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "response.cancel", + detail: "reason=barge-in", + }); + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "conversation.item.truncate", + detail: "reason=barge-in audioEndMs=300", + }); + }); + + it("ignores zero-length playback barge-in without clearing audio", async () => { + const onClearAudio = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ + onClearAudio, + onEvent, + }); + const socket = await connectReadyBridge(bridge); + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + + expect(onClearAudio).not.toHaveBeenCalled(); + expect(hasSentEventType(socket, "response.cancel")).toBe(false); + expect(parseSent(socket).some((event) => event.type === "conversation.item.truncate")).toBe( + false, + ); + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "conversation.item.truncate.skipped", + detail: "reason=barge-in audioEndMs=0 minAudioEndMs=250", + }); + }); + + it("force-cancels zero-length playback barge-in for agent handoff fallback", async () => { + const onClearAudio = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ + onClearAudio, + onEvent, + }); + const socket = await connectReadyBridge(bridge); + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + + bridge.handleBargeIn?.({ audioPlaybackActive: true, force: true }); + + expect(parseSent(socket).slice(-2)).toEqual([ + expectedResponseCancelEvent(), + { + type: "conversation.item.truncate", + item_id: "item_1", + content_index: 0, + audio_end_ms: 0, + }, + ]); + expect(onClearAudio).toHaveBeenCalled(); + expect( + onEvent.mock.calls.some( + ([event]) => isRecord(event) && event.type === "conversation.item.truncate.skipped", + ), + ).toBe(false); + }); + + it("allows immediate playback barge-in when the minimum audio window is zero", async () => { + const onClearAudio = vi.fn(); + const bridge = createNativeBridge({ + providerConfig: { + apiKey: "test-api-key-test", + minBargeInAudioEndMs: 0, + }, + onClearAudio, + }); + const socket = await connectReadyBridge(bridge); + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + + expect(onClearAudio).toHaveBeenCalledWith("barge-in"); + expect(parseSent(socket).slice(-2)).toEqual([ + expectedResponseCancelEvent(), + { + type: "conversation.item.truncate", + item_id: "item_1", + content_index: 0, + audio_end_ms: 0, + }, + ]); + }); +}); diff --git a/extensions/openai/realtime-voice-bridge-reconnect.test.ts b/extensions/openai/realtime-voice-bridge-reconnect.test.ts new file mode 100644 index 000000000000..4ac6a8c0264b --- /dev/null +++ b/extensions/openai/realtime-voice-bridge-reconnect.test.ts @@ -0,0 +1,560 @@ +// Openai tests cover realtime voice provider plugin behavior. +import type { RealtimeVoiceBridgeEvent } from "openclaw/plugin-sdk/realtime-voice"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const OPENAI_REALTIME_REJECTED_KEY_MESSAGE = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + parseSent, + createNativeBridge, + requireSocket, + beginBridgeConnection, + openSocket, + emitServerEvent, + emitSessionUpdated, + emitCompletedToolCalls, + emitFunctionOutputAdded, + connectReadyBridge, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice bridge reconnect", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("rotates realtime bridges on provider max-duration events without reporting an error", async () => { + vi.useFakeTimers(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const onReady = vi.fn(); + const bridge = createNativeBridge({ onError, onEvent, onReady }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); + + openSocket(firstSocket); + emitSessionUpdated(firstSocket); + await connecting; + expect(onReady).toHaveBeenCalledOnce(); + + firstSocket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: "Your session hit the maximum duration of 60 minutes." }, + }), + ), + ); + + expect(onError).not.toHaveBeenCalled(); + expect(firstSocket.closed).toBe(true); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "session.rotation", + detail: "reason=max-duration", + }); + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.scheduled", + detail: "reason=max-duration attempt=1 delayMs=1000", + }); + + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); + + await vi.waitFor(() => + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "session.rotation.ready", + detail: "reason=max-duration", + }), + ); + await vi.waitFor(() => + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.ready", + detail: "reason=max-duration attempt=1", + }), + ); + expect(bridge.isConnected()).toBe(true); + expect(onReady).toHaveBeenCalledOnce(); + + bridge.close(); + }); + + it("clears canceled rotation metadata before an explicit reconnect", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const onReady = vi.fn(); + const bridge = createNativeBridge({ onClose, onError, onEvent, onReady }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); + + firstSocket.deferClose = true; + openSocket(firstSocket); + emitSessionUpdated(firstSocket); + await connecting; + expect(onReady).toHaveBeenCalledOnce(); + + emitServerEvent(firstSocket, { + type: "error", + error: { message: "Your session hit the maximum duration of 60 minutes." }, + }); + expect(firstSocket.closed).toBe(true); + + bridge.close(); + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + + const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); + firstSocket.emitDeferredClose(); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); + await reconnecting; + + expect(onReady).toHaveBeenCalledTimes(2); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "session.rotation.ready" }), + ); + expect(onError).not.toHaveBeenCalled(); + + secondSocket.readyState = FakeWebSocket.CLOSED; + secondSocket.emit("close", 1006, Buffer.from("ordinary drop")); + await vi.advanceTimersByTimeAsync(0); + + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.scheduled", + detail: "reason=websocket-close attempt=1 delayMs=1000", + }); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "session.reconnect.scheduled", + detail: expect.stringContaining("reason=max-duration"), + }), + ); + + bridge.close(); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenLastCalledWith("completed"); + }); + + it("cancels a pending reconnect and allows a later explicit connect", async () => { + vi.useFakeTimers(); + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + emitSessionUpdated(socket); + await connecting; + + socket.readyState = FakeWebSocket.CLOSED; + socket.emit("close", 1006, Buffer.from("transient drop")); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(1); + + bridge.close(); + await vi.advanceTimersByTimeAsync(0); + + expect(vi.getTimerCount()).toBe(0); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onError).not.toHaveBeenCalled(); + + const { connecting: reconnecting, socket: reconnectedSocket } = beginBridgeConnection( + bridge, + 1, + ); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); + await reconnecting; + + expect(bridge.isConnected()).toBe(true); + expect(FakeWebSocket.instances).toHaveLength(2); + expect(onError).not.toHaveBeenCalled(); + bridge.close(); + }); + + it("does not report reconnect readiness after cancellation during provider setup", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onClose, onError, onEvent }); + const socket = await connectReadyBridge(bridge); + + socket.readyState = FakeWebSocket.CLOSED; + socket.emit("close", 1006, Buffer.from("transient drop")); + await vi.advanceTimersByTimeAsync(1000); + const retrySocket = requireSocket(1); + openSocket(retrySocket); + + bridge.close(); + await vi.advanceTimersByTimeAsync(0); + + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "session.reconnect.ready" }), + ); + expect(onError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); + + it("lets cancellation win a queued reconnect startup error", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const bridge = createNativeBridge({ onClose, onError }); + const socket = await connectReadyBridge(bridge); + + socket.readyState = FakeWebSocket.CLOSED; + socket.emit("close", 1006, Buffer.from("transient drop")); + await vi.advanceTimersByTimeAsync(1000); + const retrySocket = requireSocket(1); + openSocket(retrySocket); + emitServerEvent(retrySocket, { + type: "error", + error: { message: "queued retry startup failure" }, + }); + + bridge.close(); + await vi.advanceTimersByTimeAsync(0); + + expect(onError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("reports one terminal error for malformed audio during reconnect setup", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onClose, onError, onEvent }); + const socket = await connectReadyBridge(bridge); + + socket.readyState = FakeWebSocket.CLOSED; + socket.emit("close", 1006, Buffer.from("transient drop")); + await vi.advanceTimersByTimeAsync(1000); + const retrySocket = requireSocket(1); + openSocket(retrySocket); + emitServerEvent(retrySocket, { + type: "response.output_audio.delta", + item_id: "item_1", + delta: "not-base64!", + }); + await vi.advanceTimersByTimeAsync(0); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith( + new Error("OpenAI realtime stream returned malformed base64 audio data"), + ); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("error"); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "session.reconnect.ready" }), + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("ignores late events from a socket replaced by reconnect", async () => { + vi.useFakeTimers(); + const onAudio = vi.fn(); + const onClose = vi.fn(); + const onError = vi.fn(); + const bridge = createNativeBridge({ + onAudio, + onClose, + onError, + }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); + + openSocket(firstSocket); + emitSessionUpdated(firstSocket); + await connecting; + + firstSocket.readyState = FakeWebSocket.CLOSED; + firstSocket.emit("close", 1006, Buffer.from("transient drop")); + firstSocket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + delta: Buffer.from("late audio").toString("base64"), + }), + ), + ); + firstSocket.emit("error", new Error("late retry-wait failure")); + expect(onAudio).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); + await vi.waitFor(() => expect(bridge.isConnected()).toBe(true)); + + emitSessionUpdated(firstSocket); + firstSocket.emit("error", new Error("late socket failure")); + firstSocket.emit("close", 1006, Buffer.from("late socket close")); + await vi.advanceTimersByTimeAsync(0); + + expect(bridge.isConnected()).toBe(true); + expect(FakeWebSocket.instances).toHaveLength(2); + expect(vi.getTimerCount()).toBe(0); + expect(onError).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + bridge.close(); + }); + + it("exhausts retries when sockets open but never become provider-ready", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onClose, onError, onEvent }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); + + openSocket(firstSocket); + emitSessionUpdated(firstSocket); + await connecting; + + firstSocket.readyState = FakeWebSocket.CLOSED; + firstSocket.emit("close", 1006, Buffer.from("transient drop")); + + for (let attempt = 1; attempt <= 5; attempt += 1) { + await vi.waitFor(() => + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.scheduled", + detail: `reason=websocket-close attempt=${attempt} delayMs=${1000 * 2 ** (attempt - 1)}`, + }), + ); + await vi.advanceTimersByTimeAsync(1000 * 2 ** (attempt - 1)); + const retrySocket = requireSocket(attempt); + openSocket(retrySocket); + retrySocket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: `retry startup failure ${attempt}` }, + }), + ), + ); + } + + await vi.waitFor(() => expect(onClose).toHaveBeenCalledWith("error")); + expect(onClose).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledTimes(5); + expect(FakeWebSocket.instances).toHaveLength(6); + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.exhausted", + detail: "reason=websocket-close attempts=5", + }); + + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("keeps a retried connection ready after delayed startup failure close", async () => { + const onClose = vi.fn(); + const bridge = createNativeBridge({ onClose }); + const { connecting: failedConnect, socket: failedSocket } = beginBridgeConnection(bridge); + failedSocket.deferClose = true; + + openSocket(failedSocket); + failedSocket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: "Incorrect API key provided" }, + }), + ), + ); + + await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(failedSocket.deferredClose).toBeDefined(); + + const { connecting: retryConnect, socket: retrySocket } = beginBridgeConnection(bridge, 1); + openSocket(retrySocket); + emitSessionUpdated(retrySocket); + await retryConnect; + + expect(bridge.isConnected()).toBe(true); + failedSocket.emitDeferredClose(); + expect(bridge.isConnected()).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("resets consumer tool ownership before a fresh reconnect can reuse a call id", async () => { + vi.useFakeTimers(); + const staleWork = new AbortController(); + const onEvent = vi.fn((event: RealtimeVoiceBridgeEvent) => { + if (event.direction === "client" && event.type === "session.continuity.reset") { + staleWork.abort(); + } + }); + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onEvent, onToolCall }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket, ["call_reused"]); + expect(onToolCall).toHaveBeenCalledTimes(1); + + socket.emit("close", 1006, Buffer.from("transient drop")); + const lifecycleEvents = onEvent.mock.calls.map(([event]) => event.type); + expect(lifecycleEvents.indexOf("session.continuity.reset")).toBeLessThan( + lifecycleEvents.indexOf("session.reconnect.scheduled"), + ); + await vi.advanceTimersByTimeAsync(1000); + const reconnectedSocket = requireSocket(1); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); + + emitCompletedToolCalls(socket, ["call_from_old_socket"]); + expect( + parseSent(reconnectedSocket).filter((event) => event.type === "conversation.item.create"), + ).toEqual([]); + expect(onToolCall).toHaveBeenCalledTimes(1); + + emitCompletedToolCalls(reconnectedSocket, ["call_reused"]); + if (!staleWork.signal.aborted) { + void bridge.submitToolResult("call_reused", { text: "stale" }); + } + const fresh = bridge.submitToolResult("call_reused", { text: "fresh" }); + emitFunctionOutputAdded(reconnectedSocket, "call_reused"); + await fresh; + + expect(onToolCall).toHaveBeenCalledTimes(2); + expect( + parseSent(reconnectedSocket) + .filter( + (event) => + event.type === "conversation.item.create" && + (event.item as { call_id?: string } | undefined)?.call_id === "call_reused", + ) + .map((event) => (event.item as { output?: string } | undefined)?.output), + ).toEqual([JSON.stringify({ text: "fresh" })]); + }); +}); diff --git a/extensions/openai/realtime-voice-bridge-tools.test.ts b/extensions/openai/realtime-voice-bridge-tools.test.ts new file mode 100644 index 000000000000..12cc220588bc --- /dev/null +++ b/extensions/openai/realtime-voice-bridge-tools.test.ts @@ -0,0 +1,697 @@ +// Openai tests cover realtime voice provider plugin behavior. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + parseSent, + createNativeBridge, + emitServerEvent, + emitCompletedToolCalls, + emitFunctionOutputAdded, + expectedFunctionOutput, + connectReadyBridge, + expectedResponseCreateEvent, + hasSentEventType, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice bridge tools", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("executes tool calls only from successful response output", async () => { + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.function_call_arguments.delta", + item_id: "item_tool_1", + name: "openclaw_agent_consult", + call_id: "call_1", + delta: '{"question":"provisional', + }); + emitServerEvent(socket, { + type: "response.function_call_arguments.done", + item_id: "item_tool_1", + name: "openclaw_agent_consult", + call_id: "call_1", + arguments: '{"question":"still provisional"}', + }); + emitServerEvent(socket, { + type: "conversation.item.done", + item: { + id: "item_tool_1", + type: "function_call", + name: "openclaw_agent_consult", + call_id: "call_1", + arguments: '{"question":"not terminal"}', + }, + }); + expect(onToolCall).not.toHaveBeenCalled(); + + const completed = { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: [ + { + id: "item_tool_1", + type: "function_call", + status: "completed", + name: "openclaw_agent_consult", + call_id: "call_1", + arguments: '{"question":"delegate this"}', + }, + ], + }, + }; + emitServerEvent(socket, completed); + emitServerEvent(socket, completed); + + expect(onToolCall).toHaveBeenCalledTimes(1); + expect(onToolCall).toHaveBeenCalledWith({ + itemId: "item_tool_1", + callId: "call_1", + name: "openclaw_agent_consult", + args: { question: "delegate this" }, + }); + }); + + it.each(["cancelled", "failed", "incomplete"])( + "ignores function calls from a %s response", + async (status) => { + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status, + output: [ + { + id: "item_tool_1", + type: "function_call", + status: "completed", + name: "openclaw_agent_consult", + call_id: "call_1", + arguments: '{"question":"must stay inert"}', + }, + ], + }, + }); + + expect(onToolCall).not.toHaveBeenCalled(); + }, + ); + + it("ignores malformed and unfinished response output items", async () => { + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: [ + null, + "invalid", + { + id: "item_tool_1", + type: "function_call", + status: "incomplete", + name: "openclaw_agent_consult", + call_id: "call_1", + arguments: '{"question":"unfinished"}', + }, + ], + }, + }); + + expect(onToolCall).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "an argument object", + finalArguments: '{"city":"Paris"}', + expectedArguments: { city: "Paris" }, + }, + { + name: "the shipped empty argument contract", + finalArguments: "", + expectedArguments: {}, + }, + ])( + "uses terminal response arguments for $name", + async ({ finalArguments, expectedArguments }) => { + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: [ + { + id: "item_tool_1", + type: "function_call", + status: "completed", + call_id: "call_1", + name: "lookup_weather", + arguments: finalArguments, + }, + ], + }, + }); + + expect(onToolCall).toHaveBeenCalledWith({ + itemId: "item_tool_1", + callId: "call_1", + name: "lookup_weather", + args: expectedArguments, + }); + }, + ); + + it.each([ + { name: "malformed JSON", arguments: '{"city":', reason: "malformed-json" }, + { name: "an array", arguments: '["Paris"]', reason: "non-object-json" }, + { name: "JSON null", arguments: "null", reason: "non-object-json" }, + { name: "a number", arguments: "42", reason: "non-object-json" }, + { name: "a boolean", arguments: "true", reason: "non-object-json" }, + { name: "missing arguments", arguments: undefined, reason: "invalid-json-type" }, + { name: "non-string arguments", arguments: { city: "Paris" }, reason: "invalid-json-type" }, + ])("rejects $name per call without ending the session", async ({ arguments: args, reason }) => { + const onToolCall = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onToolCall, onError, onEvent }); + const socket = await connectReadyBridge(bridge); + const completed = { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: [ + { + id: "item_tool_1", + type: "function_call", + status: "completed", + call_id: "call_1", + name: "lookup_weather", + arguments: args, + }, + ], + }, + }; + + emitServerEvent(socket, { type: "response.created", response: { id: "response_1" } }); + emitServerEvent(socket, completed); + emitServerEvent(socket, completed); + + expect(onToolCall).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "tool_call.arguments.rejected", + detail: `reason=${reason}`, + itemId: "item_tool_1", + }); + expect( + parseSent(socket).filter( + (event) => + event.type === "conversation.item.create" && + (event.item as { call_id?: string } | undefined)?.call_id === "call_1", + ), + ).toHaveLength(1); + }); + + it.each([ + { + name: "accepts", + encoding: "ASCII", + argumentBytes: 256_000, + unit: "a", + repeat: 255_992, + suffix: "", + rejected: false, + }, + { + name: "rejects", + encoding: "ASCII", + argumentBytes: 256_001, + unit: "a", + repeat: 255_993, + suffix: "", + rejected: true, + }, + { + name: "accepts", + encoding: "multibyte", + argumentBytes: 256_000, + unit: "é", + repeat: 127_996, + suffix: "", + rejected: false, + }, + { + name: "rejects", + encoding: "multibyte", + argumentBytes: 256_001, + unit: "é", + repeat: 127_996, + suffix: "a", + rejected: true, + }, + ])( + "$name $argumentBytes-byte $encoding UTF-8 arguments", + async ({ argumentBytes, unit, repeat, suffix, rejected }) => { + const onToolCall = vi.fn(); + const onError = vi.fn(); + const bridge = createNativeBridge({ onToolCall, onError }); + const socket = await connectReadyBridge(bridge); + const rawArgs = `{"x":"${unit.repeat(repeat)}${suffix}"}`; + expect(Buffer.byteLength(rawArgs, "utf8")).toBe(argumentBytes); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: [ + { + id: "item_tool_1", + type: "function_call", + status: "completed", + call_id: "call_1", + name: "lookup_weather", + arguments: rawArgs, + }, + ], + }, + }); + + expect(onToolCall).toHaveBeenCalledTimes(rejected ? 0 : 1); + expect( + parseSent(socket).some( + (event) => + event.type === "conversation.item.create" && + (event.item as { call_id?: string } | undefined)?.call_id === "call_1", + ), + ).toBe(rejected); + expect(onError).not.toHaveBeenCalled(); + }, + ); + + it("ends an extreme session before terminal tool-call ids become unbounded", async () => { + const onToolCall = vi.fn(); + const onError = vi.fn(); + const onClose = vi.fn(); + const bridge = createNativeBridge({ onToolCall, onError, onClose }); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: Array.from({ length: 1_025 }, (_, index) => ({ + id: `item_${index}`, + type: "function_call", + status: "completed", + call_id: `call_${index}`, + name: "lookup_weather", + arguments: "{}", + })), + }, + }); + + expect(onToolCall).toHaveBeenCalledTimes(1_024); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith( + new Error("OpenAI realtime tool-call session limit exceeded (1024)"), + ); + expect(onClose).toHaveBeenCalledWith("error"); + expect(socket.closed).toBe(true); + await expect(bridge.connect()).rejects.toThrow( + "OpenAI realtime tool-call session limit exceeded (1024)", + ); + }); + + it("stops dispatching terminal output when a tool callback closes the bridge", async () => { + const onToolCall = vi.fn(); + const bridge = createNativeBridge({ onToolCall }); + onToolCall.mockImplementation(() => bridge.close()); + const socket = await connectReadyBridge(bridge); + + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_1", + status: "completed", + output: Array.from({ length: 2 }, (_, index) => ({ + id: `item_${index}`, + type: "function_call", + status: "completed", + call_id: `call_${index}`, + name: "lookup_weather", + arguments: "{}", + })), + }, + }); + + expect(onToolCall).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["undefined", (): undefined => undefined], + ["function", () => () => undefined], + ["symbol", () => Symbol("invalid-tool-result")], + ["bigint", () => ({ value: 1n })], + [ + "circular", + () => { + const result: { self?: unknown } = {}; + result.self = result; + return result; + }, + ], + ["omitted custom serialization", () => ({ toJSON: () => undefined })], + ] as const)( + "rejects %s tool results without consuming a retryable call", + async (_label, create) => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket); + const previousEventCount = socket.sent.length; + + expect(() => bridge.submitToolResult("call_1", create())).toThrow(); + expect(socket.sent).toHaveLength(previousEventCount); + expect(hasSentEventType(socket, "response.create")).toBe(false); + + await bridge.submitToolResult("call_1", { recovered: true }); + + expect(parseSent(socket).find((event) => event.type === "conversation.item.create")).toEqual({ + type: "conversation.item.create", + item: { + type: "function_call_output", + call_id: "call_1", + output: JSON.stringify({ recovered: true }), + }, + }); + }, + ); + + it("preserves valid JSON tool results and invokes custom serialization once", async () => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + const values: unknown[] = [null, false, 0, "", "text", [1], { ok: true }]; + const customSerialization = vi.fn((key: string) => ({ key })); + values.push({ toJSON: customSerialization }); + const callIds = values.map((_, index) => `call_${index}`); + emitCompletedToolCalls(socket, callIds); + + for (const [index, result] of values.entries()) { + await bridge.submitToolResult(callIds[index]!, result, { suppressResponse: true }); + } + + const outputs = parseSent(socket) + .filter((event) => event.type === "conversation.item.create") + .map((event) => (event.item as { output: string }).output); + expect(outputs).toEqual([ + "null", + "false", + "0", + '""', + '"text"', + "[1]", + '{"ok":true}', + '{"key":""}', + ]); + expect(customSerialization).toHaveBeenCalledExactlyOnceWith(""); + }); + + it("does not request a realtime response for continuing tool results", async () => { + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onEvent, onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket); + + const working = bridge.submitToolResult( + "call_1", + { status: "working" }, + { willContinue: true }, + ); + + expect(parseSent(socket).slice(-1)).toEqual([ + expectedFunctionOutput("call_1", { status: "working" }), + ]); + expect(hasSentEventType(socket, "response.create")).toBe(false); + expect(working).toBeUndefined(); + + const done = bridge.submitToolResult("call_1", { text: "done" }); + expect(done).toBeUndefined(); + + expect(parseSent(socket).slice(-3)).toEqual([ + expectedFunctionOutput("call_1", { text: "done" }), + expect.objectContaining({ type: "session.update" }), + expectedResponseCreateEvent(), + ]); + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + emitFunctionOutputAdded(socket, "call_1"); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "conversation.item.added", + detail: "itemType=function_call_output", + }); + emitServerEvent(socket, { + type: "conversation.item.done", + item: { type: "function_call_output", call_id: "call_1" }, + }); + expect(onEvent).toHaveBeenCalledWith({ + direction: "server", + type: "conversation.item.done", + detail: "itemType=function_call_output", + }); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_2" } })), + ); + emitServerEvent(socket, { type: "response.done" }); + + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + }); + + it("does not request a realtime response for suppressed tool results", async () => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket); + + const submission = bridge.submitToolResult( + "call_1", + { status: "already_delivered" }, + { suppressResponse: true }, + ); + + expect(parseSent(socket).slice(-1)).toEqual([ + expectedFunctionOutput("call_1", { status: "already_delivered" }), + ]); + emitFunctionOutputAdded(socket, "call_1"); + await submission; + expect(hasSentEventType(socket, "response.create")).toBe(false); + }); + + it("waits for every parallel tool result before continuing the response", async () => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket, ["call_1", "call_2"]); + + const first = bridge.submitToolResult("call_1", { text: "first" }); + emitFunctionOutputAdded(socket, "call_1"); + await first; + + expect(parseSent(socket).filter((event) => event.type === "response.create")).toEqual([]); + + const second = bridge.submitToolResult("call_2", { text: "second" }); + emitFunctionOutputAdded(socket, "call_2"); + await second; + + expect( + parseSent(socket).filter((event) => event.type === "conversation.item.create"), + ).toHaveLength(2); + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + }); + + it("releases a deferred continuation when the last parallel result is suppressed", async () => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket, ["call_1", "call_2"]); + + const first = bridge.submitToolResult("call_1", { text: "first" }); + const second = bridge.submitToolResult( + "call_2", + { status: "already_delivered" }, + { suppressResponse: true }, + ); + emitFunctionOutputAdded(socket, "call_1"); + emitFunctionOutputAdded(socket, "call_2"); + await Promise.all([first, second]); + + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + }); + + it("does not flush deferred response.create while a tool result is still continuing", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError, onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + + emitCompletedToolCalls(socket); + const working = bridge.submitToolResult( + "call_1", + { status: "working" }, + { willContinue: true }, + ); + emitFunctionOutputAdded(socket, "call_1"); + await working; + bridge.sendUserMessage?.("queue after tool result"); + + expect(onError).not.toHaveBeenCalled(); + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_status" } })), + ); + emitServerEvent(socket, { + type: "response.done", + response: { id: "resp_status", status: "completed", output: [] }, + }); + + const done = bridge.submitToolResult("call_1", { text: "done" }); + emitFunctionOutputAdded(socket, "call_1"); + await done; + + expect(parseSent(socket).slice(-3)).toEqual([ + expectedFunctionOutput("call_1", { text: "done" }), + expect.objectContaining({ type: "session.update" }), + expectedResponseCreateEvent(), + ]); + }); +}); diff --git a/extensions/openai/realtime-voice-bridge.ts b/extensions/openai/realtime-voice-bridge.ts new file mode 100644 index 000000000000..fbcee5b08044 --- /dev/null +++ b/extensions/openai/realtime-voice-bridge.ts @@ -0,0 +1,700 @@ +import { randomUUID } from "node:crypto"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; +import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http"; +import { + captureWsEvent, + createDebugProxyWebSocketAgent, + resolveDebugProxySettings, +} from "openclaw/plugin-sdk/proxy-capture"; +import type { + RealtimeVoiceBridge, + RealtimeVoiceSessionConnection, + RealtimeVoiceToolResultOptions, +} from "openclaw/plugin-sdk/realtime-voice"; +import { RealtimeVoiceSessionLifecycle } from "openclaw/plugin-sdk/realtime-voice"; +import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import WebSocket from "ws"; +import { + captureOpenAIRealtimeWsClose, + readRealtimeErrorDetail, +} from "./realtime-provider-shared.js"; +import { buildOpenAIRealtimeSidebandUrl } from "./realtime-quicksilver-wire.js"; +import { + OpenAIRealtimeEvents, + OpenAIRealtimeMalformedAudioError, +} from "./realtime-voice-events.js"; +import { + OPENAI_REALTIME_DEFAULT_MODEL, + OPENAI_REALTIME_API_KEY_REQUIRED, + OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED, + OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED, + OPENAI_REALTIME_SIDEBAND_STARTUP_MAX_BYTES, + OPENAI_VOICE_WS_MAX_PAYLOAD_BYTES, + hasOpenAIRealtimeConfiguredApiKeyInput, + isDirectOpenAIRealtimeWebSocketUrl, + isOpenAIRealtimeStartupAuthFailure, + requireOpenAIRealtimeApiKey, + requireOpenAIRealtimePlatformAuth, + resolveOpenAIRealtimeEnvApiKey, + resolveOpenAIRealtimeSecretInput, + type OpenAIRealtimeUserMessageOptions, + type RealtimeEvent, +} from "./realtime-voice-session-policy.js"; + +export class OpenAIRealtimeBridge extends OpenAIRealtimeEvents implements RealtimeVoiceBridge { + private static readonly DEFAULT_MODEL = OPENAI_REALTIME_DEFAULT_MODEL; + + private static readonly MAX_RECONNECT_ATTEMPTS = 5; + + private static readonly BASE_RECONNECT_DELAY_MS = 1000; + + private static readonly CONNECT_TIMEOUT_MS = 10_000; + + private ws: WebSocket | null = null; + + private readonly lifecycle = new RealtimeVoiceSessionLifecycle("OpenAI"); + + private connectionUrl = ""; + + private readonly flowId = randomUUID(); + + private sessionReadyFired = false; + + private reconnectReason: string | undefined; + + private activeConnectionReason: string | undefined; + + private terminalError: Error | undefined; + + async connect(): Promise { + if (this.terminalError) { + throw this.terminalError; + } + await this.lifecycle.connect((connection) => this.doConnect(connection)); + } + + sendAudio(audio: Buffer): void { + if (this.lifecycle.phase() === "terminal") { + return; + } + if (!this.lifecycle.isReady() || this.ws?.readyState !== WebSocket.OPEN) { + this.lifecycle.enqueuePendingAudio(audio); + return; + } + this.sendEvent({ + type: "input_audio_buffer.append", + audio: audio.toString("base64"), + }); + } + + sendUserMessage(text: string, options?: OpenAIRealtimeUserMessageOptions): void { + if ( + options?.toolChoice && + (this.responseActive || + this.responseCreateInFlight || + this.responseCancelInFlight || + this.pendingToolCallIds.size > 0) + ) { + throw new Error("Forced realtime tool choice requires an idle response state"); + } + if (this.pendingToolCallIds.size > 0) { + // Control/status speech must not wait behind the long-running consult whose + // function output owns the default conversation response. + this.standaloneSpeechQueue.push(text); + this.flushStandaloneSpeech(); + return; + } + this.sendEvent({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }, + }); + this.requestResponseCreate(options); + } + + triggerGreeting(instructions?: string): void { + if (!this.isConnected() || !this.ws) { + return; + } + this.sendUserMessage(instructions ?? this.config.instructions ?? "Greet the meeting."); + } + + submitToolResult( + callId: string, + result: unknown, + options?: RealtimeVoiceToolResultOptions, + ): void { + if (this.lifecycle.phase() === "terminal" || !this.pendingToolCallIds.has(callId)) { + return; + } + const output = JSON.stringify(result); + if (typeof output !== "string") { + throw new Error("OpenAI realtime voice tool result is not JSON-serializable"); + } + this.sendEvent({ + type: "conversation.item.create", + item: { + type: "function_call_output", + call_id: callId, + output, + }, + }); + if (options?.willContinue === true) { + this.continuingToolCallIds.add(callId); + return; + } + this.continuingToolCallIds.delete(callId); + this.pendingToolCallIds.delete(callId); + if (options?.suppressResponse === true) { + this.flushPendingResponseCreate(); + return; + } + this.requestResponseCreate(); + } + + close(): void { + const connection = this.lifecycle.currentConnection(); + if (!this.lifecycle.cancel()) { + return; + } + this.resetTerminalState(); + if (!connection) { + return; + } + const ws = this.ws; + this.ws = null; + ws?.close(1000, "Bridge closed"); + this.notifyClose(connection, "completed"); + } + + isConnected(): boolean { + return this.lifecycle.isReady() && this.ws?.readyState === WebSocket.OPEN; + } + + private async doConnect(lifecycleConnection: RealtimeVoiceSessionConnection): Promise { + let activeWs: WebSocket | undefined; + let startupFrameBytes = 0; + const attempt = this.lifecycle.createConnectAttempt({ + connection: lifecycleConnection, + timeoutMs: OpenAIRealtimeBridge.CONNECT_TIMEOUT_MS, + timeoutError: () => new Error("OpenAI realtime connection timeout"), + onTimeout: () => activeWs?.terminate(), + onAbort: () => { + if (activeWs && activeWs.readyState !== WebSocket.CLOSED) { + activeWs.close(1000, "connection canceled"); + } + }, + }); + + const openWebSocket = (resolvedConnection: { + url: string; + headers: Record; + }) => { + if (attempt.settled) { + return; + } + if (!this.lifecycle.isCurrent(lifecycleConnection) || lifecycleConnection.signal.aborted) { + attempt.resolve(); + return; + } + // Auth preparation owns its own timeout. Start the socket deadline only + // after connection parameters are available. + attempt.startTimeout(); + const url = resolvedConnection.url; + this.connectionUrl = resolvedConnection.url; + const debugProxy = resolveDebugProxySettings(); + const proxyAgent = createDebugProxyWebSocketAgent(debugProxy); + const ws = new WebSocket(resolvedConnection.url, { + headers: resolvedConnection.headers, + maxPayload: OPENAI_VOICE_WS_MAX_PAYLOAD_BYTES, + ...(proxyAgent ? { agent: proxyAgent } : {}), + }); + activeWs = ws; + this.ws = ws; + + const rejectStartup = (error: Error) => { + if (!attempt.rejectStartup(error)) { + return; + } + if (ws.readyState !== WebSocket.CLOSED) { + ws.close(1000, "startup failed"); + } + }; + + ws.on("open", () => { + if (!this.lifecycle.acceptsEvents(lifecycleConnection)) { + ws.close(1000, "stale connection"); + return; + } + this.resetRealtimeSessionState(); + captureWsEvent({ + url, + direction: "local", + kind: "ws-open", + flowId: this.flowId, + meta: { + provider: "openai", + capability: "realtime-voice", + }, + }); + this.sendSessionUpdate(); + }); + + ws.on("message", (data: Buffer) => { + if (!this.lifecycle.acceptsEvents(lifecycleConnection) || this.ws !== ws) { + return; + } + if (attempt.settled && !attempt.ready) { + return; + } + if (!attempt.ready) { + startupFrameBytes += data.byteLength; + if (startupFrameBytes > OPENAI_REALTIME_SIDEBAND_STARTUP_MAX_BYTES) { + const error = new Error("OpenAI realtime sideband startup buffer exceeded"); + attempt.reject(error); + this.failConnection(error, ws, lifecycleConnection, { + code: 1009, + reason: "Sideband startup buffer exceeded", + }); + return; + } + } + captureWsEvent({ + url, + direction: "inbound", + kind: "ws-frame", + flowId: this.flowId, + payload: data, + meta: { + provider: "openai", + capability: "realtime-voice", + }, + }); + try { + const event = JSON.parse(data.toString()) as RealtimeEvent; + if (event.type === "error" && !attempt.ready) { + // Only direct OpenAI auth failures get bounded remediation. Azure, + // custom endpoints, and non-auth startup details remain provider-owned. + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(event.error) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : new Error(readRealtimeErrorDetail(event.error)), + ); + return; + } + if (event.type === "session.updated") { + try { + this.handleEvent(event, lifecycleConnection); + } catch (error) { + const readyError = toStringifiedError(error); + attempt.reject(readyError); + this.failConnection(readyError, ws, lifecycleConnection, { + code: 1011, + reason: "Readiness callback failed", + }); + return; + } + attempt.resolve(this.lifecycle.isReady()); + return; + } + this.handleEvent(event, lifecycleConnection); + } catch (error) { + if (error instanceof OpenAIRealtimeMalformedAudioError) { + attempt.reject(error); + this.failConnection(error, ws, lifecycleConnection, { + code: 1002, + reason: "Malformed audio payload", + }); + return; + } + console.error("[openai] realtime event parse failed:", error); + } + }); + + ws.on("error", (error) => { + if (!this.lifecycle.acceptsEvents(lifecycleConnection) || this.ws !== ws) { + return; + } + captureWsEvent({ + url, + direction: "local", + kind: "error", + flowId: this.flowId, + errorText: error instanceof Error ? error.message : String(error), + meta: { + provider: "openai", + capability: "realtime-voice", + }, + }); + if (!attempt.ready) { + const startupError = toStringifiedError(error); + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(startupError) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : startupError, + ); + return; + } + this.config.onError?.(toStringifiedError(error)); + }); + + ws.on("close", (code, reasonBuffer) => { + captureOpenAIRealtimeWsClose({ + url, + flowId: this.flowId, + capability: "realtime-voice", + code, + reasonBuffer, + }); + if (!this.lifecycle.isCurrent(lifecycleConnection)) { + return; + } + if (this.ws === ws) { + this.ws = null; + } + if (attempt.startupFailed) { + return; + } + if (this.terminalError) { + this.notifyClose(lifecycleConnection, "error"); + return; + } + if (this.lifecycle.terminalOutcome(lifecycleConnection) === "completed") { + attempt.resolve(); + this.notifyClose(lifecycleConnection, "completed"); + return; + } + if (!attempt.ready && !attempt.settled) { + const error = new Error("OpenAI realtime connection closed before ready"); + attempt.reject(error); + return; + } + const reason = this.reconnectReason ?? "websocket-close"; + this.reconnectReason = undefined; + void this.attemptReconnect(reason, lifecycleConnection); + }); + }; + + let connectionOrPromise: + | { url: string; headers: Record } + | Promise<{ url: string; headers: Record }>; + try { + connectionOrPromise = this.resolveConnectionParams(); + } catch (error) { + attempt.reject(toStringifiedError(error)); + return attempt.promise; + } + if (connectionOrPromise instanceof Promise) { + void connectionOrPromise.then(openWebSocket).catch((error: unknown) => { + if ( + !this.lifecycle.isCurrent(lifecycleConnection) || + this.lifecycle.terminalOutcome(lifecycleConnection) === "completed" + ) { + attempt.resolve(); + return; + } + attempt.reject(toStringifiedError(error)); + }); + } else { + try { + openWebSocket(connectionOrPromise); + } catch (error) { + attempt.reject(toStringifiedError(error)); + } + } + await attempt.promise; + } + + private resolveConnectionParams(): + | { url: string; headers: Record } + | Promise<{ url: string; headers: Record }> { + const cfg = this.config; + const model = cfg.model ?? OpenAIRealtimeBridge.DEFAULT_MODEL; + if (cfg.azureEndpoint && cfg.azureDeployment) { + const apiKey = requireOpenAIRealtimeApiKey(cfg.apiKey); + const base = cfg.azureEndpoint + .replace(/\/$/, "") + .replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`); + const apiVersion = cfg.azureApiVersion ?? "2024-10-01-preview"; + const url = `${base}/openai/realtime?api-version=${apiVersion}&deployment=${encodeURIComponent( + cfg.azureDeployment, + )}`; + return { + url, + headers: resolveProviderRequestHeaders({ + provider: "openai", + baseUrl: url, + capability: "audio", + transport: "websocket", + defaultHeaders: { "api-key": apiKey }, + }) ?? { "api-key": apiKey }, + }; + } + + if (hasOpenAIRealtimeConfiguredApiKeyInput(cfg.apiKey)) { + const directApiKey = resolveOpenAIRealtimeSecretInput(cfg.apiKey); + if (directApiKey.status === "missing") { + throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); + } + return this.resolveApiKeyConnectionParams(directApiKey.value, model); + } + + if (cfg.azureEndpoint) { + const directApiKey = resolveOpenAIRealtimeEnvApiKey(); + if (directApiKey.status === "missing") { + throw new Error(OPENAI_REALTIME_API_KEY_REQUIRED); + } + return this.resolveApiKeyConnectionParams(directApiKey.value, model); + } + + return this.resolveDefaultConnectionParams(model); + } + + private async resolveDefaultConnectionParams(model: string): Promise<{ + url: string; + headers: Record; + }> { + const auth = await requireOpenAIRealtimePlatformAuth({ + configuredApiKey: this.config.apiKey, + cfg: this.config.cfg, + }); + return this.resolveApiKeyConnectionParams(auth.value, model); + } + + private resolveApiKeyConnectionParams( + apiKey: string, + model: string, + ): { url: string; headers: Record } { + const cfg = this.config; + if (cfg.azureEndpoint) { + const base = cfg.azureEndpoint + .replace(/\/$/, "") + .replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`); + const url = `${base}/v1/realtime?model=${encodeURIComponent(model)}`; + return { + url, + headers: resolveProviderRequestHeaders({ + provider: "openai", + baseUrl: url, + capability: "audio", + transport: "websocket", + defaultHeaders: { Authorization: `Bearer ${apiKey}` }, + }) ?? { Authorization: `Bearer ${apiKey}` }, + }; + } + + const url = cfg.callId + ? buildOpenAIRealtimeSidebandUrl(cfg.callId) + : `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`; + return { + url, + headers: resolveProviderRequestHeaders({ + provider: "openai", + baseUrl: url, + capability: "audio", + transport: "websocket", + defaultHeaders: { + Authorization: `Bearer ${apiKey}`, + }, + }) ?? { + Authorization: `Bearer ${apiKey}`, + }, + }; + } + + private async attemptReconnect( + reason: string, + connection: RealtimeVoiceSessionConnection, + ): Promise { + const retry = this.lifecycle.retry(connection, OpenAIRealtimeBridge.MAX_RECONNECT_ATTEMPTS); + if (!retry) { + return; + } + if (retry === "exhausted") { + this.config.onEvent?.({ + direction: "client", + type: "session.reconnect.exhausted", + detail: `reason=${reason} attempts=${OpenAIRealtimeBridge.MAX_RECONNECT_ATTEMPTS}`, + }); + if (this.lifecycle.failure(connection)) { + this.resetTerminalState(); + } + this.notifyClose(connection, "error"); + return; + } + const attempt = retry.attempt; + const delay = OpenAIRealtimeBridge.BASE_RECONNECT_DELAY_MS * 2 ** (attempt - 1); + if (attempt === 1) { + // OpenAI reconnects start a fresh provider generation. Reset consumers + // before backoff so stale async work cannot satisfy reused call ids. + this.resetRealtimeSessionState(); + this.config.onEvent?.({ + direction: "client", + type: "session.continuity.reset", + }); + } + this.config.onEvent?.({ + direction: "client", + type: "session.reconnect.scheduled", + detail: `reason=${reason} attempt=${attempt} delayMs=${delay}`, + }); + try { + await sleepWithAbort(delay, retry.signal); + } catch (error) { + if (!retry.signal.aborted) { + throw error; + } + return; + } + const nextConnection = this.lifecycle.reconnect(connection); + if (!nextConnection) { + return; + } + try { + await this.doConnect(nextConnection); + if (!this.lifecycle.isCurrent(nextConnection) || !this.lifecycle.isReady()) { + return; + } + this.config.onEvent?.({ + direction: "client", + type: "session.reconnect.ready", + detail: `reason=${reason} attempt=${attempt}`, + }); + } catch (error) { + if (!this.lifecycle.acceptsEvents(nextConnection)) { + return; + } + this.config.onError?.(toStringifiedError(error)); + await this.attemptReconnect(reason, nextConnection); + } + } + + private markSessionReady(connection: RealtimeVoiceSessionConnection): void { + if (!this.lifecycle.ready(connection)) { + return; + } + if (this.activeConnectionReason) { + this.config.onEvent?.({ + direction: "server", + type: "session.rotation.ready", + detail: `reason=${this.activeConnectionReason}`, + }); + this.activeConnectionReason = undefined; + } + if (!this.sessionReadyFired) { + this.sessionReadyFired = true; + this.config.onReady?.(); + } + for (const chunk of this.lifecycle.drainPendingAudio()) { + this.sendAudio(chunk); + } + } + + private resetTerminalState(): void { + // Transport retries preserve readiness and rotation attribution. A terminal + // session clears both so explicit bridge reuse starts as a new session. + this.sessionReadyFired = false; + this.reconnectReason = undefined; + this.activeConnectionReason = undefined; + this.resetRealtimeSessionState(); + } + + private failConnection( + error: Error, + ws: WebSocket, + connection: RealtimeVoiceSessionConnection, + close: { code: number; reason: string }, + ): void { + if (this.terminalError) { + return; + } + this.terminalError = error; + this.lifecycle.failure(connection); + this.resetTerminalState(); + try { + this.config.onError?.(error); + } finally { + if (ws.readyState !== WebSocket.CLOSED) { + ws.close(close.code, close.reason); + } else { + this.notifyClose(connection, "error"); + } + } + } + + private notifyClose( + connection: RealtimeVoiceSessionConnection, + outcome: "completed" | "error", + ): void { + const terminalOutcome = this.lifecycle.close(connection, outcome); + if (!terminalOutcome) { + return; + } + this.resetTerminalState(); + this.config.onClose?.(terminalOutcome); + } + + protected sendEvent(event: unknown, detail?: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + const type = + event && typeof event === "object" && typeof (event as { type?: unknown }).type === "string" + ? (event as { type: string }).type + : "unknown"; + this.config.onEvent?.({ direction: "client", type, ...(detail ? { detail } : {}) }); + const payload = JSON.stringify(event); + captureWsEvent({ + url: this.connectionUrl, + direction: "outbound", + kind: "ws-frame", + flowId: this.flowId, + payload, + meta: { + provider: "openai", + capability: "realtime-voice", + }, + }); + this.ws.send(payload); + } + } + + protected acceptsEvent(connection: RealtimeVoiceSessionConnection): boolean { + return this.lifecycle.acceptsEvents(connection); + } + + protected isTransportOpen(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + protected onSessionUpdated(connection: RealtimeVoiceSessionConnection): void { + this.markSessionReady(connection); + } + + protected rotateExpiredSession(): void { + this.reconnectReason = "max-duration"; + this.activeConnectionReason = "max-duration"; + this.config.onEvent?.({ + direction: "server", + type: "session.rotation", + detail: "reason=max-duration", + }); + this.ws?.close(1000, "max-duration rotation"); + } + + protected failToolCallSessionLimit( + error: Error, + connection: RealtimeVoiceSessionConnection, + ): void { + const ws = this.ws; + if (ws) { + this.failConnection(error, ws, connection, { + code: 1008, + reason: "Tool-call session limit exceeded", + }); + } + } +} diff --git a/extensions/openai/realtime-voice-browser-auth.test.ts b/extensions/openai/realtime-voice-browser-auth.test.ts new file mode 100644 index 000000000000..7f37fb805a69 --- /dev/null +++ b/extensions/openai/realtime-voice-browser-auth.test.ts @@ -0,0 +1,762 @@ +// Openai tests cover realtime voice provider plugin behavior. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +const OPENAI_REALTIME_REJECTED_KEY_MESSAGE = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + createNativeBridge, + beginBridgeConnection, + openSocket, + createJsonResponse, + requireRecord, + requireNestedRecord, + expectRecordFields, + firstMockCall, + requireFetchRequest, + requireFetchInit, + requireFetchHeaders, + requireFetchJsonBody, + createTestJwt, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice browser authentication", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("requires Platform auth for native realtime websocket bridges", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await expect(bridge.connect()).rejects.toThrow( + "OpenAI Realtime voice requires an OpenAI Platform API key", + ); + + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("uses OPENAI_API_KEY for default GPT realtime bridges", async () => { + vi.stubEnv("OPENAI_API_KEY", "test-api-key-env"); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + bridge.close(); + + expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ + [ + { + provider: "openai", + cfg: {}, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }, + ], + ]); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + const socket = FakeWebSocket.instances[0]; + const options = socket?.args[1] as { headers?: Record } | undefined; + expect(options?.headers?.Authorization).toBe("Bearer test-api-key-env"); + }); + + it("does not use Codex OAuth profiles for default GPT realtime bridges", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await expect(bridge.connect()).rejects.toThrow( + "OpenAI Realtime voice requires an OpenAI Platform API key", + ); + + expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ + [ + { + provider: "openai", + cfg: {}, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }, + ], + ]); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("uses OPENAI_API_KEY when a configured API-key profile cannot be resolved", async () => { + vi.stubEnv("OPENAI_API_KEY", "test-api-key-env"); + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); + isProviderAuthProfileConfiguredMock.mockReturnValueOnce(true); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + bridge.close(); + + expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); + const socket = FakeWebSocket.instances[0]; + const options = socket?.args[1] as { headers?: Record } | undefined; + expect(options?.headers?.Authorization).toBe("Bearer test-api-key-env"); + }); + + it("uses OpenAI API-key auth profiles", async () => { + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("test-api-key-profile"); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + bridge.close(); + + expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ + [ + { + provider: "openai", + cfg: {}, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }, + ], + ]); + const socket = FakeWebSocket.instances[0]; + const options = socket?.args[1] as { headers?: Record } | undefined; + expect(options?.headers?.Authorization).toBe("Bearer test-api-key-profile"); + }); + + it("keeps explicit OpenAI realtime API keys as the advanced override", () => { + vi.stubEnv("OPENAI_API_KEY", "test-api-key-env"); + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("test-api-key-profile"); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { + apiKey: "test-api-key-configured", + model: "gpt-realtime-2", + }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + bridge.close(); + + expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalled(); + const socket = FakeWebSocket.instances[0]; + const options = socket?.args[1] as { headers?: Record } | undefined; + expect(options?.headers?.Authorization).toBe("Bearer test-api-key-configured"); + }); + + it("requires an API key for custom realtime endpoints", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: { + azureEndpoint: "https://example.openai.azure.com", + model: "gpt-realtime-2", + }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await expect(bridge.connect()).rejects.toThrow("OpenAI Realtime voice requires an API key"); + + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("returns browser-safe OpenClaw attribution headers for native WebRTC offers", async () => { + vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ + client_secret: { value: "client-secret-123" }, + expires_at: 1_765_000_000, + }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + const session = await provider.createBrowserSession({ + providerConfig: { apiKey: "test-api-key-test" }, + instructions: "Be concise.", + voice: " Marin ", + }); + + expectRecordFields(requireFetchRequest(), "fetch request", { + url: "https://api.openai.com/v1/realtime/client_secrets", + policy: { + allowRfc2544BenchmarkRange: true, + allowIpv6UniqueLocalRange: true, + hostnameAllowlist: ["api.openai.com"], + }, + }); + expectRecordFields(requireFetchInit(), "fetch init", { method: "POST" }); + expectRecordFields(requireFetchHeaders(), "fetch headers", { + Authorization: "Bearer test-api-key-test", + "Content-Type": "application/json", + originator: "openclaw", + version: "2026.3.22", + "User-Agent": "openclaw/2026.3.22", + }); + const body = requireFetchJsonBody(); + const bodySession = requireRecord(body.session, "fetch session"); + expect(bodySession.model).toBe("gpt-realtime-2.1"); + expect(requireNestedRecord(bodySession, ["audio", "input"])).toEqual({ + noise_reduction: { type: "near_field" }, + turn_detection: { + type: "server_vad", + create_response: true, + interrupt_response: true, + }, + transcription: { model: "gpt-4o-mini-transcribe" }, + }); + expect(requireNestedRecord(bodySession, ["audio", "output"])).toEqual({ voice: "marin" }); + expect(bodySession).not.toHaveProperty("temperature"); + expectRecordFields(session, "browser session", { + provider: "openai", + transport: "webrtc", + clientSecret: "client-secret-123", + offerUrl: "https://api.openai.com/v1/realtime/calls", + model: "gpt-realtime-2.1", + expiresAt: 1_765_000_000_000, + }); + // originator, version, and User-Agent are server-side attribution headers; they + // must not be forwarded to the browser so that the browser's direct SDP POST to + // api.openai.com passes the CORS preflight (only authorization,content-type + // allowed — #76435). All three are filtered, leaving no browser offer headers. + expect((session as { offerHeaders?: Record }).offerHeaders).toBeUndefined(); + }); + + it.each(["configured", "profile", "environment"] as const)( + "explains how auth precedence affects a rejected %s API key", + async (source) => { + if (source === "profile") { + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("test-api-key-profile"); + } else if (source === "environment") { + vi.stubEnv("OPENAI_API_KEY", "test-api-key-env"); + } + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse( + { error: { message: "Incorrect API key provided: test-api-key-proj-***" } }, + { status: 401 }, + ), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await expect( + provider.createBrowserSession({ + providerConfig: source === "configured" ? { apiKey: "test-api-key-stale" } : {}, + }), + ).rejects.toThrow( + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source", + ); + }, + ); + + it("resolves keychain OPENAI_API_KEY refs before creating browser sessions", async () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BROWSER_TEST"); + execFileSyncMock.mockReturnValueOnce("test-api-key-browser-env\n"); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ + client_secret: { value: "client-secret-123" }, + }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await provider.createBrowserSession({ + providerConfig: {}, + instructions: "Be concise.", + }); + + const [securityBinary, securityArgs, securityOptions] = firstMockCall( + execFileSyncMock, + "security keychain lookup", + ); + expect(securityBinary).toBe("/usr/bin/security"); + expect(securityArgs).toEqual([ + "find-generic-password", + "-s", + "openclaw", + "-a", + "OPENAI_REALTIME_BROWSER_TEST", + "-w", + ]); + expectRecordFields(securityOptions, "security command options", { + encoding: "utf8", + timeout: 5000, + }); + expectRecordFields(requireFetchHeaders(), "fetch headers", { + Authorization: "Bearer test-api-key-browser-env", + }); + }); + + it("resolves and caches keychain OPENAI_API_KEY refs before creating bridges", async () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BRIDGE_TEST"); + execFileSyncMock.mockReturnValue("test-api-key-bridge-env\n"); + const provider = buildOpenAIRealtimeVoiceProvider(); + + const first = provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const second = provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + void first.connect(); + void second.connect(); + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(2)); + first.close(); + second.close(); + + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + for (const socket of FakeWebSocket.instances) { + const options = socket.args[1] as { headers?: Record } | undefined; + expectRecordFields(options?.headers, "websocket headers", { + Authorization: "Bearer test-api-key-bridge-env", + }); + } + }); + + it("keeps Platform precedence for GA realtime when OAuth is also available", async () => { + const oauthToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") ? oauthToken : undefined, + ); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ client_secret: { value: "client-secret-123" } }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + + await provider.createBrowserSession?.({ + providerConfig: { apiKey: "test-api-key-platform" }, + model: "gpt-realtime-2.1", + }); + + expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalledWith( + expect.objectContaining({ profileTypes: ["oauth"] }), + ); + expectRecordFields(requireFetchHeaders(), "fetch headers", { + Authorization: "Bearer test-api-key-platform", + }); + }); + + it("does not use GA OAuth fallback when a Platform credential source is unresolved", async () => { + const oauthToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") ? oauthToken : undefined, + ); + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("api_key") === true, + ); + const createBrowserSession = vi.fn(); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { handlesAgentConsult: true as const }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + + await expect( + provider.createBrowserSession?.({ + cfg: {} as never, + providerConfig: {}, + model: "gpt-realtime-2.1", + agentId: "main", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + } as never), + ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); + expect(createBrowserSession).not.toHaveBeenCalled(); + expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalledWith( + expect.objectContaining({ profileTypes: ["oauth"] }), + ); + }); + + it("requires Platform auth for browser sessions", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + await expect( + provider.createBrowserSession?.({ + providerConfig: {}, + }), + ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("reports an unresolved Platform credential without trying another auth route", async () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST"); + execFileSyncMock.mockImplementationOnce(() => { + throw new Error("keychain unavailable"); + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + + await expect( + provider.createBrowserSession?.({ + providerConfig: {}, + }), + ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); + }); + + it("treats OpenAI API-key auth profiles as configured for browser realtime sessions", () => { + isProviderAuthProfileConfiguredMock.mockReturnValue(true); + const provider = buildOpenAIRealtimeVoiceProvider(); + const cfg = { agents: { defaults: {} } } as never; + + expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(true); + expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ + provider: "openai", + cfg, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }); + }); + + it("does not configure Azure realtime sessions without a Platform API key", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const cfg = { agents: { defaults: {} } } as never; + + expect( + provider.isConfigured({ + cfg, + providerConfig: { + azureEndpoint: "https://example.openai.azure.com", + azureDeployment: "realtime", + }, + }), + ).toBe(false); + }); + + it("requires Platform auth before minting browser realtime client secrets", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + const cfg = { agents: { defaults: {} } } as never; + + await expect( + provider.createBrowserSession({ + cfg, + providerConfig: {}, + instructions: "Be concise.", + }), + ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("uses OPENAI_API_KEY for default GPT browser sessions", async () => { + vi.stubEnv("OPENAI_API_KEY", "test-api-key-env"); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ + client_secret: { value: "client-secret-123" }, + }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + const cfg = { agents: { defaults: {} } } as never; + + await provider.createBrowserSession({ + cfg, + providerConfig: {}, + model: "gpt-realtime-2", + instructions: "Be concise.", + }); + + expectRecordFields(requireFetchHeaders(), "fetch headers", { + Authorization: "Bearer test-api-key-env", + }); + }); + + it("fails closed when keychain refs cannot be resolved", async () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST"); + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); + execFileSyncMock.mockImplementationOnce(() => { + throw new Error("keychain unavailable"); + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + + const bridge = provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await expect(bridge.connect()).rejects.toThrow( + "OpenAI Realtime voice requires an OpenAI Platform API key", + ); + expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); + }); + + it("fails closed when a configured API-key profile cannot be resolved", async () => { + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); + isProviderAuthProfileConfiguredMock.mockReturnValueOnce(true); + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + cfg: {} as never, + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await expect(bridge.connect()).rejects.toThrow( + "OpenAI Realtime voice requires an OpenAI Platform API key", + ); + expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); + }); + + it("treats pre-ready auth errors as a single startup failure", async () => { + const onError = vi.fn(); + const onClose = vi.fn(); + const bridge = createNativeBridge({ onError, onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: "Incorrect API key provided: test-api-key-proj-***" }, + }), + ), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { message: "Incorrect API key provided: test-api-key-proj-***" }, + }), + ), + ); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(onError).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(socket.closed).toBe(true); + expect(bridge.isConnected()).toBe(false); + }); + + it("normalizes structured direct OpenAI startup auth errors", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_api_key", + message: "Invalid API key", + }, + }), + ), + ); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it("normalizes direct OpenAI socket handshake auth errors", async () => { + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it.each([ + [ + "Azure deployment", + { + apiKey: "test-api-key-test", + azureEndpoint: "https://example.openai.azure.com", + azureDeployment: "realtime-prod", + }, + ], + [ + "custom endpoint", + { + apiKey: "test-api-key-test", + azureEndpoint: "https://realtime-proxy.example.com", + }, + ], + ])("preserves %s startup auth errors", async (_label, providerConfig) => { + const bridge = createNativeBridge({ + providerConfig, + }); + const { connecting, socket } = beginBridgeConnection(bridge); + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow("Unexpected server response: 401"); + expect(bridge.isConnected()).toBe(false); + }); +}); diff --git a/extensions/openai/realtime-voice-events.ts b/extensions/openai/realtime-voice-events.ts new file mode 100644 index 000000000000..a60c04c1607a --- /dev/null +++ b/extensions/openai/realtime-voice-events.ts @@ -0,0 +1,402 @@ +import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; +import type { RealtimeVoiceSessionConnection } from "openclaw/plugin-sdk/realtime-voice"; +import { normalizeRealtimeVoiceResponseOutcome } from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readRealtimeErrorDetail } from "./realtime-provider-shared.js"; +import { OpenAIRealtimeProtocol } from "./realtime-voice-protocol.js"; +import { + OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX, + OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR, + isOpenAIRealtimeMaxSessionDurationError, + readRealtimeErrorEventId, + type RealtimeEvent, +} from "./realtime-voice-session-policy.js"; + +export class OpenAIRealtimeMalformedAudioError extends Error {} + +function base64ToBuffer(b64: string): Buffer { + const canonicalAudio = canonicalizeBase64(b64); + if (!canonicalAudio) { + throw new OpenAIRealtimeMalformedAudioError( + "OpenAI realtime stream returned malformed base64 audio data", + ); + } + return Buffer.from(canonicalAudio, "base64"); +} + +export abstract class OpenAIRealtimeEvents extends OpenAIRealtimeProtocol { + protected handleEvent(event: RealtimeEvent, connection: RealtimeVoiceSessionConnection): void { + const emitServerEvent = () => + this.config.onEvent?.({ + direction: "server", + type: event.type, + detail: this.describeServerEvent(event), + ...(event.item_id ? { itemId: event.item_id } : {}), + ...((event.response_id ?? event.response?.id) + ? { responseId: event.response_id ?? event.response?.id } + : {}), + }); + if ( + event.type === "error" && + isOpenAIRealtimeMaxSessionDurationError(readRealtimeErrorDetail(event.error)) + ) { + this.rotateExpiredSession(); + return; + } + if (event.type === "response.done") { + this.handleResponseDone(event, connection, emitServerEvent); + return; + } + if (event.type === "response.cancelled") { + try { + emitServerEvent(); + } finally { + this.releaseResponseState(); + } + return; + } + emitServerEvent(); + switch (event.type) { + case "session.created": + return; + + case "session.updated": { + this.onSessionUpdated(connection); + return; + } + + case "response.created": + this.responseActive = true; + this.responseCreateInFlight = false; + return; + + case "conversation.output_audio.delta": + case "response.audio.delta": + case "response.output_audio.delta": { + const audioDelta = event.delta ?? event.data; + if (!audioDelta) { + return; + } + const audio = base64ToBuffer(audioDelta); + this.config.onAudio(audio); + if (event.item_id && event.item_id !== this.lastAssistantItemId) { + this.lastAssistantItemId = event.item_id; + this.responseStartTimestamp = this.latestMediaTimestamp; + } else if (this.responseStartTimestamp === null) { + this.responseStartTimestamp = this.latestMediaTimestamp; + } + this.responseActive = true; + this.sendMark(); + return; + } + + case "input_audio_buffer.speech_started": + if (this.config.interruptResponseOnInputAudio ?? this.config.autoRespondToAudio ?? true) { + this.handleBargeIn(); + } + return; + + case "conversation.output_transcript.delta": + case "response.text.delta": + case "response.output_text.delta": + case "response.audio_transcript.delta": + case "response.output_audio_transcript.delta": + if (event.delta) { + this.config.onTranscript?.("assistant", event.delta, false); + } + return; + + case "response.text.done": + case "response.output_text.done": + case "response.audio_transcript.done": + case "response.output_audio_transcript.done": + { + const transcript = event.transcript ?? event.text; + if (transcript) { + this.config.onTranscript?.("assistant", transcript, true); + } + } + return; + + case "conversation.input_transcript.delta": + case "conversation.item.input_audio_transcription.delta": + if (event.delta) { + this.config.onTranscript?.("user", event.delta, false); + } + return; + + case "conversation.item.input_audio_transcription.completed": + if (event.transcript) { + this.config.onTranscript?.("user", event.transcript, true); + } + return; + + case "conversation.item.input_audio_transcription.failed": + this.config.onError?.(new Error(readRealtimeErrorDetail(event.error))); + break; + + case "conversation.item.added": + break; + + case "response.function_call_arguments.delta": + case "response.function_call_arguments.done": + case "conversation.item.done": + // These events are provisional and can also arrive for interrupted, + // incomplete, or cancelled responses. Successful response.done output + // is the sole execution boundary. + return; + + case "error": { + const detail = readRealtimeErrorDetail(event.error); + const rejectedEventId = readRealtimeErrorEventId(event.error); + if (rejectedEventId && rejectedEventId === this.standaloneSpeechEventId) { + this.responseCreateInFlight = false; + this.standaloneSpeechActive = false; + this.standaloneSpeechEventId = null; + this.config.onError?.(new Error(detail)); + if (this.standaloneSpeechQueue.length > 0) { + this.flushStandaloneSpeech(); + } else if (this.responseCreatePending) { + this.flushPendingResponseCreate(); + } + return; + } + const rejectsManualResponseCreate = + this.manualResponseCreateEventId !== null && + readRealtimeErrorEventId(event.error) === this.manualResponseCreateEventId; + if ( + rejectsManualResponseCreate && + detail.startsWith(OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX) + ) { + this.responseActive = true; + this.responseCreateInFlight = false; + this.manualResponseCreateEventId = null; + this.responseCreatePending = true; + return; + } + const rejectsManualResponseCancel = + this.manualResponseCancelEventId !== null && + readRealtimeErrorEventId(event.error) === this.manualResponseCancelEventId; + if (detail === OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR) { + if (!rejectsManualResponseCancel) { + return; + } + this.responseActive = false; + this.responseCancelInFlight = false; + this.manualResponseCancelEventId = null; + if (this.responseCreatePending) { + this.flushPendingResponseCreate(); + } else { + this.restoreAutoRespondAfterManualResponse(); + } + return; + } + if (rejectsManualResponseCreate) { + this.responseCreateInFlight = false; + this.manualResponseCreateEventId = null; + if (this.responseCreatePending) { + this.flushPendingResponseCreate(); + } else { + this.restoreAutoRespondAfterManualResponse(); + } + } + this.config.onError?.(new Error(detail)); + } + + default: + } + } + + private handleCompletedResponse( + event: RealtimeEvent, + connection: RealtimeVoiceSessionConnection, + ): boolean { + if ( + event.response?.status !== "completed" || + !Array.isArray(event.response.output) || + !this.config.onToolCall + ) { + return false; + } + for (const output of event.response.output) { + if (!this.acceptsEvent(connection) || !this.isTransportOpen()) { + return true; + } + if ( + !isRecord(output) || + output.type !== "function_call" || + (output.status !== undefined && output.status !== "completed") + ) { + continue; + } + const itemId = typeof output.id === "string" ? output.id.trim() || undefined : undefined; + const callId = typeof output.call_id === "string" ? output.call_id.trim() : ""; + const name = typeof output.name === "string" ? output.name.trim() : ""; + if (!callId || !name || this.completedToolCallIds.has(callId)) { + continue; + } + if (this.completedToolCallIds.size >= OpenAIRealtimeProtocol.MAX_COMPLETED_TOOL_CALL_IDS) { + this.failToolCallSessionLimit( + new Error( + `OpenAI realtime tool-call session limit exceeded (${OpenAIRealtimeProtocol.MAX_COMPLETED_TOOL_CALL_IDS})`, + ), + connection, + ); + return true; + } + this.completedToolCallIds.add(callId); + this.pendingToolCallIds.add(callId); + if (typeof output.arguments !== "string") { + this.rejectToolCallArguments({ + itemId, + callId, + reason: "invalid-json-type", + message: "Invalid tool arguments: expected a JSON object.", + }); + continue; + } + const rawArgs = output.arguments; + if (Buffer.byteLength(rawArgs, "utf8") > OpenAIRealtimeProtocol.MAX_TOOL_ARGUMENT_BYTES) { + this.rejectToolCallArguments({ + itemId, + callId, + reason: "too-large", + message: `Realtime tool arguments exceed the ${OpenAIRealtimeProtocol.MAX_TOOL_ARGUMENT_BYTES}-byte UTF-8 limit`, + }); + continue; + } + let args: unknown; + try { + args = JSON.parse(rawArgs || "{}"); + } catch { + this.rejectToolCallArguments({ + itemId, + callId, + reason: "malformed-json", + message: "Invalid tool arguments: expected a JSON object.", + }); + continue; + } + if (!isRecord(args)) { + this.rejectToolCallArguments({ + itemId, + callId, + reason: "non-object-json", + message: "Invalid tool arguments: expected a JSON object.", + }); + continue; + } + this.config.onToolCall({ itemId: itemId ?? callId, callId, name, args }); + } + return false; + } + + private handleResponseDone( + event: RealtimeEvent, + connection: RealtimeVoiceSessionConnection, + emitServerEvent: () => void, + ): void { + const outcome = normalizeRealtimeVoiceResponseOutcome({ + providerLabel: "OpenAI realtime voice", + response: event.response, + responseId: event.response_id, + }); + let callbackError: unknown; + let providerTerminated = false; + const invoke = (callback: () => void) => { + try { + callback(); + } catch (error) { + callbackError ??= error; + } + }; + try { + invoke(() => this.config.onResponseDone?.(outcome)); + invoke(emitServerEvent); + invoke(() => { + providerTerminated = this.handleCompletedResponse(event, connection); + }); + } finally { + // response.done owns response state regardless of observer success. A fatal tool + // boundary still clears state, but must not start queued work on a closing socket. + const canDrain = + !providerTerminated && this.acceptsEvent(connection) && this.isTransportOpen(); + this.releaseResponseState({ drain: canDrain }); + } + if (callbackError) { + throw callbackError instanceof Error + ? callbackError + : new Error("OpenAI realtime response callback failed", { cause: callbackError }); + } + } + + private rejectToolCallArguments(params: { + itemId?: string; + callId: string; + reason: string; + message: string; + }): void { + this.config.onEvent?.({ + direction: "server", + type: "tool_call.arguments.rejected", + detail: `reason=${params.reason}`, + itemId: params.itemId, + }); + this.submitToolResult(params.callId, { error: params.message }); + } + + private describeServerEvent(event: RealtimeEvent): string | undefined { + if ( + event.type === "error" || + event.type === "conversation.item.input_audio_transcription.failed" + ) { + return readRealtimeErrorDetail(event.error); + } + if (event.type === "session.created" || event.type === "session.updated") { + const session = isRecord(event.session) ? event.session : undefined; + const tools = Array.isArray(session?.tools) ? session.tools.length : 0; + const rawToolChoice = session?.tool_choice; + const toolChoice = + typeof rawToolChoice === "string" + ? rawToolChoice + : isRecord(rawToolChoice) && typeof rawToolChoice.type === "string" + ? rawToolChoice.type + : "unset"; + return `tools=${tools} toolChoice=${toolChoice}`; + } + if ( + (event.type === "conversation.item.added" || event.type === "conversation.item.done") && + event.item?.type + ) { + return [ + `itemType=${event.item.type}`, + event.item.name ? `name=${event.item.name}` : undefined, + ] + .filter(Boolean) + .join(" "); + } + if (event.type === "response.done") { + const status = event.response?.status; + const details = + event.response?.status_details === undefined + ? undefined + : JSON.stringify(event.response.status_details); + return ( + [status ? `status=${status}` : undefined, details].filter(Boolean).join(" ") || undefined + ); + } + if (event.type === "response.cancelled") { + return "cancelled"; + } + return undefined; + } + + protected abstract acceptsEvent(connection: RealtimeVoiceSessionConnection): boolean; + protected abstract isTransportOpen(): boolean; + protected abstract onSessionUpdated(connection: RealtimeVoiceSessionConnection): void; + protected abstract rotateExpiredSession(): void; + protected abstract failToolCallSessionLimit( + error: Error, + connection: RealtimeVoiceSessionConnection, + ): void; +} diff --git a/extensions/openai/realtime-voice-protocol.ts b/extensions/openai/realtime-voice-protocol.ts new file mode 100644 index 000000000000..513d1ff832a2 --- /dev/null +++ b/extensions/openai/realtime-voice-protocol.ts @@ -0,0 +1,417 @@ +import { randomUUID } from "node:crypto"; +import type { + RealtimeVoiceAudioFormat, + RealtimeVoiceBargeInOptions, + RealtimeVoiceToolResultOptions, +} from "openclaw/plugin-sdk/realtime-voice"; +import { REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ } from "openclaw/plugin-sdk/realtime-voice"; +import { + AZURE_OPENAI_REALTIME_TOOL_NAME_MAX_LENGTH, + OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS, + OPENAI_REALTIME_DEFAULT_MODEL, + buildOpenAIRealtimeGaSessionPolicy, + buildOpenAIRealtimeTurnDetectionConfig, + normalizeOpenAIRealtimeTools, + parsePlaybackMarkSequence, + type OpenAIRealtimeUserMessageOptions, + type OpenAIRealtimeVoiceBridgeConfig, + type RealtimeAzureDeploymentSessionUpdate, + type RealtimeGaSessionUpdate, + type RealtimeTurnDetectionConfig, +} from "./realtime-voice-session-policy.js"; + +export abstract class OpenAIRealtimeProtocol { + static readonly MAX_TOOL_ARGUMENT_BYTES = 256_000; + + // Realtime defines no replay window. Keep every terminal id for this + // connection generation, then fail instead of re-admitting late duplicates. + static readonly MAX_COMPLETED_TOOL_CALL_IDS = 1_024; + + readonly supportsToolResultContinuation = true; + + readonly supportsToolResultSuppression = true; + + protected nextMarkSequence = 1; + + protected oldestOutstandingMarkSequence: number | null = null; + + protected latestOutstandingMarkSequence: number | null = null; + + protected responseStartTimestamp: number | null = null; + + protected responseActive = false; + + protected responseCreateInFlight = false; + + protected manualResponseCreateEventId: string | null = null; + + protected responseCancelInFlight = false; + + protected manualResponseCancelEventId: string | null = null; + + protected responseCreatePending = false; + + protected autoRespondSuppressedForManualResponse = false; + + protected continuingToolCallIds = new Set(); + + protected pendingToolCallIds = new Set(); + + protected latestMediaTimestamp = 0; + + protected lastAssistantItemId: string | null = null; + + protected completedToolCallIds = new Set(); + + protected standaloneSpeechQueue: string[] = []; + + protected standaloneSpeechActive = false; + + protected standaloneSpeechEventId: string | null = null; + + private readonly audioFormat: RealtimeVoiceAudioFormat; + + constructor(protected readonly config: OpenAIRealtimeVoiceBridgeConfig) { + this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ; + } + + setMediaTimestamp(ts: number): void { + this.latestMediaTimestamp = ts; + } + + acknowledgeMark(markName?: string): void { + const oldest = this.oldestOutstandingMarkSequence; + const latest = this.latestOutstandingMarkSequence; + if (oldest === null || latest === null) { + return; + } + const acknowledgedSequence = + markName === undefined ? oldest : parsePlaybackMarkSequence(markName); + if ( + acknowledgedSequence === undefined || + acknowledgedSequence < oldest || + acknowledgedSequence > latest + ) { + return; + } + // Marks follow ordered playback. Reaching a named mark also acknowledges every + // earlier mark, while late acknowledgements from that prefix remain harmless. + if (acknowledgedSequence === latest) { + this.oldestOutstandingMarkSequence = null; + this.latestOutstandingMarkSequence = null; + return; + } + this.oldestOutstandingMarkSequence = acknowledgedSequence + 1; + } + + protected sendSessionUpdate(): void { + if (this.usesAzureDeploymentRealtimeApi()) { + this.sendEvent(this.buildAzureDeploymentSessionUpdate()); + return; + } + + this.sendEvent(this.buildGaSessionUpdate()); + } + + protected buildGaSessionUpdate(): RealtimeGaSessionUpdate { + const cfg = this.config; + return { + type: "session.update", + session: + cfg.gaSessionPolicy ?? + buildOpenAIRealtimeGaSessionPolicy({ + audioFormat: this.audioFormat, + autoRespondToAudio: cfg.autoRespondToAudio, + instructions: cfg.instructions, + interruptResponseOnInputAudio: cfg.interruptResponseOnInputAudio, + language: cfg.language, + model: cfg.model ?? OPENAI_REALTIME_DEFAULT_MODEL, + noiseReduction: null, + prefixPaddingMs: cfg.prefixPaddingMs, + reasoningEffort: cfg.reasoningEffort, + silenceDurationMs: cfg.silenceDurationMs, + tools: normalizeOpenAIRealtimeTools(cfg.tools), + vadThreshold: cfg.vadThreshold, + voice: cfg.voice ?? "alloy", + }), + }; + } + + protected usesAzureDeploymentRealtimeApi(): boolean { + return Boolean(this.config.azureEndpoint && this.config.azureDeployment); + } + + protected buildAzureDeploymentSessionUpdate(): RealtimeAzureDeploymentSessionUpdate { + const cfg = this.config; + const format = this.resolveLegacyRealtimeAudioFormat(); + const tools = normalizeOpenAIRealtimeTools( + cfg.tools, + AZURE_OPENAI_REALTIME_TOOL_NAME_MAX_LENGTH, + ); + return { + type: "session.update", + session: { + modalities: ["text", "audio"], + instructions: cfg.instructions, + voice: cfg.voice ?? "alloy", + input_audio_format: format, + output_audio_format: format, + input_audio_transcription: { + model: "whisper-1", + ...(cfg.language ? { language: cfg.language } : {}), + }, + turn_detection: this.buildTurnDetectionConfig(), + temperature: cfg.temperature ?? 0.8, + ...(tools + ? { + tools, + tool_choice: "auto", + } + : {}), + }, + }; + } + + protected buildTurnDetectionConfig(options?: { + createResponse?: boolean; + includeInterruptResponse?: boolean; + }): RealtimeTurnDetectionConfig { + return buildOpenAIRealtimeTurnDetectionConfig({ + autoRespondToAudio: this.config.autoRespondToAudio, + createResponse: options?.createResponse, + includeInterruptResponse: options?.includeInterruptResponse, + interruptResponseOnInputAudio: this.config.interruptResponseOnInputAudio, + prefixPaddingMs: this.config.prefixPaddingMs, + silenceDurationMs: this.config.silenceDurationMs, + vadThreshold: this.config.vadThreshold, + }); + } + + protected sendAutoResponseSessionUpdate(createResponse: boolean): void { + const azureDeployment = this.usesAzureDeploymentRealtimeApi(); + const turnDetection = this.buildTurnDetectionConfig({ + createResponse, + includeInterruptResponse: !azureDeployment, + }); + if (azureDeployment) { + this.sendEvent({ type: "session.update", session: { turn_detection: turnDetection } }); + return; + } + this.sendEvent({ + type: "session.update", + session: { type: "realtime", audio: { input: { turn_detection: turnDetection } } }, + }); + } + + protected resolveLegacyRealtimeAudioFormat(): "g711_ulaw" | "pcm16" { + return this.audioFormat.encoding === "pcm16" ? "pcm16" : "g711_ulaw"; + } + + protected releaseResponseState(options: { drain?: boolean } = {}): void { + this.responseActive = false; + this.responseCreateInFlight = false; + this.manualResponseCreateEventId = null; + this.responseCancelInFlight = false; + this.manualResponseCancelEventId = null; + if (this.standaloneSpeechActive) { + this.standaloneSpeechActive = false; + this.standaloneSpeechEventId = null; + } + if (options.drain === false) { + return; + } + if (this.standaloneSpeechQueue.length > 0) { + this.flushStandaloneSpeech(); + } else if (this.responseCreatePending) { + this.flushPendingResponseCreate(); + } else { + this.restoreAutoRespondAfterManualResponse(); + } + } + + handleBargeIn(options?: RealtimeVoiceBargeInOptions): void { + const assistantItemId = this.lastAssistantItemId; + const responseStartTimestamp = this.responseStartTimestamp; + const force = options?.force === true; + const shouldInterruptProvider = + assistantItemId !== null && + ((responseStartTimestamp !== null && + (this.oldestOutstandingMarkSequence !== null || options?.audioPlaybackActive === true)) || + force); + const audioEndMs = shouldInterruptProvider + ? Math.max( + 0, + responseStartTimestamp === null + ? this.latestMediaTimestamp + : this.latestMediaTimestamp - responseStartTimestamp, + ) + : null; + const minBargeInAudioEndMs = + this.config.minBargeInAudioEndMs ?? OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS; + if (!force && audioEndMs !== null && audioEndMs < minBargeInAudioEndMs) { + this.config.onEvent?.({ + direction: "client", + type: "conversation.item.truncate.skipped", + detail: `reason=barge-in audioEndMs=${audioEndMs} minAudioEndMs=${minBargeInAudioEndMs}`, + }); + return; + } + if ( + options?.audioPlaybackActive === true && + this.responseActive && + !this.responseCancelInFlight + ) { + const eventId = `openclaw-response-cancel-${randomUUID()}`; + this.manualResponseCancelEventId = eventId; + this.sendEvent({ type: "response.cancel", event_id: eventId }, "reason=barge-in"); + this.responseCancelInFlight = true; + } + if (shouldInterruptProvider) { + this.sendEvent( + { + type: "conversation.item.truncate", + item_id: assistantItemId, + content_index: 0, + audio_end_ms: audioEndMs, + }, + `reason=barge-in audioEndMs=${audioEndMs}`, + ); + this.config.onClearAudio("barge-in"); + this.clearOutstandingMarks(); + this.lastAssistantItemId = null; + this.responseStartTimestamp = null; + return; + } + this.config.onClearAudio("barge-in"); + } + + protected requestResponseCreate(options?: OpenAIRealtimeUserMessageOptions): void { + if ( + this.responseActive || + this.responseCreateInFlight || + this.responseCancelInFlight || + this.continuingToolCallIds.size > 0 || + this.pendingToolCallIds.size > 0 + ) { + this.responseCreatePending = true; + return; + } + this.responseCreatePending = false; + this.responseCreateInFlight = true; + this.suppressAutoRespondForManualResponse(); + const eventId = `openclaw-response-create-${randomUUID()}`; + // Realtime errors can describe unrelated client events. Keep this id until + // the manual turn settles so only its rejection may release VAD suppression. + this.manualResponseCreateEventId = eventId; + this.sendEvent({ + type: "response.create", + event_id: eventId, + ...(options?.toolChoice + ? { response: { output_modalities: ["audio"], tool_choice: options.toolChoice } } + : {}), + }); + } + + protected flushStandaloneSpeech(): void { + if ( + this.standaloneSpeechActive || + this.responseActive || + this.responseCreateInFlight || + this.responseCancelInFlight + ) { + return; + } + const text = this.standaloneSpeechQueue.shift(); + if (!text) { + return; + } + const eventId = `openclaw-standalone-speech-${randomUUID()}`; + this.standaloneSpeechActive = true; + this.standaloneSpeechEventId = eventId; + this.responseCreateInFlight = true; + this.sendEvent({ + type: "response.create", + event_id: eventId, + response: { + conversation: "none", + output_modalities: ["audio"], + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }, + ], + }, + }); + } + + protected suppressAutoRespondForManualResponse(): void { + if (this.config.autoRespondToAudio === false || this.autoRespondSuppressedForManualResponse) { + return; + } + // Manual response.create owns this turn. Keep VAD events and interruption active, + // but prevent a second server-owned response until all queued manual work finishes. + this.autoRespondSuppressedForManualResponse = true; + this.sendAutoResponseSessionUpdate(false); + } + + protected restoreAutoRespondAfterManualResponse(): void { + if (!this.autoRespondSuppressedForManualResponse) { + return; + } + this.autoRespondSuppressedForManualResponse = false; + this.sendAutoResponseSessionUpdate(true); + } + + protected flushPendingResponseCreate(): void { + if (!this.responseCreatePending) { + return; + } + this.responseCreatePending = false; + this.requestResponseCreate(); + } + + protected resetRealtimeSessionState(): void { + this.clearOutstandingMarks(); + this.responseStartTimestamp = null; + this.responseActive = false; + this.responseCreateInFlight = false; + this.manualResponseCreateEventId = null; + this.responseCancelInFlight = false; + this.manualResponseCancelEventId = null; + this.responseCreatePending = false; + this.autoRespondSuppressedForManualResponse = false; + this.continuingToolCallIds.clear(); + this.pendingToolCallIds.clear(); + this.lastAssistantItemId = null; + this.completedToolCallIds.clear(); + this.standaloneSpeechQueue = []; + this.standaloneSpeechActive = false; + this.standaloneSpeechEventId = null; + } + + protected sendMark(): void { + const sequence = this.nextMarkSequence; + this.nextMarkSequence += 1; + if (this.oldestOutstandingMarkSequence === null) { + this.oldestOutstandingMarkSequence = sequence; + } + this.latestOutstandingMarkSequence = sequence; + const markName = `audio-${sequence}`; + this.config.onMark?.(markName); + } + + protected clearOutstandingMarks(): void { + this.oldestOutstandingMarkSequence = null; + this.latestOutstandingMarkSequence = null; + } + + abstract submitToolResult( + callId: string, + result: unknown, + options?: RealtimeVoiceToolResultOptions, + ): void; + + protected abstract sendEvent(event: unknown, detail?: string): void; +} diff --git a/extensions/openai/realtime-voice-provider-routing.test.ts b/extensions/openai/realtime-voice-provider-routing.test.ts new file mode 100644 index 000000000000..22946a22111a --- /dev/null +++ b/extensions/openai/realtime-voice-provider-routing.test.ts @@ -0,0 +1,756 @@ +// Openai tests cover realtime voice provider plugin behavior. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1"); + +function readInternalRealtimeVoiceProviderApi(provider: object) { + return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as { + isBrowserSessionConfigured: (ctx: { + cfg?: object; + providerConfig: Record; + agentId?: string; + }) => boolean; + isGatewayRelayConfigured: (ctx: { + cfg?: object; + providerConfig: Record; + agentId?: string; + }) => boolean | undefined; + resolveBrowserSessionCapabilities: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + }) => { + handlesAgentConsult?: boolean; + supportsToolCalls?: boolean; + supportsVideoFrames?: boolean; + supportsGatewayControl?: boolean; + transports?: string[]; + }; + resolveGatewayRelayCapabilities: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + }) => { + handlesAgentConsult?: boolean; + supportsToolCalls?: boolean; + transports?: string[]; + }; + validateGatewayRelayLaunch: (ctx: { + cfg?: object; + providerConfig: Record; + model?: string; + autoRespondToAudio?: boolean; + }) => string | undefined; + cancelBrowserSession: (request: Record, session: object) => Promise; + }; +} + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + createJsonResponse, + requireRecord, + requireFetchJsonBody, + createRealtimeTool, + createUnreadableToolName, + createMalformedToolName, + createTestJwt, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice provider routing", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("declares realtime Talk capabilities for catalog selection", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + + expect(provider.defaultModel).toBe("gpt-realtime-2.1"); + expect(provider.capabilities).toEqual({ + transports: ["webrtc", "gateway-relay"], + inputAudioFormats: [ + { encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 }, + { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, + ], + outputAudioFormats: [ + { encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 }, + { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, + ], + supportsBrowserSession: true, + supportsBargeIn: true, + handlesInputAudioBargeIn: true, + supportsToolCalls: true, + supportsVideoFrames: true, + }); + }); + + it("advertises continuing realtime tool results", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "test-api-key-test" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + expect(bridge.supportsToolResultContinuation).toBe(true); + expect(bridge.supportsToolResultSuppression).toBe(true); + }); + + it("advertises quicksilver capabilities only for curated /v1/live models", () => { + const quicksilverBroker = { + capabilities: { + transports: ["webrtc" as const], + handlesAgentConsult: true as const, + supportsToolCalls: false, + supportsVideoFrames: false, + }, + createBrowserSession: vi.fn(), + cancelBrowserSession: vi.fn(), + }; + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: quicksilverBroker, + }); + const internalApi = readInternalRealtimeVoiceProviderApi(provider); + + expect( + internalApi.resolveBrowserSessionCapabilities({ + providerConfig: { model: "gpt-realtime-2.1" }, + model: "gpt-live-1-codex", + }), + ).toMatchObject({ + transports: ["webrtc", "gateway-relay"], + handlesAgentConsult: true, + supportsToolCalls: false, + supportsVideoFrames: false, + }); + expect( + internalApi.resolveGatewayRelayCapabilities({ + providerConfig: { model: "gpt-realtime-2.1" }, + model: "gpt-live-1-codex", + }), + ).toMatchObject({ + transports: ["webrtc", "gateway-relay"], + handlesAgentConsult: true, + supportsToolCalls: false, + }); + expect( + internalApi.resolveBrowserSessionCapabilities({ + providerConfig: { model: "gpt-realtime-2.1" }, + model: "gpt-live-1-mini", + }), + ).not.toHaveProperty("handlesAgentConsult"); + expect( + internalApi.resolveGatewayRelayCapabilities({ + providerConfig: { model: "gpt-realtime-2.1" }, + model: "gpt-live-1-mini", + }), + ).not.toHaveProperty("handlesAgentConsult"); + }); + + it("omits unsupported OpenAI tool names from browser sessions", async () => { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ client_secret: { value: "client-secret-123" } }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await provider.createBrowserSession({ + providerConfig: { apiKey: "test-api-key-test" }, + tools: [ + createRealtimeTool("1_lookup"), + createRealtimeTool("calendar.lookup:next"), + createMalformedToolName(undefined), + createUnreadableToolName(), + ], + }); + + const bodySession = requireRecord(requireFetchJsonBody().session, "fetch session"); + const tools = bodySession.tools as Array<{ name?: string }>; + expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); + }); + + it("does not resolve keychain refs during configured checks", () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_CONFIGURED_TEST"); + const provider = buildOpenAIRealtimeVoiceProvider(); + + expect(provider.isConfigured({ providerConfig: {} })).toBe(true); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it("does not treat Codex OAuth profiles as configured for realtime sessions", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const cfg = { agents: { defaults: {} } } as never; + + expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(false); + expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ + provider: "openai", + cfg, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }); + }); + + it("routes gpt-live Platform sessions through the native quicksilver broker", async () => { + const createBrowserSession = vi.fn(async (_request: unknown, _auth: unknown) => ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "quicksilver-token", + offerUrl: "/plugins/openai/realtime/calls", + })); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { + transports: ["webrtc" as const], + handlesAgentConsult: true as const, + supportsToolCalls: false, + supportsVideoFrames: false, + }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + const request = { + providerConfig: { apiKey: "test-api-key-platform" }, + model: "gpt-live-1", + agentId: "main", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + runAgentConsult: vi.fn(async () => ({ text: "Done" })), + }; + + await expect(provider.createBrowserSession?.(request)).resolves.toMatchObject({ + offerUrl: "/plugins/openai/realtime/calls", + }); + expect(createBrowserSession).toHaveBeenCalledWith(expect.objectContaining(request), { + type: "api-key", + token: "test-api-key-platform", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("routes an explicit unlisted gpt-live alias without advertising it as ready", async () => { + const oauthToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") ? oauthToken : undefined, + ); + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") === true, + ); + const createBrowserSession = vi.fn(async () => ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "quicksilver-token", + offerUrl: "/plugins/openai/realtime/calls", + })); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { + transports: ["webrtc" as const], + handlesAgentConsult: true as const, + supportsToolCalls: false, + supportsVideoFrames: false, + }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + const cfg = { agents: { defaults: {} } } as never; + const request = { + cfg, + providerConfig: {}, + model: "gpt-live-1-mini", + agentId: "main", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + runAgentConsult: vi.fn(async () => ({ text: "Done" })), + }; + + expect(provider.isConfigured({ cfg, providerConfig: { model: "gpt-live-1-mini" } })).toBe( + false, + ); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-mini" }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { + model: "gpt-live-1-mini", + azureEndpoint: "https://example.openai.azure.com", + azureDeployment: "gpt-live", + }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-mini" }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { model: "gpt-realtime-2.1", apiKey: "test-api-key-platform" }, + agentId: "main", + }), + ).toBeUndefined(); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { + model: "gpt-realtime-2.1", + apiKey: "test-api-key-platform", + azureEndpoint: "https://example.openai.azure.com", + }, + agentId: "main", + }), + ).toBeUndefined(); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { + model: "gpt-live-1-codex", + apiKey: "test-api-key-platform", + azureEndpoint: "https://example.openai.azure.com", + }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-mini", apiKey: "test-api-key-platform" }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-mini", apiKey: "test-api-key-platform" }, + agentId: "main", + }), + ).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-codex" }, + agentId: "main", + }), + ).toBe(true); + expect( + readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-codex" }, + agentId: "voice-agent", + }), + ).toBe(true); + expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: expect.stringContaining("voice-agent"), + profileTypes: ["oauth"], + }), + ); + expect( + readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ + cfg, + providerConfig: { model: "gpt-live-1-codex" }, + agentId: "main", + }), + ).toBe(true); + await provider.createBrowserSession?.(request); + expect(createBrowserSession).toHaveBeenCalledWith(expect.objectContaining(request), { + type: "oauth", + token: oauthToken, + accountId: "account-123", + }); + expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + profileTypes: ["oauth"], + includeExternalCliAuth: false, + }), + ); + }); + + it("rejects forced consult routing for prefix-routed gpt-live sessions", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const internalApi = readInternalRealtimeVoiceProviderApi(provider); + + expect( + internalApi.validateGatewayRelayLaunch({ + providerConfig: { model: "gpt-live-future-alias" }, + autoRespondToAudio: false, + }), + ).toContain("cannot use forced agent consult routing"); + expect( + internalApi.validateGatewayRelayLaunch({ + providerConfig: { model: "gpt-realtime-2.1" }, + autoRespondToAudio: false, + }), + ).toBeUndefined(); + }); + + it("prefers ChatGPT OAuth over Platform auth for gpt-live", async () => { + const oauthToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") ? oauthToken : undefined, + ); + const createBrowserSession = vi.fn(async () => ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "quicksilver-token", + offerUrl: "/plugins/openai/realtime/calls", + })); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { handlesAgentConsult: true as const }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + + await provider.createBrowserSession?.({ + providerConfig: { apiKey: "test-api-key-platform" }, + model: "gpt-live-1-codex", + agentId: "main", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + runAgentConsult: vi.fn(async () => ({ text: "Done" })), + } as never); + + expect(createBrowserSession).toHaveBeenCalledWith(expect.any(Object), { + type: "oauth", + token: oauthToken, + accountId: "account-123", + }); + }); + + it("does not advertise GA Gateway control for OAuth-only browser auth", () => { + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") === true, + ); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { handlesAgentConsult: true as const }, + createBrowserSession: vi.fn(), + cancelBrowserSession: vi.fn(async () => undefined), + }, + }); + expect( + readInternalRealtimeVoiceProviderApi(provider).resolveBrowserSessionCapabilities({ + cfg: {}, + providerConfig: {}, + model: "gpt-realtime-2.1", + }), + ).not.toHaveProperty("supportsGatewayControl"); + }); + + it("uses ChatGPT OAuth as the browser-only fallback for GA realtime", async () => { + const oauthToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, + }); + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") ? oauthToken : undefined, + ); + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ profileTypes }: { profileTypes?: readonly string[] }) => + profileTypes?.includes("oauth") === true, + ); + const createBrowserSession = vi.fn(async () => ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "broker-token", + offerUrl: "/plugins/openai/realtime/calls", + })); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { handlesAgentConsult: true as const }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + const cfg = { agents: { defaults: {} } } as never; + const request = { + cfg, + providerConfig: {}, + model: "gpt-realtime-2.1", + voice: "cedar", + agentId: "main", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + }; + + expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(false); + expect( + readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ + cfg, + providerConfig: { model: "gpt-realtime-2.1" }, + agentId: "main", + }), + ).toBe(true); + await expect(provider.createBrowserSession?.(request)).resolves.toMatchObject({ + clientSecret: "broker-token", + offerUrl: "/plugins/openai/realtime/calls", + }); + expect(createBrowserSession).toHaveBeenCalledWith( + expect.objectContaining({ model: "gpt-realtime-2.1", voice: "cedar" }), + { type: "oauth", token: oauthToken, accountId: "account-123" }, + ); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("passes configured gpt-live model and voice to the native broker", async () => { + const createBrowserSession = vi.fn(async (_request: unknown, _auth: unknown) => ({ + provider: "openai", + transport: "webrtc" as const, + clientSecret: "quicksilver-token", + offerUrl: "/plugins/openai/realtime/calls", + })); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { + transports: ["webrtc" as const], + handlesAgentConsult: true as const, + supportsToolCalls: false, + supportsVideoFrames: false, + }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + + await provider.createBrowserSession?.({ + providerConfig: { + apiKey: "test-api-key-platform", + model: "gpt-live-1", + speakerVoice: "cedar", + }, + instructions: "Always address the caller as Captain.", + agentId: "voice-agent", + workspaceDir: "/tmp/openclaw-agent-workspace", + initialItems: [], + runAgentConsult: vi.fn(async () => ({ text: "Done" })), + } as never); + + expect(createBrowserSession).toHaveBeenCalledWith( + expect.objectContaining({ model: "gpt-live-1", voice: "cedar" }), + { type: "api-key", token: "test-api-key-platform" }, + ); + const quicksilverRequest = requireRecord( + createBrowserSession.mock.calls[0]?.[0], + "quicksilver request", + ); + expect(quicksilverRequest.instructions).toMatch(/^You are OpenClaw's realtime voice layer\./); + expect(quicksilverRequest.instructions).toContain( + "Context on the commentary channel is silent background", + ); + expect(quicksilverRequest.instructions).toContain( + "Context on the speakable channel is your answer", + ); + expect(quicksilverRequest.instructions).toMatch(/Always address the caller as Captain\.$/); + }); + + it("explains both gpt-live authentication options when neither is available", async () => { + const createBrowserSession = vi.fn(); + const provider = buildOpenAIRealtimeVoiceProvider({ + quicksilverBrowserSessionBroker: { + capabilities: { + transports: ["webrtc" as const], + handlesAgentConsult: true as const, + supportsToolCalls: false, + supportsVideoFrames: false, + }, + createBrowserSession, + cancelBrowserSession: vi.fn(), + }, + }); + + await expect( + provider.createBrowserSession?.({ + providerConfig: {}, + model: "gpt-live-1", + }), + ).rejects.toThrow( + "GPT-Live Talk requires either an OpenAI Platform API key or a ChatGPT OAuth subscription profile", + ); + expect(createBrowserSession).not.toHaveBeenCalled(); + }); + + it("normalizes provider-owned voice settings from raw provider config", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const resolved = provider.resolveConfig?.({ + cfg: {} as never, + rawConfig: { + providers: { + openai: { + model: "gpt-realtime-2", + voice: " Verse ", + temperature: 0.6, + silenceDurationMs: 850, + vadThreshold: 0.35, + reasoningEffort: "low", + }, + }, + }, + }); + + expect(resolved).toEqual({ + model: "gpt-realtime-2", + voice: "verse", + temperature: 0.6, + silenceDurationMs: 850, + vadThreshold: 0.35, + reasoningEffort: "low", + }); + }); + + it("drops malformed realtime voice numeric settings", () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const resolved = provider.resolveConfig?.({ + cfg: {} as never, + rawConfig: { + providers: { + openai: { + vadThreshold: 1.5, + silenceDurationMs: -1, + prefixPaddingMs: 10.5, + minBargeInAudioEndMs: 25.5, + }, + }, + }, + }); + + expect(resolved?.vadThreshold).toBeUndefined(); + expect(resolved?.silenceDurationMs).toBeUndefined(); + expect(resolved?.prefixPaddingMs).toBeUndefined(); + expect(resolved?.minBargeInAudioEndMs).toBeUndefined(); + }); +}); diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts deleted file mode 100644 index 7439acbf17e7..000000000000 --- a/extensions/openai/realtime-voice-provider.test.ts +++ /dev/null @@ -1,4259 +0,0 @@ -// Openai tests cover realtime voice provider plugin behavior. -import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; -import type { - RealtimeVoiceBridge, - RealtimeVoiceBridgeCreateRequest, - RealtimeVoiceBridgeEvent, - RealtimeVoiceTool, -} from "openclaw/plugin-sdk/realtime-voice"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; - -const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1"); -const OPENAI_REALTIME_REJECTED_KEY_MESSAGE = - "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; - -function readInternalRealtimeVoiceProviderApi(provider: object) { - return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as { - isBrowserSessionConfigured: (ctx: { - cfg?: object; - providerConfig: Record; - agentId?: string; - }) => boolean; - isGatewayRelayConfigured: (ctx: { - cfg?: object; - providerConfig: Record; - agentId?: string; - }) => boolean | undefined; - resolveBrowserSessionCapabilities: (ctx: { - cfg?: object; - providerConfig: Record; - model?: string; - }) => { - handlesAgentConsult?: boolean; - supportsToolCalls?: boolean; - supportsVideoFrames?: boolean; - supportsGatewayControl?: boolean; - transports?: string[]; - }; - resolveGatewayRelayCapabilities: (ctx: { - cfg?: object; - providerConfig: Record; - model?: string; - }) => { - handlesAgentConsult?: boolean; - supportsToolCalls?: boolean; - transports?: string[]; - }; - validateGatewayRelayLaunch: (ctx: { - cfg?: object; - providerConfig: Record; - model?: string; - autoRespondToAudio?: boolean; - }) => string | undefined; - cancelBrowserSession: (request: Record, session: object) => Promise; - }; -} - -const { - FakeWebSocket, - execFileSyncMock, - fetchWithSsrFGuardMock, - isProviderAuthProfileConfiguredMock, - resolveProviderAuthProfileApiKeyMock, -} = vi.hoisted(() => { - type Listener = (...args: unknown[]) => void; - - class MockWebSocket { - static readonly OPEN = 1; - static readonly CLOSED = 3; - static instances: MockWebSocket[] = []; - - readonly listeners = new Map(); - readyState = 0; - sent: string[] = []; - closed = false; - terminated = false; - deferClose = false; - deferredClose: (() => void) | undefined; - args: unknown[]; - - constructor(...args: unknown[]) { - this.args = args; - MockWebSocket.instances.push(this); - } - - on(event: string, listener: Listener): this { - const listeners = this.listeners.get(event) ?? []; - listeners.push(listener); - this.listeners.set(event, listeners); - return this; - } - - emit(event: string, ...args: unknown[]): void { - for (const listener of this.listeners.get(event) ?? []) { - listener(...args); - } - } - - send(payload: string): void { - this.sent.push(payload); - } - - close(code?: number, reason?: string): void { - this.closed = true; - this.readyState = MockWebSocket.CLOSED; - const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); - if (this.deferClose) { - this.deferredClose = emitClose; - return; - } - emitClose(); - } - - terminate(): void { - this.terminated = true; - this.close(1006, "terminated"); - } - - emitDeferredClose(): void { - const emitClose = this.deferredClose; - this.deferredClose = undefined; - emitClose?.(); - } - } - - return { - FakeWebSocket: MockWebSocket, - execFileSyncMock: vi.fn(), - fetchWithSsrFGuardMock: vi.fn(), - isProviderAuthProfileConfiguredMock: vi.fn(), - resolveProviderAuthProfileApiKeyMock: vi.fn(), - }; -}); - -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - execFileSync: execFileSyncMock, - }; -}); - -vi.mock("ws", () => ({ - default: FakeWebSocket, -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, -})); - -vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ - isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, - resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, -})); - -type FakeWebSocketInstance = InstanceType; -type SentRealtimeEvent = { - type: string; - event_id?: string; - audio?: string; - item_id?: string; - item?: unknown; - content_index?: number; - audio_end_ms?: number; - session?: { - type?: string; - model?: string; - modalities?: string[]; - instructions?: string; - voice?: string; - input_audio_format?: string; - output_audio_format?: string; - input_audio_transcription?: Record; - turn_detection?: { - create_response?: boolean; - }; - output_modalities?: string[]; - tools?: Array<{ name?: string }>; - audio?: { - input?: { - format?: Record; - noise_reduction?: Record | null; - transcription?: Record; - turn_detection?: { - create_response?: boolean; - interrupt_response?: boolean; - }; - }; - output?: { - format?: Record; - voice?: string; - }; - }; - }; -}; - -function parseSent(socket: FakeWebSocketInstance): SentRealtimeEvent[] { - return socket.sent.map((payload: string) => JSON.parse(payload) as SentRealtimeEvent); -} - -function createNativeBridge( - overrides: Partial = {}, -): RealtimeVoiceBridge { - return buildOpenAIRealtimeVoiceProvider().createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - ...overrides, - }); -} - -function requireSocket(index = 0): FakeWebSocketInstance { - const socket = FakeWebSocket.instances[index]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - return socket; -} - -function beginBridgeConnection( - bridge: RealtimeVoiceBridge, - socketIndex = 0, -): { connecting: Promise; socket: FakeWebSocketInstance } { - const connecting = bridge.connect(); - return { connecting, socket: requireSocket(socketIndex) }; -} - -function openSocket(socket: FakeWebSocketInstance): void { - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); -} - -function emitServerEvent(socket: FakeWebSocketInstance, event: Record): void { - socket.emit("message", Buffer.from(JSON.stringify(event))); -} - -function emitSessionUpdated(socket: FakeWebSocketInstance): void { - emitServerEvent(socket, { type: "session.updated" }); -} - -function emitCompletedToolCalls( - socket: FakeWebSocketInstance, - callIds: string[] = ["call_1"], -): void { - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_tools", - status: "completed", - output: callIds.map((callId, index) => ({ - id: `item_${index + 1}`, - type: "function_call", - status: "completed", - call_id: callId, - name: "lookup_weather", - arguments: "{}", - })), - }, - }); -} - -function emitFunctionOutputAdded(socket: FakeWebSocketInstance, callId: string): void { - emitServerEvent(socket, { - type: "conversation.item.added", - item: { type: "function_call_output", call_id: callId }, - }); -} - -function expectedFunctionOutput(callId: string, result: unknown) { - return expect.objectContaining({ - type: "conversation.item.create", - item: { - type: "function_call_output", - call_id: callId, - output: JSON.stringify(result), - }, - }); -} - -async function connectReadyBridge( - bridge: RealtimeVoiceBridge, - socketIndex = 0, -): Promise { - const { connecting, socket } = beginBridgeConnection(bridge, socketIndex); - openSocket(socket); - emitSessionUpdated(socket); - await connecting; - return socket; -} - -function expectedResponseCreateEvent() { - return expect.objectContaining({ - type: "response.create", - event_id: expect.stringMatching(/^openclaw-response-create-/), - }); -} - -function expectedResponseCancelEvent() { - return expect.objectContaining({ - type: "response.cancel", - event_id: expect.stringMatching(/^openclaw-response-cancel-/), - }); -} - -function createJsonResponse(body: unknown, init?: { status?: number }): Response { - return new Response(JSON.stringify(body), { - status: init?.status ?? 200, - headers: { - "Content-Type": "application/json", - }, - }); -} - -function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} must be an object`).toBe(true); - return value as Record; -} - -function requireNestedRecord( - value: unknown, - path: readonly string[], - label = path.join("."), -): Record { - let current = requireRecord(value, label); - for (const key of path) { - current = requireRecord(current[key], `${label}.${key}`); - } - return current; -} - -function expectRecordFields( - value: unknown, - label: string, - expected: Record, -): Record { - const record = requireRecord(value, label); - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key], `${label}.${key}`).toEqual(expectedValue); - } - return record; -} - -function firstMockCall( - mock: { mock: { calls: Array } }, - label: string, -): readonly unknown[] { - const call = mock.mock.calls[0]; - if (!call) { - throw new Error(`expected ${label} call`); - } - return call; -} - -function requireFetchRequest(callIndex = 0): Record { - return requireRecord(fetchWithSsrFGuardMock.mock.calls[callIndex]?.[0], "fetch request"); -} - -function requireFetchInit(callIndex = 0): Record { - return requireRecord(requireFetchRequest(callIndex).init, "fetch init"); -} - -function requireFetchHeaders(callIndex = 0): Record { - return requireRecord(requireFetchInit(callIndex).headers, "fetch headers"); -} - -function requireFetchJsonBody(callIndex = 0): Record { - const body = requireFetchInit(callIndex).body; - expect(typeof body, "fetch body must be a JSON string").toBe("string"); - return requireRecord(JSON.parse(body as string), "fetch JSON body"); -} - -function requireSession(socket: FakeWebSocketInstance, index = 0): Record { - return requireRecord(parseSent(socket)[index]?.session, "session"); -} - -function hasSentEventType(socket: FakeWebSocketInstance, type: string): boolean { - return parseSent(socket).some((event) => event.type === type); -} - -function createRealtimeTool(name: string): RealtimeVoiceTool { - return { - type: "function", - name, - description: "Contract test tool", - parameters: { type: "object", properties: {} }, - }; -} - -function createUnreadableToolName(): RealtimeVoiceTool { - return { - type: "function", - get name(): string { - throw new Error("unreadable tool name"); - }, - description: "Contract test tool", - parameters: { type: "object", properties: {} }, - }; -} - -function createMalformedToolName(name: unknown): RealtimeVoiceTool { - return { - type: "function", - name, - description: "Contract test tool", - parameters: { type: "object", properties: {} }, - } as unknown as RealtimeVoiceTool; -} - -function createTestJwt(payload: Record): string { - return [ - Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), - Buffer.from(JSON.stringify(payload)).toString("base64url"), - "test-signature", - ].join("."); -} - -describe("buildOpenAIRealtimeVoiceProvider", () => { - beforeEach(() => { - FakeWebSocket.instances = []; - vi.stubEnv("OPENAI_API_KEY", ""); - execFileSyncMock.mockReset(); - fetchWithSsrFGuardMock.mockReset(); - isProviderAuthProfileConfiguredMock.mockReset(); - isProviderAuthProfileConfiguredMock.mockReturnValue(false); - resolveProviderAuthProfileApiKeyMock.mockReset(); - resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllEnvs(); - }); - - it("declares realtime Talk capabilities for catalog selection", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - - expect(provider.defaultModel).toBe("gpt-realtime-2.1"); - expect(provider.capabilities).toEqual({ - transports: ["webrtc", "gateway-relay"], - inputAudioFormats: [ - { encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 }, - { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, - ], - outputAudioFormats: [ - { encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 }, - { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, - ], - supportsBrowserSession: true, - supportsBargeIn: true, - handlesInputAudioBargeIn: true, - supportsToolCalls: true, - supportsVideoFrames: true, - }); - }); - - it("advertises continuing realtime tool results", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - expect(bridge.supportsToolResultContinuation).toBe(true); - expect(bridge.supportsToolResultSuppression).toBe(true); - }); - - it("advertises quicksilver capabilities only for curated /v1/live models", () => { - const quicksilverBroker = { - capabilities: { - transports: ["webrtc" as const], - handlesAgentConsult: true as const, - supportsToolCalls: false, - supportsVideoFrames: false, - }, - createBrowserSession: vi.fn(), - cancelBrowserSession: vi.fn(), - }; - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: quicksilverBroker, - }); - const internalApi = readInternalRealtimeVoiceProviderApi(provider); - - expect( - internalApi.resolveBrowserSessionCapabilities({ - providerConfig: { model: "gpt-realtime-2.1" }, - model: "gpt-live-1-codex", - }), - ).toMatchObject({ - transports: ["webrtc", "gateway-relay"], - handlesAgentConsult: true, - supportsToolCalls: false, - supportsVideoFrames: false, - }); - expect( - internalApi.resolveGatewayRelayCapabilities({ - providerConfig: { model: "gpt-realtime-2.1" }, - model: "gpt-live-1-codex", - }), - ).toMatchObject({ - transports: ["webrtc", "gateway-relay"], - handlesAgentConsult: true, - supportsToolCalls: false, - }); - expect( - internalApi.resolveBrowserSessionCapabilities({ - providerConfig: { model: "gpt-realtime-2.1" }, - model: "gpt-live-1-mini", - }), - ).not.toHaveProperty("handlesAgentConsult"); - expect( - internalApi.resolveGatewayRelayCapabilities({ - providerConfig: { model: "gpt-realtime-2.1" }, - model: "gpt-live-1-mini", - }), - ).not.toHaveProperty("handlesAgentConsult"); - }); - - it("adds OpenClaw attribution headers to native realtime websocket requests", () => { - vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - void bridge.connect(); - bridge.close(); - - const socket = FakeWebSocket.instances[0]; - const options = socket?.args[1] as - | { headers?: Record; maxPayload?: number } - | undefined; - expectRecordFields(options?.headers, "websocket headers", { - originator: "openclaw", - version: "2026.3.22", - "User-Agent": "openclaw/2026.3.22", - }); - expect(options?.headers).not.toHaveProperty("OpenAI-Beta"); - expect(options?.maxPayload).toBe(16 * 1024 * 1024); - }); - - it("requires Platform auth for native realtime websocket bridges", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { model: "gpt-realtime-2" }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - await expect(bridge.connect()).rejects.toThrow( - "OpenAI Realtime voice requires an OpenAI Platform API key", - ); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - it("uses OPENAI_API_KEY for default GPT realtime bridges", async () => { - vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { model: "gpt-realtime-2" }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - void bridge.connect(); - await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); - bridge.close(); - - expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ - [ - { - provider: "openai", - cfg: {}, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }, - ], - ]); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - const socket = FakeWebSocket.instances[0]; - const options = socket?.args[1] as { headers?: Record } | undefined; - expect(options?.headers?.Authorization).toBe("Bearer sk-env"); - }); - - it("does not use Codex OAuth profiles for default GPT realtime bridges", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { model: "gpt-realtime-2" }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - await expect(bridge.connect()).rejects.toThrow( - "OpenAI Realtime voice requires an OpenAI Platform API key", - ); - - expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ - [ - { - provider: "openai", - cfg: {}, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }, - ], - ]); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - it("uses OPENAI_API_KEY when a configured API-key profile cannot be resolved", async () => { - vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); - isProviderAuthProfileConfiguredMock.mockReturnValueOnce(true); - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { model: "gpt-realtime-2" }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - void bridge.connect(); - await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); - bridge.close(); - - expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); - const socket = FakeWebSocket.instances[0]; - const options = socket?.args[1] as { headers?: Record } | undefined; - expect(options?.headers?.Authorization).toBe("Bearer sk-env"); - }); - - it("uses OpenAI API-key auth profiles", async () => { - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("sk-profile"); // pragma: allowlist secret - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { model: "gpt-realtime-2" }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - void bridge.connect(); - await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); - bridge.close(); - - expect(resolveProviderAuthProfileApiKeyMock.mock.calls).toEqual([ - [ - { - provider: "openai", - cfg: {}, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }, - ], - ]); - const socket = FakeWebSocket.instances[0]; - const options = socket?.args[1] as { headers?: Record } | undefined; - expect(options?.headers?.Authorization).toBe("Bearer sk-profile"); - }); - - it("keeps explicit OpenAI realtime API keys as the advanced override", () => { - vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("sk-profile"); // pragma: allowlist secret - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { - apiKey: "sk-configured", // pragma: allowlist secret - model: "gpt-realtime-2", - }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - void bridge.connect(); - bridge.close(); - - expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalled(); - const socket = FakeWebSocket.instances[0]; - const options = socket?.args[1] as { headers?: Record } | undefined; - expect(options?.headers?.Authorization).toBe("Bearer sk-configured"); - }); - - it("requires an API key for custom realtime endpoints", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: { - azureEndpoint: "https://example.openai.azure.com", - model: "gpt-realtime-2", - }, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - await expect(bridge.connect()).rejects.toThrow("OpenAI Realtime voice requires an API key"); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - it("returns browser-safe OpenClaw attribution headers for native WebRTC offers", async () => { - vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse({ - client_secret: { value: "client-secret-123" }, - expires_at: 1_765_000_000, - }), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - - const session = await provider.createBrowserSession({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - instructions: "Be concise.", - voice: " Marin ", - }); - - expectRecordFields(requireFetchRequest(), "fetch request", { - url: "https://api.openai.com/v1/realtime/client_secrets", - policy: { - allowRfc2544BenchmarkRange: true, - allowIpv6UniqueLocalRange: true, - hostnameAllowlist: ["api.openai.com"], - }, - }); - expectRecordFields(requireFetchInit(), "fetch init", { method: "POST" }); - expectRecordFields(requireFetchHeaders(), "fetch headers", { - Authorization: "Bearer sk-test", // pragma: allowlist secret - "Content-Type": "application/json", - originator: "openclaw", - version: "2026.3.22", - "User-Agent": "openclaw/2026.3.22", - }); - const body = requireFetchJsonBody(); - const bodySession = requireRecord(body.session, "fetch session"); - expect(bodySession.model).toBe("gpt-realtime-2.1"); - expect(requireNestedRecord(bodySession, ["audio", "input"])).toEqual({ - noise_reduction: { type: "near_field" }, - turn_detection: { - type: "server_vad", - create_response: true, - interrupt_response: true, - }, - transcription: { model: "gpt-4o-mini-transcribe" }, - }); - expect(requireNestedRecord(bodySession, ["audio", "output"])).toEqual({ voice: "marin" }); - expect(bodySession).not.toHaveProperty("temperature"); - expectRecordFields(session, "browser session", { - provider: "openai", - transport: "webrtc", - clientSecret: "client-secret-123", - offerUrl: "https://api.openai.com/v1/realtime/calls", - model: "gpt-realtime-2.1", - expiresAt: 1_765_000_000_000, - }); - // originator, version, and User-Agent are server-side attribution headers; they - // must not be forwarded to the browser so that the browser's direct SDP POST to - // api.openai.com passes the CORS preflight (only authorization,content-type - // allowed — #76435). All three are filtered, leaving no browser offer headers. - expect((session as { offerHeaders?: Record }).offerHeaders).toBeUndefined(); - }); - - it.each(["configured", "profile", "environment"] as const)( - "explains how auth precedence affects a rejected %s API key", - async (source) => { - if (source === "profile") { - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("sk-profile"); // pragma: allowlist secret - } else if (source === "environment") { - vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret - } - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse( - { error: { message: "Incorrect API key provided: sk-proj-***" } }, - { status: 401 }, - ), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - - await expect( - provider.createBrowserSession({ - providerConfig: - source === "configured" - ? { apiKey: "sk-stale" } // pragma: allowlist secret - : {}, - }), - ).rejects.toThrow( - "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source", - ); - }, - ); - - it("omits unsupported OpenAI tool names from browser sessions", async () => { - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse({ client_secret: { value: "client-secret-123" } }), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - - await provider.createBrowserSession({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - tools: [ - createRealtimeTool("1_lookup"), - createRealtimeTool("calendar.lookup:next"), - createMalformedToolName(undefined), - createUnreadableToolName(), - ], - }); - - const bodySession = requireRecord(requireFetchJsonBody().session, "fetch session"); - const tools = bodySession.tools as Array<{ name?: string }>; - expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); - }); - - it("resolves keychain OPENAI_API_KEY refs before creating browser sessions", async () => { - vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BROWSER_TEST"); - execFileSyncMock.mockReturnValueOnce("sk-browser-env\n"); // pragma: allowlist secret - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse({ - client_secret: { value: "client-secret-123" }, - }), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - - await provider.createBrowserSession({ - providerConfig: {}, - instructions: "Be concise.", - }); - - const [securityBinary, securityArgs, securityOptions] = firstMockCall( - execFileSyncMock, - "security keychain lookup", - ); - expect(securityBinary).toBe("/usr/bin/security"); - expect(securityArgs).toEqual([ - "find-generic-password", - "-s", - "openclaw", - "-a", - "OPENAI_REALTIME_BROWSER_TEST", - "-w", - ]); - expectRecordFields(securityOptions, "security command options", { - encoding: "utf8", - timeout: 5000, - }); - expectRecordFields(requireFetchHeaders(), "fetch headers", { - Authorization: "Bearer sk-browser-env", // pragma: allowlist secret - }); - }); - - it("resolves and caches keychain OPENAI_API_KEY refs before creating bridges", async () => { - vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BRIDGE_TEST"); - execFileSyncMock.mockReturnValue("sk-bridge-env\n"); // pragma: allowlist secret - const provider = buildOpenAIRealtimeVoiceProvider(); - - const first = provider.createBridge({ - providerConfig: {}, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const second = provider.createBridge({ - providerConfig: {}, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - void first.connect(); - void second.connect(); - await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(2)); - first.close(); - second.close(); - - expect(execFileSyncMock).toHaveBeenCalledTimes(1); - for (const socket of FakeWebSocket.instances) { - const options = socket.args[1] as { headers?: Record } | undefined; - expectRecordFields(options?.headers, "websocket headers", { - Authorization: "Bearer sk-bridge-env", // pragma: allowlist secret - }); - } - }); - - it("does not resolve keychain refs during configured checks", () => { - vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_CONFIGURED_TEST"); - const provider = buildOpenAIRealtimeVoiceProvider(); - - expect(provider.isConfigured({ providerConfig: {} })).toBe(true); - expect(execFileSyncMock).not.toHaveBeenCalled(); - }); - - it("does not treat Codex OAuth profiles as configured for realtime sessions", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const cfg = { agents: { defaults: {} } } as never; - - expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(false); - expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ - provider: "openai", - cfg, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }); - }); - - it("routes gpt-live Platform sessions through the native quicksilver broker", async () => { - const createBrowserSession = vi.fn(async (_request: unknown, _auth: unknown) => ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "quicksilver-token", - offerUrl: "/plugins/openai/realtime/calls", - })); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { - transports: ["webrtc" as const], - handlesAgentConsult: true as const, - supportsToolCalls: false, - supportsVideoFrames: false, - }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - const request = { - providerConfig: { apiKey: "sk-platform" }, // pragma: allowlist secret - model: "gpt-live-1", - agentId: "main", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - runAgentConsult: vi.fn(async () => ({ text: "Done" })), - }; - - await expect(provider.createBrowserSession?.(request)).resolves.toMatchObject({ - offerUrl: "/plugins/openai/realtime/calls", - }); - expect(createBrowserSession).toHaveBeenCalledWith(expect.objectContaining(request), { - type: "api-key", - token: "sk-platform", // pragma: allowlist secret - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("routes an explicit unlisted gpt-live alias without advertising it as ready", async () => { - const oauthToken = createTestJwt({ - "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, - }); - resolveProviderAuthProfileApiKeyMock.mockImplementation( - async ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") ? oauthToken : undefined, - ); - isProviderAuthProfileConfiguredMock.mockImplementation( - ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") === true, - ); - const createBrowserSession = vi.fn(async () => ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "quicksilver-token", - offerUrl: "/plugins/openai/realtime/calls", - })); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { - transports: ["webrtc" as const], - handlesAgentConsult: true as const, - supportsToolCalls: false, - supportsVideoFrames: false, - }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - const cfg = { agents: { defaults: {} } } as never; - const request = { - cfg, - providerConfig: {}, - model: "gpt-live-1-mini", - agentId: "main", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - runAgentConsult: vi.fn(async () => ({ text: "Done" })), - }; - - expect(provider.isConfigured({ cfg, providerConfig: { model: "gpt-live-1-mini" } })).toBe( - false, - ); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-mini" }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { - model: "gpt-live-1-mini", - azureEndpoint: "https://example.openai.azure.com", - azureDeployment: "gpt-live", - }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-mini" }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { model: "gpt-realtime-2.1", apiKey: "sk-platform" }, - agentId: "main", - }), - ).toBeUndefined(); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { - model: "gpt-realtime-2.1", - apiKey: "sk-platform", - azureEndpoint: "https://example.openai.azure.com", - }, - agentId: "main", - }), - ).toBeUndefined(); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { - model: "gpt-live-1-codex", - apiKey: "sk-platform", - azureEndpoint: "https://example.openai.azure.com", - }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-mini", apiKey: "sk-platform" }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-mini", apiKey: "sk-platform" }, - agentId: "main", - }), - ).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-codex" }, - agentId: "main", - }), - ).toBe(true); - expect( - readInternalRealtimeVoiceProviderApi(provider).isGatewayRelayConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-codex" }, - agentId: "voice-agent", - }), - ).toBe(true); - expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith( - expect.objectContaining({ - agentDir: expect.stringContaining("voice-agent"), - profileTypes: ["oauth"], - }), - ); - expect( - readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ - cfg, - providerConfig: { model: "gpt-live-1-codex" }, - agentId: "main", - }), - ).toBe(true); - await provider.createBrowserSession?.(request); - expect(createBrowserSession).toHaveBeenCalledWith(expect.objectContaining(request), { - type: "oauth", - token: oauthToken, - accountId: "account-123", - }); - expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - profileTypes: ["oauth"], - includeExternalCliAuth: false, - }), - ); - }); - - it("rejects forced consult routing for prefix-routed gpt-live sessions", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const internalApi = readInternalRealtimeVoiceProviderApi(provider); - - expect( - internalApi.validateGatewayRelayLaunch({ - providerConfig: { model: "gpt-live-future-alias" }, - autoRespondToAudio: false, - }), - ).toContain("cannot use forced agent consult routing"); - expect( - internalApi.validateGatewayRelayLaunch({ - providerConfig: { model: "gpt-realtime-2.1" }, - autoRespondToAudio: false, - }), - ).toBeUndefined(); - }); - - it("prefers ChatGPT OAuth over Platform auth for gpt-live", async () => { - const oauthToken = createTestJwt({ - "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, - }); - resolveProviderAuthProfileApiKeyMock.mockImplementation( - async ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") ? oauthToken : undefined, - ); - const createBrowserSession = vi.fn(async () => ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "quicksilver-token", - offerUrl: "/plugins/openai/realtime/calls", - })); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { handlesAgentConsult: true as const }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - - await provider.createBrowserSession?.({ - providerConfig: { apiKey: "sk-platform" }, // pragma: allowlist secret - model: "gpt-live-1-codex", - agentId: "main", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - runAgentConsult: vi.fn(async () => ({ text: "Done" })), - } as never); - - expect(createBrowserSession).toHaveBeenCalledWith(expect.any(Object), { - type: "oauth", - token: oauthToken, - accountId: "account-123", - }); - }); - - it("keeps Platform precedence for GA realtime when OAuth is also available", async () => { - const oauthToken = createTestJwt({ - "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, - }); - resolveProviderAuthProfileApiKeyMock.mockImplementation( - async ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") ? oauthToken : undefined, - ); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse({ client_secret: { value: "client-secret-123" } }), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - - await provider.createBrowserSession?.({ - providerConfig: { apiKey: "sk-platform" }, // pragma: allowlist secret - model: "gpt-realtime-2.1", - }); - - expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalledWith( - expect.objectContaining({ profileTypes: ["oauth"] }), - ); - expectRecordFields(requireFetchHeaders(), "fetch headers", { - Authorization: "Bearer sk-platform", // pragma: allowlist secret - }); - }); - - it("sends one shared GA policy and waits for session.updated on an attached sideband", async () => { - const createBrowserSession = vi.fn( - async (_request: unknown, _auth: unknown) => - ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "gateway-token", - offerUrl: "/plugins/openai/realtime/calls", - }) as const, - ); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { handlesAgentConsult: true as const }, - createBrowserSession, - cancelBrowserSession: vi.fn(async () => undefined), - }, - }); - const bindBridge = vi.fn(); - const onEvent = vi.fn(); - const onReady = vi.fn(); - const cfg = {} as never; - - expect( - readInternalRealtimeVoiceProviderApi(provider).resolveBrowserSessionCapabilities({ - cfg, - providerConfig: { apiKey: "sk-platform" }, // pragma: allowlist secret - model: "gpt-realtime-2.1", - }), - ).toMatchObject({ supportsGatewayControl: true }); - await expect( - provider.createBrowserSession?.({ - cfg, - providerConfig: { apiKey: "sk-platform" }, // pragma: allowlist secret - instructions: "Stay concise.", - model: "gpt-realtime-2.1", - prefixPaddingMs: 420, - reasoningEffort: "medium", - silenceDurationMs: 650, - tools: [createRealtimeTool("openclaw_agent_consult")], - vadThreshold: 0.7, - voice: "marin", - gatewayControl: { bindBridge, onEvent, onReady }, - }), - ).resolves.toMatchObject({ - clientSecret: "gateway-token", - offerUrl: "/plugins/openai/realtime/calls", - }); - const brokerRequest = requireRecord(createBrowserSession.mock.calls[0]?.[0], "broker request"); - expect(createBrowserSession.mock.calls[0]?.[1]).toEqual({ - type: "api-key", - token: "sk-platform", // pragma: allowlist secret - }); - const gaSideband = requireRecord(brokerRequest.gaSideband, "GA sideband request"); - expect(gaSideband.session).toMatchObject({ - type: "realtime", - instructions: "Stay concise.", - model: "gpt-realtime-2.1", - output_modalities: ["audio"], - reasoning: { effort: "medium" }, - tool_choice: "auto", - audio: { - input: { - format: { type: "audio/pcm", rate: 24000 }, - noise_reduction: { type: "near_field" }, - turn_detection: { - type: "server_vad", - threshold: 0.7, - prefix_padding_ms: 420, - silence_duration_ms: 650, - create_response: true, - interrupt_response: true, - }, - }, - output: { format: { type: "audio/pcm", rate: 24000 }, voice: "marin" }, - }, - }); - const createBridge = gaSideband.createBridge as (params: { - apiKey: string; - callId: string; - onTerminal: () => void; - }) => RealtimeVoiceBridge; - const bridge = createBridge({ - apiKey: "sk-platform", // pragma: allowlist secret - callId: "rtc_gateway", - onTerminal: vi.fn(), - }); - expect(bindBridge).toHaveBeenCalledWith(bridge); - const { connecting, socket } = beginBridgeConnection(bridge); - let connectResolved = false; - void connecting.then(() => { - connectResolved = true; - }); - expect(socket.args[0]).toBe("wss://api.openai.com/v1/realtime?call_id=rtc_gateway"); - openSocket(socket); - await Promise.resolve(); - const sessionUpdates = parseSent(socket).filter((event) => event.type === "session.update"); - expect(sessionUpdates).toHaveLength(1); - expect(sessionUpdates[0]?.session).toEqual(gaSideband.session); - emitServerEvent(socket, { - type: "session.created", - session: { type: "realtime", tools: [{ type: "function" }], tool_choice: "auto" }, - }); - await Promise.resolve(); - expect(connectResolved).toBe(false); - expect(onReady).not.toHaveBeenCalled(); - expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength(1); - emitSessionUpdated(socket); - await connecting; - expect(connectResolved).toBe(true); - expect(onReady).toHaveBeenCalledOnce(); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "session.created", - detail: "tools=1 toolChoice=auto", - }); - bridge.close(); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("does not advertise GA Gateway control for OAuth-only browser auth", () => { - isProviderAuthProfileConfiguredMock.mockImplementation( - ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") === true, - ); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { handlesAgentConsult: true as const }, - createBrowserSession: vi.fn(), - cancelBrowserSession: vi.fn(async () => undefined), - }, - }); - expect( - readInternalRealtimeVoiceProviderApi(provider).resolveBrowserSessionCapabilities({ - cfg: {}, - providerConfig: {}, - model: "gpt-realtime-2.1", - }), - ).not.toHaveProperty("supportsGatewayControl"); - }); - - it("uses ChatGPT OAuth as the browser-only fallback for GA realtime", async () => { - const oauthToken = createTestJwt({ - "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, - }); - resolveProviderAuthProfileApiKeyMock.mockImplementation( - async ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") ? oauthToken : undefined, - ); - isProviderAuthProfileConfiguredMock.mockImplementation( - ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") === true, - ); - const createBrowserSession = vi.fn(async () => ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "broker-token", - offerUrl: "/plugins/openai/realtime/calls", - })); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { handlesAgentConsult: true as const }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - const cfg = { agents: { defaults: {} } } as never; - const request = { - cfg, - providerConfig: {}, - model: "gpt-realtime-2.1", - voice: "cedar", - agentId: "main", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - }; - - expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(false); - expect( - readInternalRealtimeVoiceProviderApi(provider).isBrowserSessionConfigured({ - cfg, - providerConfig: { model: "gpt-realtime-2.1" }, - agentId: "main", - }), - ).toBe(true); - await expect(provider.createBrowserSession?.(request)).resolves.toMatchObject({ - clientSecret: "broker-token", - offerUrl: "/plugins/openai/realtime/calls", - }); - expect(createBrowserSession).toHaveBeenCalledWith( - expect.objectContaining({ model: "gpt-realtime-2.1", voice: "cedar" }), - { type: "oauth", token: oauthToken, accountId: "account-123" }, - ); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("does not use GA OAuth fallback when a Platform credential source is unresolved", async () => { - const oauthToken = createTestJwt({ - "https://api.openai.com/auth": { chatgpt_account_id: "account-123" }, - }); - resolveProviderAuthProfileApiKeyMock.mockImplementation( - async ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("oauth") ? oauthToken : undefined, - ); - isProviderAuthProfileConfiguredMock.mockImplementation( - ({ profileTypes }: { profileTypes?: readonly string[] }) => - profileTypes?.includes("api_key") === true, - ); - const createBrowserSession = vi.fn(); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { handlesAgentConsult: true as const }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - - await expect( - provider.createBrowserSession?.({ - cfg: {} as never, - providerConfig: {}, - model: "gpt-realtime-2.1", - agentId: "main", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - } as never), - ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); - expect(createBrowserSession).not.toHaveBeenCalled(); - expect(resolveProviderAuthProfileApiKeyMock).not.toHaveBeenCalledWith( - expect.objectContaining({ profileTypes: ["oauth"] }), - ); - }); - - it("passes configured gpt-live model and voice to the native broker", async () => { - const createBrowserSession = vi.fn(async (_request: unknown, _auth: unknown) => ({ - provider: "openai", - transport: "webrtc" as const, - clientSecret: "quicksilver-token", - offerUrl: "/plugins/openai/realtime/calls", - })); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { - transports: ["webrtc" as const], - handlesAgentConsult: true as const, - supportsToolCalls: false, - supportsVideoFrames: false, - }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - - await provider.createBrowserSession?.({ - providerConfig: { - apiKey: "sk-platform", // pragma: allowlist secret - model: "gpt-live-1", - speakerVoice: "cedar", - }, - instructions: "Always address the caller as Captain.", - agentId: "voice-agent", - workspaceDir: "/tmp/openclaw-agent-workspace", - initialItems: [], - runAgentConsult: vi.fn(async () => ({ text: "Done" })), - } as never); - - expect(createBrowserSession).toHaveBeenCalledWith( - expect.objectContaining({ model: "gpt-live-1", voice: "cedar" }), - { type: "api-key", token: "sk-platform" }, // pragma: allowlist secret - ); - const quicksilverRequest = requireRecord( - createBrowserSession.mock.calls[0]?.[0], - "quicksilver request", - ); - expect(quicksilverRequest.instructions).toMatch(/^You are OpenClaw's realtime voice layer\./); - expect(quicksilverRequest.instructions).toContain( - "Context on the commentary channel is silent background", - ); - expect(quicksilverRequest.instructions).toContain( - "Context on the speakable channel is your answer", - ); - expect(quicksilverRequest.instructions).toMatch(/Always address the caller as Captain\.$/); - }); - - it("explains both gpt-live authentication options when neither is available", async () => { - const createBrowserSession = vi.fn(); - const provider = buildOpenAIRealtimeVoiceProvider({ - quicksilverBrowserSessionBroker: { - capabilities: { - transports: ["webrtc" as const], - handlesAgentConsult: true as const, - supportsToolCalls: false, - supportsVideoFrames: false, - }, - createBrowserSession, - cancelBrowserSession: vi.fn(), - }, - }); - - await expect( - provider.createBrowserSession?.({ - providerConfig: {}, - model: "gpt-live-1", - }), - ).rejects.toThrow( - "GPT-Live Talk requires either an OpenAI Platform API key or a ChatGPT OAuth subscription profile", - ); - expect(createBrowserSession).not.toHaveBeenCalled(); - }); - - it("requires Platform auth for browser sessions", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - await expect( - provider.createBrowserSession?.({ - providerConfig: {}, - }), - ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("reports an unresolved Platform credential without trying another auth route", async () => { - vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST"); - execFileSyncMock.mockImplementationOnce(() => { - throw new Error("keychain unavailable"); - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - - await expect( - provider.createBrowserSession?.({ - providerConfig: {}, - }), - ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); - }); - - it("treats OpenAI API-key auth profiles as configured for browser realtime sessions", () => { - isProviderAuthProfileConfiguredMock.mockReturnValue(true); - const provider = buildOpenAIRealtimeVoiceProvider(); - const cfg = { agents: { defaults: {} } } as never; - - expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(true); - expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ - provider: "openai", - cfg, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }); - }); - - it("does not configure Azure realtime sessions without a Platform API key", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const cfg = { agents: { defaults: {} } } as never; - - expect( - provider.isConfigured({ - cfg, - providerConfig: { - azureEndpoint: "https://example.openai.azure.com", - azureDeployment: "realtime", - }, - }), - ).toBe(false); - }); - - it("requires Platform auth before minting browser realtime client secrets", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - const cfg = { agents: { defaults: {} } } as never; - - await expect( - provider.createBrowserSession({ - cfg, - providerConfig: {}, - instructions: "Be concise.", - }), - ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("uses OPENAI_API_KEY for default GPT browser sessions", async () => { - vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: createJsonResponse({ - client_secret: { value: "client-secret-123" }, - }), - release: vi.fn(async () => undefined), - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - if (!provider.createBrowserSession) { - throw new Error("expected OpenAI realtime provider to support browser sessions"); - } - const cfg = { agents: { defaults: {} } } as never; - - await provider.createBrowserSession({ - cfg, - providerConfig: {}, - model: "gpt-realtime-2", - instructions: "Be concise.", - }); - - expectRecordFields(requireFetchHeaders(), "fetch headers", { - Authorization: "Bearer sk-env", // pragma: allowlist secret - }); - }); - - it("fails closed when keychain refs cannot be resolved", async () => { - vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST"); - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); - execFileSyncMock.mockImplementationOnce(() => { - throw new Error("keychain unavailable"); - }); - const provider = buildOpenAIRealtimeVoiceProvider(); - - const bridge = provider.createBridge({ - providerConfig: {}, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - await expect(bridge.connect()).rejects.toThrow( - "OpenAI Realtime voice requires an OpenAI Platform API key", - ); - expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); - }); - - it("fails closed when a configured API-key profile cannot be resolved", async () => { - resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined); - isProviderAuthProfileConfiguredMock.mockReturnValueOnce(true); - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - cfg: {} as never, - providerConfig: {}, - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - - await expect(bridge.connect()).rejects.toThrow( - "OpenAI Realtime voice requires an OpenAI Platform API key", - ); - expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledTimes(1); - }); - - it("normalizes provider-owned voice settings from raw provider config", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const resolved = provider.resolveConfig?.({ - cfg: {} as never, - rawConfig: { - providers: { - openai: { - model: "gpt-realtime-2", - voice: " Verse ", - temperature: 0.6, - silenceDurationMs: 850, - vadThreshold: 0.35, - reasoningEffort: "low", - }, - }, - }, - }); - - expect(resolved).toEqual({ - model: "gpt-realtime-2", - voice: "verse", - temperature: 0.6, - silenceDurationMs: 850, - vadThreshold: 0.35, - reasoningEffort: "low", - }); - }); - - it("drops malformed realtime voice numeric settings", () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const resolved = provider.resolveConfig?.({ - cfg: {} as never, - rawConfig: { - providers: { - openai: { - vadThreshold: 1.5, - silenceDurationMs: -1, - prefixPaddingMs: 10.5, - minBargeInAudioEndMs: 25.5, - }, - }, - }, - }); - - expect(resolved?.vadThreshold).toBeUndefined(); - expect(resolved?.silenceDurationMs).toBeUndefined(); - expect(resolved?.prefixPaddingMs).toBeUndefined(); - expect(resolved?.minBargeInAudioEndMs).toBeUndefined(); - }); - - it("waits for session.updated before draining audio and firing onReady", async () => { - const onReady = vi.fn(); - const bridge = createNativeBridge({ - instructions: "Be helpful.", - language: "de", - onReady, - }); - const { connecting, socket } = beginBridgeConnection(bridge); - let connectResolved = false; - void connecting.then(() => { - connectResolved = true; - }); - - openSocket(socket); - await Promise.resolve(); - - bridge.sendAudio(Buffer.from("before-ready")); - emitServerEvent(socket, { type: "session.created" }); - - expect(connectResolved).toBe(false); - expect(onReady).not.toHaveBeenCalled(); - expect(parseSent(socket).map((event) => event.type)).toEqual(["session.update"]); - const session = requireSession(socket); - expectRecordFields(session, "session", { - type: "realtime", - model: "gpt-realtime-2.1", - output_modalities: ["audio"], - }); - const inputAudio = requireNestedRecord(session, ["audio", "input"]); - expectRecordFields(inputAudio, "session audio input", { - format: { type: "audio/pcmu" }, - noise_reduction: null, - transcription: { model: "gpt-4o-mini-transcribe", language: "de" }, - }); - expect(requireNestedRecord(session, ["audio", "output"])).toEqual({ - format: { type: "audio/pcmu" }, - voice: "alloy", - }); - expect(session).not.toHaveProperty("temperature"); - expect(bridge.isConnected()).toBe(false); - - emitSessionUpdated(socket); - await connecting; - - expect(connectResolved).toBe(true); - expect(onReady).toHaveBeenCalledTimes(1); - expect(parseSent(socket).map((event) => event.type)).toEqual([ - "session.update", - "input_audio_buffer.append", - ]); - expect(bridge.isConnected()).toBe(true); - }); - - it("bounds queued audio by aggregate bytes before session readiness", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - openSocket(socket); - await Promise.resolve(); - - bridge.sendAudio(Buffer.alloc(512 * 1024, 0x01)); - bridge.sendAudio(Buffer.alloc(512 * 1024, 0x02)); - bridge.sendAudio(Buffer.from("overflow")); - emitSessionUpdated(socket); - await connecting; - - const audioEvents = parseSent(socket).filter( - (event) => event.type === "input_audio_buffer.append", - ); - expect(audioEvents).toHaveLength(2); - expect( - audioEvents.map((event) => Buffer.from(String(event.audio), "base64").byteLength), - ).toEqual([512 * 1024, 512 * 1024]); - bridge.close(); - }); - - it("discards audio closed before the first connection and reconnects fresh", async () => { - const onClose = vi.fn(); - const bridge = createNativeBridge({ 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, socket } = beginBridgeConnection(bridge); - openSocket(socket); - emitSessionUpdated(socket); - 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 bridge = createNativeBridge(); - const { connecting: firstConnect, socket: firstSocket } = beginBridgeConnection(bridge); - openSocket(firstSocket); - await Promise.resolve(); - - bridge.sendAudio(Buffer.from("queued-before-close")); - bridge.close(); - await firstConnect; - bridge.sendAudio(Buffer.from("sent-after-close")); - - const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); - openSocket(secondSocket); - emitSessionUpdated(secondSocket); - await reconnecting; - - expect( - parseSent(secondSocket).filter((event) => event.type === "input_audio_buffer.append"), - ).toHaveLength(0); - bridge.close(); - }); - - it("shares an in-flight connection until session readiness", async () => { - const onReady = vi.fn(); - const bridge = createNativeBridge({ onReady }); - const firstConnect = bridge.connect(); - const secondConnect = bridge.connect(); - const socket = requireSocket(); - - expect(FakeWebSocket.instances).toHaveLength(1); - openSocket(socket); - emitSessionUpdated(socket); - - await Promise.all([firstConnect, secondConnect]); - expect(onReady).toHaveBeenCalledOnce(); - bridge.close(); - }); - - it("fails terminally when the readiness callback throws", async () => { - vi.useFakeTimers(); - const readyError = new Error("readiness callback failed"); - const onClose = vi.fn(); - const onError = vi.fn(); - const onReady = vi.fn(() => { - throw readyError; - }); - const bridge = createNativeBridge({ onClose, onError, onReady }); - const { connecting, socket } = beginBridgeConnection(bridge); - let connectError: unknown; - const observedConnect = connecting.catch((error: unknown) => { - connectError = error; - }); - - openSocket(socket); - bridge.sendAudio(Buffer.from("queued-before-ready")); - emitSessionUpdated(socket); - await vi.advanceTimersByTimeAsync(0); - const immediateConnectError = connectError; - - bridge.close(); - await observedConnect; - - expect(immediateConnectError).toBe(readyError); - expect(onReady).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledWith(readyError); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("error"); - expect(socket.closed).toBe(true); - expect(bridge.isConnected()).toBe(false); - expect( - parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), - ).toHaveLength(0); - - emitSessionUpdated(socket); - await expect(bridge.connect()).rejects.toBe(readyError); - expect(FakeWebSocket.instances).toHaveLength(1); - expect(onReady).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it("suppresses auto responses before draining queued initial greeting audio", async () => { - const bridgeRef: { current?: RealtimeVoiceBridge } = {}; - const onReady = vi.fn(() => { - bridgeRef.current?.triggerGreeting?.("Say exactly: hello from explicit speech."); - }); - const bridge = createNativeBridge({ - instructions: "Be helpful.", - onReady, - }); - bridgeRef.current = bridge; - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - await Promise.resolve(); - - bridge.sendAudio(Buffer.from("before-ready")); - emitSessionUpdated(socket); - await connecting; - - const sent = parseSent(socket); - expect(sent.map((event) => event.type)).toEqual([ - "session.update", - "conversation.item.create", - "session.update", - "response.create", - "input_audio_buffer.append", - ]); - expect(sent[2]).toEqual({ - type: "session.update", - session: { - type: "realtime", - audio: { - input: { - turn_detection: { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 500, - create_response: false, - interrupt_response: true, - }, - }, - }, - }, - }); - expect(sent[4]).toEqual({ - type: "input_audio_buffer.append", - audio: Buffer.from("before-ready").toString("base64"), - }); - expect(sent.filter((event) => event.type === "response.create")).toHaveLength(1); - expect(onReady).toHaveBeenCalledTimes(1); - - emitServerEvent(socket, { type: "response.done" }); - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it("omits unsupported OpenAI tool names from GA session updates", async () => { - const bridge = createNativeBridge({ - tools: [ - createRealtimeTool("1_lookup"), - createRealtimeTool("calendar.lookup:next"), - createRealtimeTool("bad/name"), - createRealtimeTool("x".repeat(65)), - createMalformedToolName(null), - createMalformedToolName(42), - createUnreadableToolName(), - ], - }); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - - const tools = requireSession(socket).tools as Array<{ name?: string }>; - expect(tools.map((tool) => tool.name)).toEqual(["1_lookup", "x".repeat(65)]); - emitSessionUpdated(socket); - await connecting; - }); - - it("rotates realtime bridges on provider max-duration events without reporting an error", async () => { - vi.useFakeTimers(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const onReady = vi.fn(); - const bridge = createNativeBridge({ onError, onEvent, onReady }); - const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - - openSocket(firstSocket); - emitSessionUpdated(firstSocket); - await connecting; - expect(onReady).toHaveBeenCalledOnce(); - - firstSocket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: "Your session hit the maximum duration of 60 minutes." }, - }), - ), - ); - - expect(onError).not.toHaveBeenCalled(); - expect(firstSocket.closed).toBe(true); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "session.rotation", - detail: "reason=max-duration", - }); - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "session.reconnect.scheduled", - detail: "reason=max-duration attempt=1 delayMs=1000", - }); - - await vi.advanceTimersByTimeAsync(1000); - await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); - const secondSocket = requireSocket(1); - openSocket(secondSocket); - emitSessionUpdated(secondSocket); - - await vi.waitFor(() => - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "session.rotation.ready", - detail: "reason=max-duration", - }), - ); - await vi.waitFor(() => - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "session.reconnect.ready", - detail: "reason=max-duration attempt=1", - }), - ); - expect(bridge.isConnected()).toBe(true); - expect(onReady).toHaveBeenCalledOnce(); - - bridge.close(); - }); - - it("clears canceled rotation metadata before an explicit reconnect", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const onReady = vi.fn(); - const bridge = createNativeBridge({ onClose, onError, onEvent, onReady }); - const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - - firstSocket.deferClose = true; - openSocket(firstSocket); - emitSessionUpdated(firstSocket); - await connecting; - expect(onReady).toHaveBeenCalledOnce(); - - emitServerEvent(firstSocket, { - type: "error", - error: { message: "Your session hit the maximum duration of 60 minutes." }, - }); - expect(firstSocket.closed).toBe(true); - - bridge.close(); - bridge.close(); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("completed"); - - const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); - firstSocket.emitDeferredClose(); - openSocket(secondSocket); - emitSessionUpdated(secondSocket); - await reconnecting; - - expect(onReady).toHaveBeenCalledTimes(2); - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "session.rotation.ready" }), - ); - expect(onError).not.toHaveBeenCalled(); - - secondSocket.readyState = FakeWebSocket.CLOSED; - secondSocket.emit("close", 1006, Buffer.from("ordinary drop")); - await vi.advanceTimersByTimeAsync(0); - - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "session.reconnect.scheduled", - detail: "reason=websocket-close attempt=1 delayMs=1000", - }); - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: "session.reconnect.scheduled", - detail: expect.stringContaining("reason=max-duration"), - }), - ); - - bridge.close(); - await vi.advanceTimersByTimeAsync(0); - expect(vi.getTimerCount()).toBe(0); - expect(onClose).toHaveBeenCalledTimes(2); - expect(onClose).toHaveBeenLastCalledWith("completed"); - }); - - it("cancels a pending reconnect and allows a later explicit connect", async () => { - vi.useFakeTimers(); - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - emitSessionUpdated(socket); - await connecting; - - socket.readyState = FakeWebSocket.CLOSED; - socket.emit("close", 1006, Buffer.from("transient drop")); - await vi.advanceTimersByTimeAsync(0); - expect(vi.getTimerCount()).toBe(1); - - bridge.close(); - await vi.advanceTimersByTimeAsync(0); - - expect(vi.getTimerCount()).toBe(0); - expect(FakeWebSocket.instances).toHaveLength(1); - expect(onError).not.toHaveBeenCalled(); - - const { connecting: reconnecting, socket: reconnectedSocket } = beginBridgeConnection( - bridge, - 1, - ); - openSocket(reconnectedSocket); - emitSessionUpdated(reconnectedSocket); - await reconnecting; - - expect(bridge.isConnected()).toBe(true); - expect(FakeWebSocket.instances).toHaveLength(2); - expect(onError).not.toHaveBeenCalled(); - bridge.close(); - }); - - it("does not report reconnect readiness after cancellation during provider setup", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onClose, onError, onEvent }); - const socket = await connectReadyBridge(bridge); - - socket.readyState = FakeWebSocket.CLOSED; - socket.emit("close", 1006, Buffer.from("transient drop")); - await vi.advanceTimersByTimeAsync(1000); - const retrySocket = requireSocket(1); - openSocket(retrySocket); - - bridge.close(); - await vi.advanceTimersByTimeAsync(0); - - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "session.reconnect.ready" }), - ); - expect(onError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("completed"); - }); - - it("lets cancellation win a queued reconnect startup error", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const onError = vi.fn(); - const bridge = createNativeBridge({ onClose, onError }); - const socket = await connectReadyBridge(bridge); - - socket.readyState = FakeWebSocket.CLOSED; - socket.emit("close", 1006, Buffer.from("transient drop")); - await vi.advanceTimersByTimeAsync(1000); - const retrySocket = requireSocket(1); - openSocket(retrySocket); - emitServerEvent(retrySocket, { - type: "error", - error: { message: "queued retry startup failure" }, - }); - - bridge.close(); - await vi.advanceTimersByTimeAsync(0); - - expect(onError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("completed"); - expect(vi.getTimerCount()).toBe(0); - }); - - it("reports one terminal error for malformed audio during reconnect setup", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onClose, onError, onEvent }); - const socket = await connectReadyBridge(bridge); - - socket.readyState = FakeWebSocket.CLOSED; - socket.emit("close", 1006, Buffer.from("transient drop")); - await vi.advanceTimersByTimeAsync(1000); - const retrySocket = requireSocket(1); - openSocket(retrySocket); - emitServerEvent(retrySocket, { - type: "response.output_audio.delta", - item_id: "item_1", - delta: "not-base64!", - }); - await vi.advanceTimersByTimeAsync(0); - - expect(onError).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledWith( - new Error("OpenAI realtime stream returned malformed base64 audio data"), - ); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("error"); - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "session.reconnect.ready" }), - ); - expect(vi.getTimerCount()).toBe(0); - }); - - it("ignores late events from a socket replaced by reconnect", async () => { - vi.useFakeTimers(); - const onAudio = vi.fn(); - const onClose = vi.fn(); - const onError = vi.fn(); - const bridge = createNativeBridge({ - onAudio, - onClose, - onError, - }); - const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - - openSocket(firstSocket); - emitSessionUpdated(firstSocket); - await connecting; - - firstSocket.readyState = FakeWebSocket.CLOSED; - firstSocket.emit("close", 1006, Buffer.from("transient drop")); - firstSocket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - delta: Buffer.from("late audio").toString("base64"), - }), - ), - ); - firstSocket.emit("error", new Error("late retry-wait failure")); - expect(onAudio).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1000); - const secondSocket = requireSocket(1); - openSocket(secondSocket); - emitSessionUpdated(secondSocket); - await vi.waitFor(() => expect(bridge.isConnected()).toBe(true)); - - emitSessionUpdated(firstSocket); - firstSocket.emit("error", new Error("late socket failure")); - firstSocket.emit("close", 1006, Buffer.from("late socket close")); - await vi.advanceTimersByTimeAsync(0); - - expect(bridge.isConnected()).toBe(true); - expect(FakeWebSocket.instances).toHaveLength(2); - expect(vi.getTimerCount()).toBe(0); - expect(onError).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - bridge.close(); - }); - - it("exhausts retries when sockets open but never become provider-ready", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onClose, onError, onEvent }); - const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - - openSocket(firstSocket); - emitSessionUpdated(firstSocket); - await connecting; - - firstSocket.readyState = FakeWebSocket.CLOSED; - firstSocket.emit("close", 1006, Buffer.from("transient drop")); - - for (let attempt = 1; attempt <= 5; attempt += 1) { - await vi.waitFor(() => - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "session.reconnect.scheduled", - detail: `reason=websocket-close attempt=${attempt} delayMs=${1000 * 2 ** (attempt - 1)}`, - }), - ); - await vi.advanceTimersByTimeAsync(1000 * 2 ** (attempt - 1)); - const retrySocket = requireSocket(attempt); - openSocket(retrySocket); - retrySocket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: `retry startup failure ${attempt}` }, - }), - ), - ); - } - - await vi.waitFor(() => expect(onClose).toHaveBeenCalledWith("error")); - expect(onClose).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledTimes(5); - expect(FakeWebSocket.instances).toHaveLength(6); - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "session.reconnect.exhausted", - detail: "reason=websocket-close attempts=5", - }); - - bridge.close(); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it("keeps Azure deployment bridges on deployment-compatible session payloads", async () => { - const bridge = createNativeBridge({ - providerConfig: { - apiKey: "sk-test", // pragma: allowlist secret - azureEndpoint: "https://example.openai.azure.com/", - azureDeployment: "realtime-prod", - azureApiVersion: "2024-10-01-preview", - voice: "verse", - }, - audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - instructions: "Be helpful.", - tools: [ - createRealtimeTool("1_lookup"), - createRealtimeTool("calendar.lookup:next"), - createRealtimeTool("x".repeat(65)), - ], - }); - const { connecting, socket } = beginBridgeConnection(bridge); - - expect(socket.args[0]).toBe( - "wss://example.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=realtime-prod", - ); - - openSocket(socket); - await Promise.resolve(); - - const session = requireSession(socket); - expectRecordFields(session, "session", { - modalities: ["text", "audio"], - instructions: "Be helpful.", - voice: "verse", - input_audio_format: "pcm16", - output_audio_format: "pcm16", - input_audio_transcription: { model: "whisper-1" }, - temperature: 0.8, - }); - expectRecordFields( - requireRecord(session.turn_detection, "session turn detection"), - "turn detection", - { - create_response: true, - }, - ); - expect(session).not.toHaveProperty("type"); - expect(session).not.toHaveProperty("audio"); - const tools = session.tools as Array<{ name?: string }>; - expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); - - emitSessionUpdated(socket); - await connecting; - - bridge.triggerGreeting?.("Say hello."); - expect(parseSent(socket).slice(-2)).toEqual([ - { - type: "session.update", - session: { - turn_detection: { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 500, - create_response: false, - }, - }, - }, - expectedResponseCreateEvent(), - ]); - - emitServerEvent(socket, { type: "response.done" }); - expect(parseSent(socket).at(-1)).toEqual({ - type: "session.update", - session: { - turn_detection: { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 500, - create_response: true, - }, - }, - }); - }); - - it("rejects connection when session configuration fails before readiness", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: "invalid realtime session" }, - }), - ), - ); - - await expect(connecting).rejects.toThrow("invalid realtime session"); - expect(bridge.isConnected()).toBe(false); - }); - - it("treats pre-ready auth errors as a single startup failure", async () => { - const onError = vi.fn(); - const onClose = vi.fn(); - const bridge = createNativeBridge({ onError, onClose }); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: "Incorrect API key provided: sk-proj-***" }, - }), - ), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: "Incorrect API key provided: sk-proj-***" }, - }), - ), - ); - - await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); - expect(onError).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - expect(socket.closed).toBe(true); - expect(bridge.isConnected()).toBe(false); - }); - - it("normalizes structured direct OpenAI startup auth errors", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - type: "invalid_request_error", - code: "invalid_api_key", - message: "Invalid API key", - }, - }), - ), - ); - - await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); - expect(bridge.isConnected()).toBe(false); - }); - - it("normalizes direct OpenAI socket handshake auth errors", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - - socket.emit("error", new Error("Unexpected server response: 401")); - - await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); - expect(bridge.isConnected()).toBe(false); - }); - - it.each([ - [ - "Azure deployment", - { - apiKey: "sk-test", // pragma: allowlist secret - azureEndpoint: "https://example.openai.azure.com", - azureDeployment: "realtime-prod", - }, - ], - [ - "custom endpoint", - { - apiKey: "sk-test", // pragma: allowlist secret - azureEndpoint: "https://realtime-proxy.example.com", - }, - ], - ])("preserves %s startup auth errors", async (_label, providerConfig) => { - const bridge = createNativeBridge({ - providerConfig, - }); - const { connecting, socket } = beginBridgeConnection(bridge); - - socket.emit("error", new Error("Unexpected server response: 401")); - - await expect(connecting).rejects.toThrow("Unexpected server response: 401"); - expect(bridge.isConnected()).toBe(false); - }); - - it("keeps a retried connection ready after delayed startup failure close", async () => { - const onClose = vi.fn(); - const bridge = createNativeBridge({ onClose }); - const { connecting: failedConnect, socket: failedSocket } = beginBridgeConnection(bridge); - failedSocket.deferClose = true; - - openSocket(failedSocket); - failedSocket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { message: "Incorrect API key provided" }, - }), - ), - ); - - await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); - expect(failedSocket.deferredClose).toBeDefined(); - - const { connecting: retryConnect, socket: retrySocket } = beginBridgeConnection(bridge, 1); - openSocket(retrySocket); - emitSessionUpdated(retrySocket); - await retryConnect; - - expect(bridge.isConnected()).toBe(true); - failedSocket.emitDeferredClose(); - expect(bridge.isConnected()).toBe(true); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("rejects connection when the socket closes before session readiness", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - socket.close(1006, "session closed"); - - await expect(connecting).rejects.toThrow("OpenAI realtime connection closed before ready"); - expect(bridge.isConnected()).toBe(false); - }); - - it("bounds sideband frames received before session readiness", async () => { - const bridge = createNativeBridge(); - const { connecting, socket } = beginBridgeConnection(bridge); - openSocket(socket); - const frame = Buffer.from( - JSON.stringify({ type: "session.created", padding: "x".repeat(600 * 1024) }), - ); - - socket.emit("message", frame); - socket.emit("message", frame); - - await expect(connecting).rejects.toThrow("sideband startup buffer exceeded"); - expect(bridge.isConnected()).toBe(false); - }); - - it("does not report startup timeout shutdown as a clean close", async () => { - vi.useFakeTimers(); - const onClose = vi.fn(); - const bridge = createNativeBridge({ onClose }); - const { connecting, socket } = beginBridgeConnection(bridge); - - const timeoutAssertion = expect(connecting).rejects.toThrow( - "OpenAI realtime connection timeout", - ); - await vi.advanceTimersByTimeAsync(10_000); - await timeoutAssertion; - expect(socket.terminated).toBe(true); - expect(onClose).not.toHaveBeenCalled(); - expect(FakeWebSocket.instances).toHaveLength(1); - expect(bridge.isConnected()).toBe(false); - }); - - it("can disable automatic audio turn responses for agent-routed voice loops", async () => { - const bridge = createNativeBridge({ - autoRespondToAudio: false, - }); - const { connecting, socket } = beginBridgeConnection(bridge); - - openSocket(socket); - emitSessionUpdated(socket); - await connecting; - - expectRecordFields( - requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), - "turn detection", - { - create_response: false, - interrupt_response: false, - }, - ); - }); - - it("can disable realtime response interruption while keeping audio responses enabled", async () => { - const bridge = createNativeBridge({ - autoRespondToAudio: true, - interruptResponseOnInputAudio: false, - }); - const socket = await connectReadyBridge(bridge); - - expectRecordFields( - requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), - "turn detection", - { - create_response: true, - interrupt_response: false, - }, - ); - }); - - it("does not locally clear playback on speech-start events when input interruption is disabled", async () => { - const onAudio = vi.fn(); - const onClearAudio = vi.fn(); - const bridge = createNativeBridge({ - autoRespondToAudio: true, - interruptResponseOnInputAudio: false, - onAudio, - onClearAudio, - }); - const socket = await connectReadyBridge(bridge); - - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "input_audio_buffer.speech_started" })), - ); - - expect(onAudio).toHaveBeenCalledTimes(1); - expect(onClearAudio).not.toHaveBeenCalled(); - expect(hasSentEventType(socket, "response.cancel")).toBe(false); - expect(hasSentEventType(socket, "conversation.item.truncate")).toBe(false); - }); - - it("keeps assistant playback active on server VAD when automatic audio responses are disabled", async () => { - const onAudio = vi.fn(); - const onClearAudio = vi.fn(); - const bridge = createNativeBridge({ - autoRespondToAudio: false, - onAudio, - onClearAudio, - }); - const socket = await connectReadyBridge(bridge); - - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "input_audio_buffer.speech_started" })), - ); - - expect(onAudio).toHaveBeenCalledTimes(1); - expect(onClearAudio).not.toHaveBeenCalled(); - expect(hasSentEventType(socket, "response.cancel")).toBe(false); - expect(hasSentEventType(socket, "conversation.item.truncate")).toBe(false); - }); - - it("can request PCM16 24 kHz realtime audio for Chrome command-pair bridges", async () => { - const bridge = createNativeBridge({ - audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - }); - const socket = await connectReadyBridge(bridge); - - const session = requireSession(socket); - expect(requireNestedRecord(session, ["audio", "input", "format"])).toEqual({ - type: "audio/pcm", - rate: 24000, - }); - expect(requireNestedRecord(session, ["audio", "output", "format"])).toEqual({ - type: "audio/pcm", - rate: 24000, - }); - }); - - it("settles cleanly when closed before the websocket opens", async () => { - const onClose = vi.fn(); - const bridge = createNativeBridge({ onClose }); - const { connecting, socket } = beginBridgeConnection(bridge); - - bridge.close(); - bridge.close(); - - await expect(connecting).resolves.toBeUndefined(); - expect(socket.closed).toBe(true); - expect(socket.terminated).toBe(false); - expect(onClose).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledWith("completed"); - }); - - it("truncates externally interrupted playback after an immediate mark acknowledgement", async () => { - const onAudio = vi.fn(); - const onClearAudio = vi.fn(); - const bridge = createNativeBridge({ - onAudio, - onClearAudio, - onMark: () => bridge.acknowledgeMark(), - }); - const socket = await connectReadyBridge(bridge); - - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - bridge.setMediaTimestamp(1300); - - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - - expect(onAudio).toHaveBeenCalledTimes(1); - expect(onClearAudio).toHaveBeenCalledWith("barge-in"); - expect(parseSent(socket).slice(-2)).toEqual([ - expectedResponseCancelEvent(), - { - type: "conversation.item.truncate", - item_id: "item_1", - content_index: 0, - audio_end_ms: 300, - }, - ]); - }); - - it("preserves FIFO playback acknowledgements after sustained output", async () => { - const onClearAudio = vi.fn(); - const onMark = vi.fn(); - const bridge = createNativeBridge({ - onClearAudio, - onMark, - }); - const socket = await connectReadyBridge(bridge); - - bridge.setMediaTimestamp(1000); - for (let index = 0; index < 300; index += 1) { - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - } - - const marks = onMark.mock.calls.map(([markName]) => String(markName)); - expect(marks).toHaveLength(300); - for (let index = 0; index < 299; index += 1) { - bridge.acknowledgeMark(); - } - bridge.setMediaTimestamp(1300); - bridge.handleBargeIn?.(); - - expect(parseSent(socket).slice(-1)).toEqual([ - { - type: "conversation.item.truncate", - item_id: "item_1", - content_index: 0, - audio_end_ms: 300, - }, - ]); - expect(onClearAudio).toHaveBeenCalledWith("barge-in"); - - for (let index = 0; index < 300; index += 1) { - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - } - const latestMark = onMark.mock.calls.at(-1)?.[0]; - if (typeof latestMark !== "string") { - throw new Error("expected a playback mark"); - } - bridge.acknowledgeMark(latestMark); - bridge.setMediaTimestamp(1600); - bridge.handleBargeIn?.(); - - expect( - parseSent(socket).filter((event) => event.type === "conversation.item.truncate"), - ).toHaveLength(1); - bridge.close(); - }); - - it("treats a later named mark as cumulative playback progress", async () => { - const onMark = vi.fn(); - const bridge = createNativeBridge({ onMark }); - const socket = await connectReadyBridge(bridge); - - bridge.setMediaTimestamp(1000); - for (let index = 0; index < 3; index += 1) { - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - } - const marks = onMark.mock.calls.map(([markName]) => String(markName)); - expect(marks).toHaveLength(3); - - bridge.acknowledgeMark(marks[2]); - bridge.acknowledgeMark(marks[0]); - bridge.acknowledgeMark(marks[1]); - bridge.setMediaTimestamp(1300); - bridge.handleBargeIn?.(); - - expect( - parseSent(socket).filter((event) => event.type === "conversation.item.truncate"), - ).toHaveLength(0); - bridge.close(); - }); - - it("forwards current realtime output audio events", async () => { - const onAudio = vi.fn(); - const onTranscript = vi.fn(); - const bridge = createNativeBridge({ - onAudio, - onTranscript, - }); - const socket = await connectReadyBridge(bridge); - - const audio = Buffer.from("assistant audio"); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.output_audio.delta", - item_id: "item_1", - delta: audio.toString("base64"), - }), - ), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.output_audio_transcript.done", - transcript: "hello from current realtime events", - }), - ), - ); - - expect(onAudio).toHaveBeenCalledWith(audio); - expect(onTranscript).toHaveBeenCalledWith( - "assistant", - "hello from current realtime events", - true, - ); - }); - - it("surfaces input transcription failures with their provider error details", async () => { - const onError = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onError, onEvent }); - const socket = await connectReadyBridge(bridge); - - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "conversation.item.input_audio_transcription.failed", - item_id: "item_speech", - error: { code: "decoder_failure", message: "speech decoder exploded" }, - }), - ), - ); - - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "speech decoder exploded" }), - ); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "conversation.item.input_audio_transcription.failed", - itemId: "item_speech", - detail: "speech decoder exploded", - }); - }); - - it("preserves corrected final text from legacy realtime text events", async () => { - const onTranscript = vi.fn(); - const bridge = createNativeBridge({ onTranscript }); - const socket = await connectReadyBridge(bridge); - - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.text.delta", delta: "draft assistant" })), - ); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.text.done", text: "corrected assistant" })), - ); - - expect(onTranscript.mock.calls).toEqual([ - ["assistant", "draft assistant", false], - ["assistant", "corrected assistant", true], - ]); - }); - - it.each([ - ["invalid alphabet", "not-base64!"], - ["non-canonical pad bits", "ZE=="], - ])("terminates the session for %s in output audio", async (_scenario, delta) => { - const onAudio = vi.fn(); - const onError = vi.fn(); - const onClose = vi.fn(); - const bridge = createNativeBridge({ - onAudio, - onError, - onClose, - }); - const socket = await connectReadyBridge(bridge); - - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.output_audio.delta", - item_id: "item_1", - delta, - }), - ), - ); - - expect(onAudio).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ - message: "OpenAI realtime stream returned malformed base64 audio data", - }), - ); - expect(onClose).toHaveBeenCalledWith("error"); - expect(socket.closed).toBe(true); - await expect(bridge.connect()).rejects.toThrow( - "OpenAI realtime stream returned malformed base64 audio data", - ); - }); - - it("forwards Codex-compatible legacy realtime audio and transcript events", async () => { - const onAudio = vi.fn(); - const onTranscript = vi.fn(); - const bridge = createNativeBridge({ - onAudio, - onTranscript, - }); - const socket = await connectReadyBridge(bridge); - - const audio = Buffer.from("legacy assistant audio"); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "conversation.output_audio.delta", - data: audio.toString("base64"), - sample_rate: 24000, - channels: 1, - }), - ), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "conversation.input_transcript.delta", - delta: "partial user", - }), - ), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "conversation.output_transcript.delta", - delta: "partial assistant", - }), - ), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.output_text.done", - text: "final assistant text", - }), - ), - ); - - expect(onAudio).toHaveBeenCalledWith(audio); - expect(onTranscript).toHaveBeenCalledWith("user", "partial user", false); - expect(onTranscript).toHaveBeenCalledWith("assistant", "partial assistant", false); - expect(onTranscript).toHaveBeenCalledWith("assistant", "final assistant text", true); - }); - - it("executes tool calls only from successful response output", async () => { - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onToolCall }); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.function_call_arguments.delta", - item_id: "item_tool_1", - name: "openclaw_agent_consult", - call_id: "call_1", - delta: '{"question":"provisional', - }); - emitServerEvent(socket, { - type: "response.function_call_arguments.done", - item_id: "item_tool_1", - name: "openclaw_agent_consult", - call_id: "call_1", - arguments: '{"question":"still provisional"}', - }); - emitServerEvent(socket, { - type: "conversation.item.done", - item: { - id: "item_tool_1", - type: "function_call", - name: "openclaw_agent_consult", - call_id: "call_1", - arguments: '{"question":"not terminal"}', - }, - }); - expect(onToolCall).not.toHaveBeenCalled(); - - const completed = { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: [ - { - id: "item_tool_1", - type: "function_call", - status: "completed", - name: "openclaw_agent_consult", - call_id: "call_1", - arguments: '{"question":"delegate this"}', - }, - ], - }, - }; - emitServerEvent(socket, completed); - emitServerEvent(socket, completed); - - expect(onToolCall).toHaveBeenCalledTimes(1); - expect(onToolCall).toHaveBeenCalledWith({ - itemId: "item_tool_1", - callId: "call_1", - name: "openclaw_agent_consult", - args: { question: "delegate this" }, - }); - }); - - it.each(["cancelled", "failed", "incomplete"])( - "ignores function calls from a %s response", - async (status) => { - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onToolCall }); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status, - output: [ - { - id: "item_tool_1", - type: "function_call", - status: "completed", - name: "openclaw_agent_consult", - call_id: "call_1", - arguments: '{"question":"must stay inert"}', - }, - ], - }, - }); - - expect(onToolCall).not.toHaveBeenCalled(); - }, - ); - - it("ignores malformed and unfinished response output items", async () => { - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onToolCall }); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: [ - null, - "invalid", - { - id: "item_tool_1", - type: "function_call", - status: "incomplete", - name: "openclaw_agent_consult", - call_id: "call_1", - arguments: '{"question":"unfinished"}', - }, - ], - }, - }); - - expect(onToolCall).not.toHaveBeenCalled(); - }); - - it.each([ - { - name: "an argument object", - finalArguments: '{"city":"Paris"}', - expectedArguments: { city: "Paris" }, - }, - { - name: "the shipped empty argument contract", - finalArguments: "", - expectedArguments: {}, - }, - ])( - "uses terminal response arguments for $name", - async ({ finalArguments, expectedArguments }) => { - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onToolCall }); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: [ - { - id: "item_tool_1", - type: "function_call", - status: "completed", - call_id: "call_1", - name: "lookup_weather", - arguments: finalArguments, - }, - ], - }, - }); - - expect(onToolCall).toHaveBeenCalledWith({ - itemId: "item_tool_1", - callId: "call_1", - name: "lookup_weather", - args: expectedArguments, - }); - }, - ); - - it.each([ - { name: "malformed JSON", arguments: '{"city":', reason: "malformed-json" }, - { name: "an array", arguments: '["Paris"]', reason: "non-object-json" }, - { name: "JSON null", arguments: "null", reason: "non-object-json" }, - { name: "a number", arguments: "42", reason: "non-object-json" }, - { name: "a boolean", arguments: "true", reason: "non-object-json" }, - { name: "missing arguments", arguments: undefined, reason: "invalid-json-type" }, - { name: "non-string arguments", arguments: { city: "Paris" }, reason: "invalid-json-type" }, - ])("rejects $name per call without ending the session", async ({ arguments: args, reason }) => { - const onToolCall = vi.fn(); - const onError = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onToolCall, onError, onEvent }); - const socket = await connectReadyBridge(bridge); - const completed = { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: [ - { - id: "item_tool_1", - type: "function_call", - status: "completed", - call_id: "call_1", - name: "lookup_weather", - arguments: args, - }, - ], - }, - }; - - emitServerEvent(socket, { type: "response.created", response: { id: "response_1" } }); - emitServerEvent(socket, completed); - emitServerEvent(socket, completed); - - expect(onToolCall).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "tool_call.arguments.rejected", - detail: `reason=${reason}`, - itemId: "item_tool_1", - }); - expect( - parseSent(socket).filter( - (event) => - event.type === "conversation.item.create" && - (event.item as { call_id?: string } | undefined)?.call_id === "call_1", - ), - ).toHaveLength(1); - }); - - it.each([ - { - name: "accepts", - encoding: "ASCII", - argumentBytes: 256_000, - unit: "a", - repeat: 255_992, - suffix: "", - rejected: false, - }, - { - name: "rejects", - encoding: "ASCII", - argumentBytes: 256_001, - unit: "a", - repeat: 255_993, - suffix: "", - rejected: true, - }, - { - name: "accepts", - encoding: "multibyte", - argumentBytes: 256_000, - unit: "é", - repeat: 127_996, - suffix: "", - rejected: false, - }, - { - name: "rejects", - encoding: "multibyte", - argumentBytes: 256_001, - unit: "é", - repeat: 127_996, - suffix: "a", - rejected: true, - }, - ])( - "$name $argumentBytes-byte $encoding UTF-8 arguments", - async ({ argumentBytes, unit, repeat, suffix, rejected }) => { - const onToolCall = vi.fn(); - const onError = vi.fn(); - const bridge = createNativeBridge({ onToolCall, onError }); - const socket = await connectReadyBridge(bridge); - const rawArgs = `{"x":"${unit.repeat(repeat)}${suffix}"}`; - expect(Buffer.byteLength(rawArgs, "utf8")).toBe(argumentBytes); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: [ - { - id: "item_tool_1", - type: "function_call", - status: "completed", - call_id: "call_1", - name: "lookup_weather", - arguments: rawArgs, - }, - ], - }, - }); - - expect(onToolCall).toHaveBeenCalledTimes(rejected ? 0 : 1); - expect( - parseSent(socket).some( - (event) => - event.type === "conversation.item.create" && - (event.item as { call_id?: string } | undefined)?.call_id === "call_1", - ), - ).toBe(rejected); - expect(onError).not.toHaveBeenCalled(); - }, - ); - - it("ends an extreme session before terminal tool-call ids become unbounded", async () => { - const onToolCall = vi.fn(); - const onError = vi.fn(); - const onClose = vi.fn(); - const bridge = createNativeBridge({ onToolCall, onError, onClose }); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: Array.from({ length: 1_025 }, (_, index) => ({ - id: `item_${index}`, - type: "function_call", - status: "completed", - call_id: `call_${index}`, - name: "lookup_weather", - arguments: "{}", - })), - }, - }); - - expect(onToolCall).toHaveBeenCalledTimes(1_024); - expect(onError).toHaveBeenCalledOnce(); - expect(onError).toHaveBeenCalledWith( - new Error("OpenAI realtime tool-call session limit exceeded (1024)"), - ); - expect(onClose).toHaveBeenCalledWith("error"); - expect(socket.closed).toBe(true); - await expect(bridge.connect()).rejects.toThrow( - "OpenAI realtime tool-call session limit exceeded (1024)", - ); - }); - - it("stops dispatching terminal output when a tool callback closes the bridge", async () => { - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onToolCall }); - onToolCall.mockImplementation(() => bridge.close()); - const socket = await connectReadyBridge(bridge); - - emitServerEvent(socket, { - type: "response.done", - response: { - id: "response_1", - status: "completed", - output: Array.from({ length: 2 }, (_, index) => ({ - id: `item_${index}`, - type: "function_call", - status: "completed", - call_id: `call_${index}`, - name: "lookup_weather", - arguments: "{}", - })), - }, - }); - - expect(onToolCall).toHaveBeenCalledTimes(1); - }); - - it("creates an explicit user item and response for manual speech", async () => { - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onEvent }); - const socket = await connectReadyBridge(bridge); - - bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); - - const sent = parseSent(socket); - expect(sent[1]).toEqual({ - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: "Say exactly: hello from explicit speech.", - }, - ], - }, - }); - expectRecordFields( - requireNestedRecord(sent[2]?.session, ["audio", "input", "turn_detection"]), - "manual response turn detection", - { - create_response: false, - interrupt_response: true, - }, - ); - expect(sent[3]).toEqual(expectedResponseCreateEvent()); - expect(JSON.stringify(parseSent(socket).at(-1))).not.toContain("output_modalities"); - expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "conversation.item.create" }); - expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "response.create" }); - - emitServerEvent(socket, { type: "response.done" }); - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it("forces one host-selected function on an otherwise automatic response", async () => { - const bridge = createNativeBridge(); - const socket = await connectReadyBridge(bridge); - - bridge.sendUserMessage?.("Run the deterministic check.", { - toolChoice: { type: "function", name: "lookup_weather" }, - }); - - expect(parseSent(socket).at(-1)).toEqual({ - type: "response.create", - event_id: expect.stringMatching(/^openclaw-response-create-/), - response: { - output_modalities: ["audio"], - tool_choice: { type: "function", name: "lookup_weather" }, - }, - }); - }); - - it("defers manual response.create while a realtime response is active", async () => { - const bridge = createNativeBridge(); - const socket = await connectReadyBridge(bridge); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - - bridge.sendUserMessage?.("queued manual response"); - - expect(parseSent(socket).slice(-1)).toEqual([ - { - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [{ type: "input_text", text: "queued manual response" }], - }, - }, - ]); - - emitServerEvent(socket, { type: "response.done" }); - - expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - }); - - it("restores automatic audio responses when a manual response is rejected", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - - bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); - - const responseCreateEvent = parseSent(socket).findLast( - (event) => event.type === "response.create", - ); - if (!responseCreateEvent?.event_id) { - throw new Error("expected response.create event id"); - } - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-2)?.session, ["audio", "input", "turn_detection"]), - "suppressed turn detection", - { - create_response: false, - interrupt_response: true, - }, - ); - - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - event_id: responseCreateEvent.event_id, - message: "bad response request", - }, - }), - ), - ); - - expect(onError).toHaveBeenCalledWith(new Error("bad response request")); - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it("keeps automatic audio suppressed for unrelated errors during a manual response", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - - bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); - const sessionUpdatesBeforeError = parseSent(socket).filter( - (event) => event.type === "session.update", - ); - - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { event_id: "unrelated-audio-event", message: "bad audio append" }, - }), - ), - ); - - expect(onError).toHaveBeenCalledWith(new Error("bad audio append")); - expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( - sessionUpdatesBeforeError.length, - ); - - emitServerEvent(socket, { type: "response.done" }); - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it("flushes a queued manual response after the prior request is rejected", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - - bridge.triggerGreeting?.("Say exactly: first greeting."); - const firstResponseCreate = parseSent(socket).findLast( - (event) => event.type === "response.create", - ); - if (!firstResponseCreate?.event_id) { - throw new Error("expected first response.create event id"); - } - const sessionUpdateCount = parseSent(socket).filter( - (event) => event.type === "session.update", - ).length; - - bridge.sendUserMessage?.("Say exactly: queued follow-up."); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - event_id: firstResponseCreate.event_id, - message: "bad response request", - }, - }), - ), - ); - - const responseCreates = parseSent(socket).filter((event) => event.type === "response.create"); - expect(responseCreates).toHaveLength(2); - expect(responseCreates[1]).toEqual(expectedResponseCreateEvent()); - expect(responseCreates[1]?.event_id).not.toBe(firstResponseCreate.event_id); - expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( - sessionUpdateCount, - ); - expect(onError).toHaveBeenCalledWith(new Error("bad response request")); - - emitServerEvent(socket, { type: "response.done" }); - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it.each([ - ["undefined", (): undefined => undefined], - ["function", () => () => undefined], - ["symbol", () => Symbol("invalid-tool-result")], - ["bigint", () => ({ value: 1n })], - [ - "circular", - () => { - const result: { self?: unknown } = {}; - result.self = result; - return result; - }, - ], - ["omitted custom serialization", () => ({ toJSON: () => undefined })], - ] as const)( - "rejects %s tool results without consuming a retryable call", - async (_label, create) => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket); - const previousEventCount = socket.sent.length; - - expect(() => bridge.submitToolResult("call_1", create())).toThrow(); - expect(socket.sent).toHaveLength(previousEventCount); - expect(hasSentEventType(socket, "response.create")).toBe(false); - - await bridge.submitToolResult("call_1", { recovered: true }); - - expect(parseSent(socket).find((event) => event.type === "conversation.item.create")).toEqual({ - type: "conversation.item.create", - item: { - type: "function_call_output", - call_id: "call_1", - output: JSON.stringify({ recovered: true }), - }, - }); - }, - ); - - it("preserves valid JSON tool results and invokes custom serialization once", async () => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - const values: unknown[] = [null, false, 0, "", "text", [1], { ok: true }]; - const customSerialization = vi.fn((key: string) => ({ key })); - values.push({ toJSON: customSerialization }); - const callIds = values.map((_, index) => `call_${index}`); - emitCompletedToolCalls(socket, callIds); - - for (const [index, result] of values.entries()) { - await bridge.submitToolResult(callIds[index]!, result, { suppressResponse: true }); - } - - const outputs = parseSent(socket) - .filter((event) => event.type === "conversation.item.create") - .map((event) => (event.item as { output: string }).output); - expect(outputs).toEqual([ - "null", - "false", - "0", - '""', - '"text"', - "[1]", - '{"ok":true}', - '{"key":""}', - ]); - expect(customSerialization).toHaveBeenCalledExactlyOnceWith(""); - }); - - it("does not request a realtime response for continuing tool results", async () => { - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onEvent, onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket); - - const working = bridge.submitToolResult( - "call_1", - { status: "working" }, - { willContinue: true }, - ); - - expect(parseSent(socket).slice(-1)).toEqual([ - expectedFunctionOutput("call_1", { status: "working" }), - ]); - expect(hasSentEventType(socket, "response.create")).toBe(false); - expect(working).toBeUndefined(); - - const done = bridge.submitToolResult("call_1", { text: "done" }); - expect(done).toBeUndefined(); - - expect(parseSent(socket).slice(-3)).toEqual([ - expectedFunctionOutput("call_1", { text: "done" }), - expect.objectContaining({ type: "session.update" }), - expectedResponseCreateEvent(), - ]); - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - emitFunctionOutputAdded(socket, "call_1"); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "conversation.item.added", - detail: "itemType=function_call_output", - }); - emitServerEvent(socket, { - type: "conversation.item.done", - item: { type: "function_call_output", call_id: "call_1" }, - }); - expect(onEvent).toHaveBeenCalledWith({ - direction: "server", - type: "conversation.item.done", - detail: "itemType=function_call_output", - }); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_2" } })), - ); - emitServerEvent(socket, { type: "response.done" }); - - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - }); - - it("does not request a realtime response for suppressed tool results", async () => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket); - - const submission = bridge.submitToolResult( - "call_1", - { status: "already_delivered" }, - { suppressResponse: true }, - ); - - expect(parseSent(socket).slice(-1)).toEqual([ - expectedFunctionOutput("call_1", { status: "already_delivered" }), - ]); - emitFunctionOutputAdded(socket, "call_1"); - await submission; - expect(hasSentEventType(socket, "response.create")).toBe(false); - }); - - it("waits for every parallel tool result before continuing the response", async () => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket, ["call_1", "call_2"]); - - const first = bridge.submitToolResult("call_1", { text: "first" }); - emitFunctionOutputAdded(socket, "call_1"); - await first; - - expect(parseSent(socket).filter((event) => event.type === "response.create")).toEqual([]); - - const second = bridge.submitToolResult("call_2", { text: "second" }); - emitFunctionOutputAdded(socket, "call_2"); - await second; - - expect( - parseSent(socket).filter((event) => event.type === "conversation.item.create"), - ).toHaveLength(2); - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - }); - - it("releases a deferred continuation when the last parallel result is suppressed", async () => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket, ["call_1", "call_2"]); - - const first = bridge.submitToolResult("call_1", { text: "first" }); - const second = bridge.submitToolResult( - "call_2", - { status: "already_delivered" }, - { suppressResponse: true }, - ); - emitFunctionOutputAdded(socket, "call_1"); - emitFunctionOutputAdded(socket, "call_2"); - await Promise.all([first, second]); - - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - }); - - it("resets consumer tool ownership before a fresh reconnect can reuse a call id", async () => { - vi.useFakeTimers(); - const staleWork = new AbortController(); - const onEvent = vi.fn((event: RealtimeVoiceBridgeEvent) => { - if (event.direction === "client" && event.type === "session.continuity.reset") { - staleWork.abort(); - } - }); - const onToolCall = vi.fn(); - const bridge = createNativeBridge({ onEvent, onToolCall }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket, ["call_reused"]); - expect(onToolCall).toHaveBeenCalledTimes(1); - - socket.emit("close", 1006, Buffer.from("transient drop")); - const lifecycleEvents = onEvent.mock.calls.map(([event]) => event.type); - expect(lifecycleEvents.indexOf("session.continuity.reset")).toBeLessThan( - lifecycleEvents.indexOf("session.reconnect.scheduled"), - ); - await vi.advanceTimersByTimeAsync(1000); - const reconnectedSocket = requireSocket(1); - openSocket(reconnectedSocket); - emitSessionUpdated(reconnectedSocket); - - emitCompletedToolCalls(socket, ["call_from_old_socket"]); - expect( - parseSent(reconnectedSocket).filter((event) => event.type === "conversation.item.create"), - ).toEqual([]); - expect(onToolCall).toHaveBeenCalledTimes(1); - - emitCompletedToolCalls(reconnectedSocket, ["call_reused"]); - if (!staleWork.signal.aborted) { - void bridge.submitToolResult("call_reused", { text: "stale" }); - } - const fresh = bridge.submitToolResult("call_reused", { text: "fresh" }); - emitFunctionOutputAdded(reconnectedSocket, "call_reused"); - await fresh; - - expect(onToolCall).toHaveBeenCalledTimes(2); - expect( - parseSent(reconnectedSocket) - .filter( - (event) => - event.type === "conversation.item.create" && - (event.item as { call_id?: string } | undefined)?.call_id === "call_reused", - ) - .map((event) => (event.item as { output?: string } | undefined)?.output), - ).toEqual([JSON.stringify({ text: "fresh" })]); - }); - - it("does not flush deferred response.create while a tool result is still continuing", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError, onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - - emitCompletedToolCalls(socket); - const working = bridge.submitToolResult( - "call_1", - { status: "working" }, - { willContinue: true }, - ); - emitFunctionOutputAdded(socket, "call_1"); - await working; - bridge.sendUserMessage?.("queue after tool result"); - - expect(onError).not.toHaveBeenCalled(); - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_status" } })), - ); - emitServerEvent(socket, { - type: "response.done", - response: { id: "resp_status", status: "completed", output: [] }, - }); - - const done = bridge.submitToolResult("call_1", { text: "done" }); - emitFunctionOutputAdded(socket, "call_1"); - await done; - - expect(parseSent(socket).slice(-3)).toEqual([ - expectedFunctionOutput("call_1", { text: "done" }), - expect.objectContaining({ type: "session.update" }), - expectedResponseCreateEvent(), - ]); - }); - - it("serializes standalone control speech while an agent tool call is pending", async () => { - const bridge = createNativeBridge({ onToolCall: vi.fn() }); - const socket = await connectReadyBridge(bridge); - emitCompletedToolCalls(socket); - - for (const text of ["status", "steer", "cancel"]) { - bridge.sendUserMessage?.(text); - } - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); - - for (let index = 0; index < 3; index += 1) { - socket.emit( - "message", - Buffer.from( - JSON.stringify({ type: "response.created", response: { id: `resp_control_${index}` } }), - ), - ); - emitServerEvent(socket, { - type: "response.done", - response: { id: `resp_control_${index}`, status: "completed", output: [] }, - }); - expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength( - Math.min(index + 2, 3), - ); - } - }); - - it("drains deferred response.create after response.cancelled", async () => { - const bridge = createNativeBridge(); - const socket = await connectReadyBridge(bridge); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - - bridge.sendUserMessage?.("queued after cancellation"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.cancelled" }))); - - expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - }); - - it("does not send duplicate response.cancel while cancellation is pending", async () => { - const onEvent = vi.fn(); - const bridge = createNativeBridge({ onEvent }); - const socket = await connectReadyBridge(bridge); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - bridge.setMediaTimestamp(1300); - - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - - expect(parseSent(socket).filter((event) => event.type === "response.cancel")).toHaveLength(1); - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "response.cancel", - detail: "reason=barge-in", - }); - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "conversation.item.truncate", - detail: "reason=barge-in audioEndMs=300", - }); - }); - - it("ignores zero-length playback barge-in without clearing audio", async () => { - const onClearAudio = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ - onClearAudio, - onEvent, - }); - const socket = await connectReadyBridge(bridge); - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - - expect(onClearAudio).not.toHaveBeenCalled(); - expect(hasSentEventType(socket, "response.cancel")).toBe(false); - expect(parseSent(socket).some((event) => event.type === "conversation.item.truncate")).toBe( - false, - ); - expect(onEvent).toHaveBeenCalledWith({ - direction: "client", - type: "conversation.item.truncate.skipped", - detail: "reason=barge-in audioEndMs=0 minAudioEndMs=250", - }); - }); - - it("force-cancels zero-length playback barge-in for agent handoff fallback", async () => { - const onClearAudio = vi.fn(); - const onEvent = vi.fn(); - const bridge = createNativeBridge({ - onClearAudio, - onEvent, - }); - const socket = await connectReadyBridge(bridge); - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - - bridge.handleBargeIn?.({ audioPlaybackActive: true, force: true }); - - expect(parseSent(socket).slice(-2)).toEqual([ - expectedResponseCancelEvent(), - { - type: "conversation.item.truncate", - item_id: "item_1", - content_index: 0, - audio_end_ms: 0, - }, - ]); - expect(onClearAudio).toHaveBeenCalled(); - expect( - onEvent.mock.calls.some( - ([event]) => isRecord(event) && event.type === "conversation.item.truncate.skipped", - ), - ).toBe(false); - }); - - it("allows immediate playback barge-in when the minimum audio window is zero", async () => { - const onClearAudio = vi.fn(); - const bridge = createNativeBridge({ - providerConfig: { - apiKey: "sk-test", // pragma: allowlist secret - minBargeInAudioEndMs: 0, - }, - onClearAudio, - }); - const socket = await connectReadyBridge(bridge); - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - - expect(onClearAudio).toHaveBeenCalledWith("barge-in"); - expect(parseSent(socket).slice(-2)).toEqual([ - expectedResponseCancelEvent(), - { - type: "conversation.item.truncate", - item_id: "item_1", - content_index: 0, - audio_end_ms: 0, - }, - ]); - }); - - it("drains deferred response.create after a no-active-response cancellation error", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - - bridge.sendUserMessage?.("queued after cancellation error"); - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - const responseCancelEvent = parseSent(socket).findLast( - (event) => event.type === "response.cancel", - ); - if (!responseCancelEvent?.event_id) { - throw new Error("expected response.cancel event id"); - } - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - event_id: responseCancelEvent.event_id, - message: "Cancellation failed: no active response found", - }, - }), - ), - ); - - expect(onError).not.toHaveBeenCalled(); - expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - }); - - it("ignores a stale cancellation error after a newer manual response starts", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - bridge.setMediaTimestamp(1000); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "response.audio.delta", - item_id: "item_1", - delta: Buffer.from("assistant audio").toString("base64"), - }), - ), - ); - bridge.setMediaTimestamp(1300); - - bridge.handleBargeIn?.({ audioPlaybackActive: true }); - const responseCancelEvent = parseSent(socket).findLast( - (event) => event.type === "response.cancel", - ); - if (!responseCancelEvent?.event_id) { - throw new Error("expected response.cancel event id"); - } - bridge.sendUserMessage?.("queued newer response"); - emitServerEvent(socket, { type: "response.done" }); - const sessionUpdateCount = parseSent(socket).filter( - (event) => event.type === "session.update", - ).length; - - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - event_id: responseCancelEvent.event_id, - message: "Cancellation failed: no active response found", - }, - }), - ), - ); - - expect(onError).not.toHaveBeenCalled(); - expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( - sessionUpdateCount, - ); - expect(parseSent(socket).at(-1)).toEqual(expectedResponseCreateEvent()); - - emitServerEvent(socket, { type: "response.done" }); - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); - - it("resets deferred response guards after websocket reconnect", async () => { - vi.useFakeTimers(); - const bridge = createNativeBridge(); - const socket = await connectReadyBridge(bridge); - socket.emit( - "message", - Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), - ); - bridge.sendUserMessage?.("queued before reconnect"); - - expect(parseSent(socket).slice(-1)[0]?.type).toBe("conversation.item.create"); - - socket.emit("close", 1006, Buffer.from("transient drop")); - await vi.advanceTimersByTimeAsync(1000); - const reconnectedSocket = requireSocket(1); - openSocket(reconnectedSocket); - emitSessionUpdated(reconnectedSocket); - bridge.sendUserMessage?.("Say hello after reconnect."); - - expect(parseSent(reconnectedSocket).slice(-3)).toEqual([ - { - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [{ type: "input_text", text: "Say hello after reconnect." }], - }, - }, - expect.objectContaining({ type: "session.update" }), - expectedResponseCreateEvent(), - ]); - }); - - it("turns active-response errors into a deferred response.create retry", async () => { - const onError = vi.fn(); - const bridge = createNativeBridge({ onError }); - const socket = await connectReadyBridge(bridge); - - bridge.sendUserMessage?.("trigger active-response retry"); - const responseCreateEvent = parseSent(socket).findLast( - (event) => event.type === "response.create", - ); - if (!responseCreateEvent?.event_id) { - throw new Error("expected response.create event id"); - } - socket.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: { - event_id: responseCreateEvent.event_id, - message: "Conversation already has an active response in progress: resp_1", - }, - }), - ), - ); - const afterError = parseSent(socket); - expect(afterError.filter((event) => event.type === "session.update")).toHaveLength(2); - expectRecordFields( - requireNestedRecord(afterError.at(-2)?.session, ["audio", "input", "turn_detection"]), - "still suppressed turn detection", - { - create_response: false, - interrupt_response: true, - }, - ); - - emitServerEvent(socket, { type: "response.done" }); - - expect(onError).not.toHaveBeenCalled(); - expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - - emitServerEvent(socket, { type: "response.done" }); - - expectRecordFields( - requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), - "restored turn detection", - { - create_response: true, - interrupt_response: true, - }, - ); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index e09162f69799..c8c652eba188 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -1,57 +1,17 @@ -// Openai provider module implements model/runtime integration. -import { execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; -import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; -import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; -import { - isProviderAuthProfileConfigured, - resolveProviderAuthProfileApiKey, -} from "openclaw/plugin-sdk/provider-auth"; import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http"; -import { - captureWsEvent, - createDebugProxyWebSocketAgent, - resolveDebugProxySettings, -} from "openclaw/plugin-sdk/proxy-capture"; import type { - RealtimeVoiceAudioFormat, - RealtimeVoiceBargeInOptions, - RealtimeVoiceBridge, RealtimeVoiceBrowserSession, RealtimeVoiceBrowserSessionCreateRequest, - RealtimeVoiceBridgeCreateRequest, RealtimeVoiceProviderCapabilities, RealtimeVoiceProviderConfig, RealtimeVoiceProviderPlugin, - RealtimeVoiceSessionConnection, - RealtimeVoiceTool, - RealtimeVoiceToolResultOptions, } from "openclaw/plugin-sdk/realtime-voice"; +import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { - REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, - REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - normalizeRealtimeVoiceResponseOutcome, - RealtimeVoiceSessionLifecycle, -} from "openclaw/plugin-sdk/realtime-voice"; -import { sleepWithAbort, warn } from "openclaw/plugin-sdk/runtime-env"; -import { - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; -import { - asFiniteNumber, - asFiniteNumberInRange, - asSafeIntegerInRange, - isRecord, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import WebSocket from "ws"; -import { - captureOpenAIRealtimeWsClose, createOpenAIRealtimeClientSecret, - readRealtimeErrorDetail, resolveOpenAIProviderConfigRecord, } from "./realtime-provider-shared.js"; import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; @@ -62,1999 +22,29 @@ import { OPENAI_QUICKSILVER_CAPABILITIES, resolveOpenAIChatGptSubscriptionAuth, } from "./realtime-quicksilver-session.js"; -import { buildOpenAIRealtimeSidebandUrl } from "./realtime-quicksilver-wire.js"; +import { isOpenAIGptLiveModel, isSupportedOpenAIGptLiveModel } from "./realtime-quicksilver.js"; +import { OpenAIRealtimeBridge } from "./realtime-voice-bridge.js"; import { - isOpenAIGptLiveModel, - isSupportedOpenAIGptLiveModel, - OPENAI_GPT_LIVE_MODELS, -} from "./realtime-quicksilver.js"; - -type OpenAIRealtimeVoice = - | "alloy" - | "ash" - | "ballad" - | "cedar" - | "coral" - | "echo" - | "marin" - | "sage" - | "shimmer" - | "verse"; - -type OpenAIRealtimeUserMessageOptions = { - toolChoice?: { type: "function"; name: string }; -}; - -type OpenAIRealtimeVoiceProviderConfig = { - apiKey?: string; - model?: string; - voice?: OpenAIRealtimeVoice; - temperature?: number; - vadThreshold?: number; - silenceDurationMs?: number; - prefixPaddingMs?: number; - interruptResponseOnInputAudio?: boolean; - minBargeInAudioEndMs?: number; - reasoningEffort?: string; - azureEndpoint?: string; - azureDeployment?: string; - azureApiVersion?: string; -}; - -type OpenAIRealtimeVoiceBridgeConfig = RealtimeVoiceBridgeCreateRequest & { - apiKey?: string; - callId?: string; - gaSessionPolicy?: RealtimeGaSessionPolicy; - model?: string; - voice?: OpenAIRealtimeVoice; - temperature?: number; - vadThreshold?: number; - silenceDurationMs?: number; - prefixPaddingMs?: number; - interruptResponseOnInputAudio?: boolean; - minBargeInAudioEndMs?: number; - reasoningEffort?: string; - azureEndpoint?: string; - azureDeployment?: string; - azureApiVersion?: string; -}; - -const OPENAI_REALTIME_DEFAULT_MODEL = "gpt-realtime-2.1"; -// Picker suggestions surfaced through talk.catalog; each value is live-verified -// against the OpenAI realtime APIs. Free-form model values are still accepted. -const OPENAI_REALTIME_MODELS = [ - "gpt-realtime-2.1", - "gpt-realtime-2.1-mini", - "gpt-realtime-2", - ...OPENAI_GPT_LIVE_MODELS, -] as const; -const OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe"; -const OPENAI_REALTIME_CAPABILITIES: RealtimeVoiceProviderCapabilities = { - transports: ["webrtc", "gateway-relay"], - inputAudioFormats: [ - REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, - REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - ], - outputAudioFormats: [ - REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, - REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - ], - supportsBrowserSession: true, - supportsBargeIn: true, - handlesInputAudioBargeIn: true, - supportsToolCalls: true, - supportsVideoFrames: true, -}; -const OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX = - "Conversation already has an active response in progress:"; -const OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR = - "Cancellation failed: no active response found"; -const OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT = "maximum duration"; -const OPENAI_VOICE_WS_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; -const OPENAI_REALTIME_SIDEBAND_STARTUP_MAX_BYTES = 1024 * 1024; -const OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS = 250; -// Realtime validates this character set but accepts names beyond the 64-character -// cap used by other OpenAI tool surfaces. -const OPENAI_REALTIME_TOOL_NAME_RE = /^[A-Za-z0-9_-]+$/; -const AZURE_OPENAI_REALTIME_TOOL_NAME_MAX_LENGTH = 64; -const OPENAI_REALTIME_VOICES = [ - "alloy", - "ash", - "ballad", - "coral", - "echo", - "sage", - "shimmer", - "verse", - "marin", - "cedar", -] as const satisfies readonly OpenAIRealtimeVoice[]; - -function normalizeOpenAIRealtimeVoice(value: unknown): OpenAIRealtimeVoice | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - return OPENAI_REALTIME_VOICES.includes(normalized as OpenAIRealtimeVoice) - ? (normalized as OpenAIRealtimeVoice) - : undefined; -} - -type RealtimeEvent = { - type: string; - delta?: string; - data?: string; - text?: string; - transcript?: string; - item_id?: string; - response_id?: string; - call_id?: string; - name?: string; - arguments?: string; - session?: unknown; - item?: { - id?: string; - type?: string; - name?: string; - call_id?: string; - arguments?: string; - }; - response?: { - id?: string; - status?: string; - status_details?: unknown; - output?: unknown[]; - }; - error?: unknown; -}; - -type RealtimeTurnDetectionConfig = { - type: "server_vad"; - threshold: number; - prefix_padding_ms: number; - silence_duration_ms: number; - create_response: boolean; - interrupt_response?: boolean; -}; - -type RealtimeGaSessionPolicy = { - type: "realtime"; - model: string; - instructions?: string; - output_modalities: string[]; - audio: { - input: { - format: OpenAIRealtimeAudioFormatConfig; - turn_detection: RealtimeTurnDetectionConfig; - noise_reduction: { type: "near_field" } | null; - transcription: { model: string; language?: string }; - }; - output: { - format: OpenAIRealtimeAudioFormatConfig; - voice: OpenAIRealtimeVoice; - }; - }; - reasoning?: { effort: string }; - tools?: RealtimeVoiceTool[]; - tool_choice?: string; -}; - -type RealtimeGaSessionUpdate = { - type: "session.update"; - session: RealtimeGaSessionPolicy; -}; - -type RealtimeAzureDeploymentSessionUpdate = { - type: "session.update"; - session: { - modalities: string[]; - instructions?: string; - voice: OpenAIRealtimeVoice; - input_audio_format: "g711_ulaw" | "pcm16"; - output_audio_format: "g711_ulaw" | "pcm16"; - input_audio_transcription?: { model: string; language?: string }; - turn_detection: RealtimeTurnDetectionConfig; - temperature: number; - tools?: RealtimeVoiceTool[]; - tool_choice?: string; - }; -}; - -type OpenAIRealtimeAudioFormatConfig = - | { - type: "audio/pcm"; - rate: 24000; - } - | { - type: "audio/pcmu"; - }; - -function normalizeProviderConfig( - config: RealtimeVoiceProviderConfig, -): OpenAIRealtimeVoiceProviderConfig { - const raw = resolveOpenAIProviderConfigRecord(config); - return { - apiKey: normalizeResolvedSecretInputString({ - value: raw?.apiKey, - path: "plugins.entries.voice-call.config.realtime.providers.openai.apiKey", - }), - model: normalizeOptionalString(raw?.model), - voice: normalizeOpenAIRealtimeVoice(raw?.speakerVoice ?? raw?.voice), - temperature: asFiniteNumber(raw?.temperature), - vadThreshold: asUnitInterval(raw?.vadThreshold), - silenceDurationMs: asNonNegativeInteger(raw?.silenceDurationMs), - prefixPaddingMs: asNonNegativeInteger(raw?.prefixPaddingMs), - interruptResponseOnInputAudio: - typeof raw?.interruptResponseOnInputAudio === "boolean" - ? raw.interruptResponseOnInputAudio - : undefined, - minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs), - reasoningEffort: normalizeOptionalString(raw?.reasoningEffort), - azureEndpoint: normalizeOptionalString(raw?.azureEndpoint), - azureDeployment: normalizeOptionalString(raw?.azureDeployment), - azureApiVersion: normalizeOptionalString(raw?.azureApiVersion), - }; -} - -function asNonNegativeInteger(value: unknown): number | undefined { - return asSafeIntegerInRange(value, { min: 0 }); -} - -function asUnitInterval(value: unknown): number | undefined { - return asFiniteNumberInRange(value, { min: 0, max: 1 }); -} - -type OpenAIRealtimeApiKeyResolution = - | { status: "available"; value: string } - | { status: "missing" }; - -const OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED = - "OpenAI Realtime voice requires an OpenAI Platform API key"; -const OPENAI_GPT_LIVE_AUTH_REQUIRED = - "GPT-Live Talk requires either an OpenAI Platform API key or a ChatGPT OAuth subscription profile"; -const OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE = - "GPT-Live Talk requires a working OpenAI Platform API key or ChatGPT OAuth subscription profile. The selected Platform API-key source could not be resolved, so OAuth fallback was not used; fix or remove it."; -const OPENAI_REALTIME_API_KEY_REQUIRED = "OpenAI Realtime voice requires an API key"; -const OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED = - "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; -const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; -const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; -const resolvedKeychainSecretRefCache = new Map(); - -function isDirectOpenAIRealtimeWebSocketUrl(value: string): boolean { - try { - return new URL(value).hostname === "api.openai.com"; - } catch { - return false; - } -} - -function isOpenAIRealtimeStartupAuthFailure(error: unknown): boolean { - const record = - typeof error === "object" && error !== null ? (error as Record) : undefined; - const status = record?.status ?? record?.statusCode; - const rawCode = record?.code ?? record?.errorCode; - const code = typeof rawCode === "string" ? rawCode.toLowerCase() : ""; - const message = readRealtimeErrorDetail(error).toLowerCase(); - return ( - status === 401 || - code === "invalid_api_key" || - message.includes("invalid_api_key") || - message.includes("incorrect api key provided") || - message.includes("unexpected server response: 401") - ); -} - -function resolveKeychainSecretRef(value: string): string | undefined { - const trimmed = value.trim(); - const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed); - if (!match) { - return trimmed || undefined; - } - const cached = resolvedKeychainSecretRefCache.get(trimmed); - if (cached) { - return cached; - } - const [, service, account] = match; - if (!service || !account) { - return undefined; - } - try { - const resolved = - execFileSync( - "/usr/bin/security", - ["find-generic-password", "-s", service, "-a", account, "-w"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: KEYCHAIN_LOOKUP_TIMEOUT_MS, - }, - ).trim() || undefined; - if (resolved) { - resolvedKeychainSecretRefCache.set(trimmed, resolved); - } - return resolved; - } catch { - return undefined; - } -} - -function resolveOpenAIRealtimeSecretInput( - configuredApiKey: string | undefined, -): OpenAIRealtimeApiKeyResolution { - const configured = normalizeSecretInputString(configuredApiKey); - if (configured) { - const value = resolveKeychainSecretRef(configured); - return value ? { status: "available", value } : { status: "missing" }; - } - - return { status: "missing" }; -} - -function resolveOpenAIRealtimeEnvApiKey(): OpenAIRealtimeApiKeyResolution { - const envValue = normalizeSecretInputString(process.env.OPENAI_API_KEY); - if (!envValue) { - return { status: "missing" }; - } - const value = resolveKeychainSecretRef(envValue); - return value ? { status: "available", value } : { status: "missing" }; -} - -function resolveOpenAIRealtimeApiKey( - configuredApiKey: string | undefined, -): OpenAIRealtimeApiKeyResolution { - const configured = resolveOpenAIRealtimeSecretInput(configuredApiKey); - if ( - configured.status === "available" || - hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey) - ) { - return configured; - } - return resolveOpenAIRealtimeEnvApiKey(); -} - -function requireOpenAIRealtimeApiKey( - configuredApiKey: string | undefined, - errorMessage = OPENAI_REALTIME_API_KEY_REQUIRED, -): string { - const resolved = resolveOpenAIRealtimeApiKey(configuredApiKey); - if (resolved.status === "available") { - return resolved.value; - } - throw new Error(errorMessage); -} - -function hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey: string | undefined): boolean { - return Boolean(normalizeSecretInputString(configuredApiKey)); -} - -function hasOpenAIRealtimeApiKeyInput(configuredApiKey: string | undefined): boolean { - return Boolean( - normalizeSecretInputString(configuredApiKey) ?? - normalizeSecretInputString(process.env.OPENAI_API_KEY), - ); -} - -function normalizeOpenAIRealtimeTools( - tools: RealtimeVoiceTool[] | undefined, - maxNameLength?: number, -): RealtimeVoiceTool[] | undefined { - const normalized: RealtimeVoiceTool[] = []; - let omitted = 0; - for (const tool of tools ?? []) { - try { - const name = tool.name; - if (typeof name !== "string") { - omitted += 1; - continue; - } - const exceedsLengthLimit = maxNameLength !== undefined && name.length > maxNameLength; - if (exceedsLengthLimit || !OPENAI_REALTIME_TOOL_NAME_RE.test(name)) { - omitted += 1; - continue; - } - normalized.push({ - type: "function", - name, - description: tool.description, - parameters: tool.parameters, - }); - } catch { - omitted += 1; - } - } - if (omitted > 0) { - warn(`openai realtime: omitted ${omitted} tool definition(s) with unsupported names`); - } - return normalized.length > 0 ? normalized : undefined; -} - -function resolveOpenAIRealtimeAudioFormat( - audioFormat: RealtimeVoiceAudioFormat = REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, -): OpenAIRealtimeAudioFormatConfig { - return audioFormat.encoding === "pcm16" - ? { type: "audio/pcm", rate: 24000 } - : { type: "audio/pcmu" }; -} - -function buildOpenAIRealtimeTurnDetectionConfig(params: { - autoRespondToAudio?: boolean; - createResponse?: boolean; - includeInterruptResponse?: boolean; - interruptResponseOnInputAudio?: boolean; - prefixPaddingMs?: number; - silenceDurationMs?: number; - vadThreshold?: number; -}): RealtimeTurnDetectionConfig { - const configuredAutoResponse = params.autoRespondToAudio ?? true; - return { - type: "server_vad", - threshold: params.vadThreshold ?? 0.5, - prefix_padding_ms: params.prefixPaddingMs ?? 300, - silence_duration_ms: params.silenceDurationMs ?? 500, - create_response: params.createResponse ?? configuredAutoResponse, - ...(params.includeInterruptResponse - ? { - interrupt_response: params.interruptResponseOnInputAudio ?? configuredAutoResponse, - } - : {}), - }; -} - -function buildOpenAIRealtimeGaSessionPolicy(params: { - audioFormat?: RealtimeVoiceAudioFormat; - autoRespondToAudio?: boolean; - instructions?: string; - interruptResponseOnInputAudio?: boolean; - language?: string; - model: string; - noiseReduction: { type: "near_field" } | null; - prefixPaddingMs?: number; - reasoningEffort?: string; - silenceDurationMs?: number; - tools?: RealtimeVoiceTool[]; - vadThreshold?: number; - voice: OpenAIRealtimeVoice; -}): RealtimeGaSessionPolicy { - const format = resolveOpenAIRealtimeAudioFormat(params.audioFormat); - return { - type: "realtime", - model: params.model, - ...(params.instructions !== undefined ? { instructions: params.instructions } : {}), - output_modalities: ["audio"], - audio: { - input: { - format, - noise_reduction: params.noiseReduction, - transcription: { - model: OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL, - ...(params.language ? { language: params.language } : {}), - }, - turn_detection: buildOpenAIRealtimeTurnDetectionConfig({ - autoRespondToAudio: params.autoRespondToAudio, - includeInterruptResponse: true, - interruptResponseOnInputAudio: params.interruptResponseOnInputAudio, - prefixPaddingMs: params.prefixPaddingMs, - silenceDurationMs: params.silenceDurationMs, - vadThreshold: params.vadThreshold, - }), - }, - output: { - format, - voice: params.voice, - }, - }, - ...(params.reasoningEffort ? { reasoning: { effort: params.reasoningEffort } } : {}), - ...(params.tools ? { tools: params.tools, tool_choice: "auto" } : {}), - }; -} - -async function resolveOpenAIRealtimePlatformAuth(params: { - configuredApiKey: string | undefined; - cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; -}): Promise { - const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey); - if ( - configured.status === "available" || - hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey) - ) { - return configured; - } - - const profileApiKey = await resolveProviderAuthProfileApiKey({ - provider: "openai", - cfg: params.cfg, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }); - if (profileApiKey) { - return { status: "available", value: profileApiKey }; - } - const hasConfiguredApiKeyProfile = isProviderAuthProfileConfigured({ - provider: "openai", - cfg: params.cfg, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }); - - const envApiKey = resolveOpenAIRealtimeEnvApiKey(); - if (envApiKey.status === "available") { - return envApiKey; - } - if (hasConfiguredApiKeyProfile || hasOpenAIRealtimeApiKeyInput(undefined)) { - return { status: "missing" }; - } - - return { status: "missing" }; -} - -async function requireOpenAIRealtimePlatformAuth(params: { - configuredApiKey: string | undefined; - cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; -}): Promise> { - const resolved = await resolveOpenAIRealtimePlatformAuth(params); - if (resolved.status === "available") { - return resolved; - } - throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); -} - -async function resolveOpenAIQuicksilverBridgeAuth(params: { - configuredApiKey: string | undefined; - cfg: RealtimeVoiceBridgeCreateRequest["cfg"] | undefined; - agentId?: string; -}) { - const subscriptionAuth = await resolveOpenAIChatGptSubscriptionAuth({ - cfg: params.cfg, - agentDir: - params.cfg && params.agentId ? resolveAgentDir(params.cfg, params.agentId) : undefined, - }); - if (subscriptionAuth) { - return subscriptionAuth; - } - const platformAuth = await resolveOpenAIRealtimePlatformAuth(params); - if (platformAuth.status === "available") { - return { type: "api-key" as const, token: platformAuth.value }; - } - if ( - hasOpenAIRealtimePlatformAuthInput({ - configuredApiKey: params.configuredApiKey, - cfg: params.cfg, - }) - ) { - throw new Error(OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE); - } - throw new Error(OPENAI_GPT_LIVE_AUTH_REQUIRED); -} - -function hasOpenAIRealtimePlatformAuthInput(params: { - configuredApiKey: string | undefined; - cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; -}): boolean { - if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) { - return true; - } - if ( - isProviderAuthProfileConfigured({ - provider: "openai", - cfg: params.cfg, - profileTypes: ["api_key"], - includeExternalCliAuth: false, - }) - ) { - return true; - } - return hasOpenAIRealtimeApiKeyInput(undefined); -} - -function hasOpenAIChatGptSubscriptionAuthInput(params: { - cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; - agentId?: string; -}): boolean { - return isProviderAuthProfileConfigured({ - provider: "openai", - cfg: params.cfg, - agentDir: - params.cfg && params.agentId ? resolveAgentDir(params.cfg, params.agentId) : undefined, - profileTypes: ["oauth"], - includeExternalCliAuth: false, - }); -} - -function isOpenAIRealtimeMaxSessionDurationError(detail: string): boolean { - const normalized = detail.toLowerCase(); - return ( - normalized.includes("session") && - normalized.includes(OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT) - ); -} - -function readRealtimeErrorEventId(error: unknown): string | undefined { - if (!error || typeof error !== "object") { - return undefined; - } - const eventId = (error as Record).event_id; - return typeof eventId === "string" ? eventId : undefined; -} - -function parsePlaybackMarkSequence(markName: string): number | undefined { - const match = /^audio-(\d+)$/u.exec(markName); - if (!match) { - return undefined; - } - const sequence = Number(match[1]); - return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined; -} - -class OpenAIRealtimeMalformedAudioError extends Error {} - -function base64ToBuffer(b64: string): Buffer { - const canonicalAudio = canonicalizeBase64(b64); - if (!canonicalAudio) { - throw new OpenAIRealtimeMalformedAudioError( - "OpenAI realtime stream returned malformed base64 audio data", - ); - } - return Buffer.from(canonicalAudio, "base64"); -} - -class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { - private static readonly DEFAULT_MODEL = OPENAI_REALTIME_DEFAULT_MODEL; - private static readonly MAX_RECONNECT_ATTEMPTS = 5; - private static readonly BASE_RECONNECT_DELAY_MS = 1000; - private static readonly CONNECT_TIMEOUT_MS = 10_000; - private static readonly MAX_TOOL_ARGUMENT_BYTES = 256_000; - // Realtime defines no replay window. Keep every terminal id for this - // connection generation, then fail instead of re-admitting late duplicates. - private static readonly MAX_COMPLETED_TOOL_CALL_IDS = 1_024; - readonly supportsToolResultContinuation = true; - readonly supportsToolResultSuppression = true; - - private ws: WebSocket | null = null; - private readonly lifecycle = new RealtimeVoiceSessionLifecycle("OpenAI"); - private nextMarkSequence = 1; - private oldestOutstandingMarkSequence: number | null = null; - private latestOutstandingMarkSequence: number | null = null; - private responseStartTimestamp: number | null = null; - private responseActive = false; - private responseCreateInFlight = false; - private manualResponseCreateEventId: string | null = null; - private responseCancelInFlight = false; - private manualResponseCancelEventId: string | null = null; - private responseCreatePending = false; - private autoRespondSuppressedForManualResponse = false; - private continuingToolCallIds = new Set(); - private pendingToolCallIds = new Set(); - private latestMediaTimestamp = 0; - private lastAssistantItemId: string | null = null; - private connectionUrl = ""; - private completedToolCallIds = new Set(); - private standaloneSpeechQueue: string[] = []; - private standaloneSpeechActive = false; - private standaloneSpeechEventId: string | null = null; - private readonly flowId = randomUUID(); - private sessionReadyFired = false; - private reconnectReason: string | undefined; - private activeConnectionReason: string | undefined; - private terminalError: Error | undefined; - private readonly audioFormat: RealtimeVoiceAudioFormat; - - constructor(private readonly config: OpenAIRealtimeVoiceBridgeConfig) { - this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ; - } - - async connect(): Promise { - if (this.terminalError) { - throw this.terminalError; - } - await this.lifecycle.connect((connection) => this.doConnect(connection)); - } - - sendAudio(audio: Buffer): void { - if (this.lifecycle.phase() === "terminal") { - return; - } - if (!this.lifecycle.isReady() || this.ws?.readyState !== WebSocket.OPEN) { - this.lifecycle.enqueuePendingAudio(audio); - return; - } - this.sendEvent({ - type: "input_audio_buffer.append", - audio: audio.toString("base64"), - }); - } - - setMediaTimestamp(ts: number): void { - this.latestMediaTimestamp = ts; - } - - sendUserMessage(text: string, options?: OpenAIRealtimeUserMessageOptions): void { - if ( - options?.toolChoice && - (this.responseActive || - this.responseCreateInFlight || - this.responseCancelInFlight || - this.pendingToolCallIds.size > 0) - ) { - throw new Error("Forced realtime tool choice requires an idle response state"); - } - if (this.pendingToolCallIds.size > 0) { - // Control/status speech must not wait behind the long-running consult whose - // function output owns the default conversation response. - this.standaloneSpeechQueue.push(text); - this.flushStandaloneSpeech(); - return; - } - this.sendEvent({ - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [{ type: "input_text", text }], - }, - }); - this.requestResponseCreate(options); - } - - triggerGreeting(instructions?: string): void { - if (!this.isConnected() || !this.ws) { - return; - } - this.sendUserMessage(instructions ?? this.config.instructions ?? "Greet the meeting."); - } - - submitToolResult( - callId: string, - result: unknown, - options?: RealtimeVoiceToolResultOptions, - ): void { - if (this.lifecycle.phase() === "terminal" || !this.pendingToolCallIds.has(callId)) { - return; - } - const output = JSON.stringify(result); - if (typeof output !== "string") { - throw new Error("OpenAI realtime voice tool result is not JSON-serializable"); - } - this.sendEvent({ - type: "conversation.item.create", - item: { - type: "function_call_output", - call_id: callId, - output, - }, - }); - if (options?.willContinue === true) { - this.continuingToolCallIds.add(callId); - return; - } - this.continuingToolCallIds.delete(callId); - this.pendingToolCallIds.delete(callId); - if (options?.suppressResponse === true) { - this.flushPendingResponseCreate(); - return; - } - this.requestResponseCreate(); - } - - acknowledgeMark(markName?: string): void { - const oldest = this.oldestOutstandingMarkSequence; - const latest = this.latestOutstandingMarkSequence; - if (oldest === null || latest === null) { - return; - } - const acknowledgedSequence = - markName === undefined ? oldest : parsePlaybackMarkSequence(markName); - if ( - acknowledgedSequence === undefined || - acknowledgedSequence < oldest || - acknowledgedSequence > latest - ) { - return; - } - // Marks follow ordered playback. Reaching a named mark also acknowledges every - // earlier mark, while late acknowledgements from that prefix remain harmless. - if (acknowledgedSequence === latest) { - this.oldestOutstandingMarkSequence = null; - this.latestOutstandingMarkSequence = null; - return; - } - this.oldestOutstandingMarkSequence = acknowledgedSequence + 1; - } - - close(): void { - const connection = this.lifecycle.currentConnection(); - if (!this.lifecycle.cancel()) { - return; - } - this.resetTerminalState(); - if (!connection) { - return; - } - const ws = this.ws; - this.ws = null; - ws?.close(1000, "Bridge closed"); - this.notifyClose(connection, "completed"); - } - - isConnected(): boolean { - return this.lifecycle.isReady() && this.ws?.readyState === WebSocket.OPEN; - } - - private async doConnect(lifecycleConnection: RealtimeVoiceSessionConnection): Promise { - let activeWs: WebSocket | undefined; - let startupFrameBytes = 0; - const attempt = this.lifecycle.createConnectAttempt({ - connection: lifecycleConnection, - timeoutMs: OpenAIRealtimeVoiceBridge.CONNECT_TIMEOUT_MS, - timeoutError: () => new Error("OpenAI realtime connection timeout"), - onTimeout: () => activeWs?.terminate(), - onAbort: () => { - if (activeWs && activeWs.readyState !== WebSocket.CLOSED) { - activeWs.close(1000, "connection canceled"); - } - }, - }); - - const openWebSocket = (resolvedConnection: { - url: string; - headers: Record; - }) => { - if (attempt.settled) { - return; - } - if (!this.lifecycle.isCurrent(lifecycleConnection) || lifecycleConnection.signal.aborted) { - attempt.resolve(); - return; - } - // Auth preparation owns its own timeout. Start the socket deadline only - // after connection parameters are available. - attempt.startTimeout(); - const url = resolvedConnection.url; - this.connectionUrl = resolvedConnection.url; - const debugProxy = resolveDebugProxySettings(); - const proxyAgent = createDebugProxyWebSocketAgent(debugProxy); - const ws = new WebSocket(resolvedConnection.url, { - headers: resolvedConnection.headers, - maxPayload: OPENAI_VOICE_WS_MAX_PAYLOAD_BYTES, - ...(proxyAgent ? { agent: proxyAgent } : {}), - }); - activeWs = ws; - this.ws = ws; - - const rejectStartup = (error: Error) => { - if (!attempt.rejectStartup(error)) { - return; - } - if (ws.readyState !== WebSocket.CLOSED) { - ws.close(1000, "startup failed"); - } - }; - - ws.on("open", () => { - if (!this.lifecycle.acceptsEvents(lifecycleConnection)) { - ws.close(1000, "stale connection"); - return; - } - this.resetRealtimeSessionState(); - captureWsEvent({ - url, - direction: "local", - kind: "ws-open", - flowId: this.flowId, - meta: { - provider: "openai", - capability: "realtime-voice", - }, - }); - this.sendSessionUpdate(); - }); - - ws.on("message", (data: Buffer) => { - if (!this.lifecycle.acceptsEvents(lifecycleConnection) || this.ws !== ws) { - return; - } - if (attempt.settled && !attempt.ready) { - return; - } - if (!attempt.ready) { - startupFrameBytes += data.byteLength; - if (startupFrameBytes > OPENAI_REALTIME_SIDEBAND_STARTUP_MAX_BYTES) { - const error = new Error("OpenAI realtime sideband startup buffer exceeded"); - attempt.reject(error); - this.failConnection(error, ws, lifecycleConnection, { - code: 1009, - reason: "Sideband startup buffer exceeded", - }); - return; - } - } - captureWsEvent({ - url, - direction: "inbound", - kind: "ws-frame", - flowId: this.flowId, - payload: data, - meta: { - provider: "openai", - capability: "realtime-voice", - }, - }); - try { - const event = JSON.parse(data.toString()) as RealtimeEvent; - if (event.type === "error" && !attempt.ready) { - // Only direct OpenAI auth failures get bounded remediation. Azure, - // custom endpoints, and non-auth startup details remain provider-owned. - rejectStartup( - isDirectOpenAIRealtimeWebSocketUrl(url) && - isOpenAIRealtimeStartupAuthFailure(event.error) - ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) - : new Error(readRealtimeErrorDetail(event.error)), - ); - return; - } - if (event.type === "session.updated") { - try { - this.handleEvent(event, lifecycleConnection); - } catch (error) { - const readyError = toStringifiedError(error); - attempt.reject(readyError); - this.failConnection(readyError, ws, lifecycleConnection, { - code: 1011, - reason: "Readiness callback failed", - }); - return; - } - attempt.resolve(this.lifecycle.isReady()); - return; - } - this.handleEvent(event, lifecycleConnection); - } catch (error) { - if (error instanceof OpenAIRealtimeMalformedAudioError) { - attempt.reject(error); - this.failConnection(error, ws, lifecycleConnection, { - code: 1002, - reason: "Malformed audio payload", - }); - return; - } - console.error("[openai] realtime event parse failed:", error); - } - }); - - ws.on("error", (error) => { - if (!this.lifecycle.acceptsEvents(lifecycleConnection) || this.ws !== ws) { - return; - } - captureWsEvent({ - url, - direction: "local", - kind: "error", - flowId: this.flowId, - errorText: error instanceof Error ? error.message : String(error), - meta: { - provider: "openai", - capability: "realtime-voice", - }, - }); - if (!attempt.ready) { - const startupError = toStringifiedError(error); - rejectStartup( - isDirectOpenAIRealtimeWebSocketUrl(url) && - isOpenAIRealtimeStartupAuthFailure(startupError) - ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) - : startupError, - ); - return; - } - this.config.onError?.(toStringifiedError(error)); - }); - - ws.on("close", (code, reasonBuffer) => { - captureOpenAIRealtimeWsClose({ - url, - flowId: this.flowId, - capability: "realtime-voice", - code, - reasonBuffer, - }); - if (!this.lifecycle.isCurrent(lifecycleConnection)) { - return; - } - if (this.ws === ws) { - this.ws = null; - } - if (attempt.startupFailed) { - return; - } - if (this.terminalError) { - this.notifyClose(lifecycleConnection, "error"); - return; - } - if (this.lifecycle.terminalOutcome(lifecycleConnection) === "completed") { - attempt.resolve(); - this.notifyClose(lifecycleConnection, "completed"); - return; - } - if (!attempt.ready && !attempt.settled) { - const error = new Error("OpenAI realtime connection closed before ready"); - attempt.reject(error); - return; - } - const reason = this.reconnectReason ?? "websocket-close"; - this.reconnectReason = undefined; - void this.attemptReconnect(reason, lifecycleConnection); - }); - }; - - let connectionOrPromise: - | { url: string; headers: Record } - | Promise<{ url: string; headers: Record }>; - try { - connectionOrPromise = this.resolveConnectionParams(); - } catch (error) { - attempt.reject(toStringifiedError(error)); - return attempt.promise; - } - if (connectionOrPromise instanceof Promise) { - void connectionOrPromise.then(openWebSocket).catch((error: unknown) => { - if ( - !this.lifecycle.isCurrent(lifecycleConnection) || - this.lifecycle.terminalOutcome(lifecycleConnection) === "completed" - ) { - attempt.resolve(); - return; - } - attempt.reject(toStringifiedError(error)); - }); - } else { - try { - openWebSocket(connectionOrPromise); - } catch (error) { - attempt.reject(toStringifiedError(error)); - } - } - await attempt.promise; - } - - private resolveConnectionParams(): - | { url: string; headers: Record } - | Promise<{ url: string; headers: Record }> { - const cfg = this.config; - const model = cfg.model ?? OpenAIRealtimeVoiceBridge.DEFAULT_MODEL; - if (cfg.azureEndpoint && cfg.azureDeployment) { - const apiKey = requireOpenAIRealtimeApiKey(cfg.apiKey); - const base = cfg.azureEndpoint - .replace(/\/$/, "") - .replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`); - const apiVersion = cfg.azureApiVersion ?? "2024-10-01-preview"; - const url = `${base}/openai/realtime?api-version=${apiVersion}&deployment=${encodeURIComponent( - cfg.azureDeployment, - )}`; - return { - url, - headers: resolveProviderRequestHeaders({ - provider: "openai", - baseUrl: url, - capability: "audio", - transport: "websocket", - defaultHeaders: { "api-key": apiKey }, - }) ?? { "api-key": apiKey }, - }; - } - - if (hasOpenAIRealtimeConfiguredApiKeyInput(cfg.apiKey)) { - const directApiKey = resolveOpenAIRealtimeSecretInput(cfg.apiKey); - if (directApiKey.status === "missing") { - throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); - } - return this.resolveApiKeyConnectionParams(directApiKey.value, model); - } - - if (cfg.azureEndpoint) { - const directApiKey = resolveOpenAIRealtimeEnvApiKey(); - if (directApiKey.status === "missing") { - throw new Error(OPENAI_REALTIME_API_KEY_REQUIRED); - } - return this.resolveApiKeyConnectionParams(directApiKey.value, model); - } - - return this.resolveDefaultConnectionParams(model); - } - - private async resolveDefaultConnectionParams(model: string): Promise<{ - url: string; - headers: Record; - }> { - const auth = await requireOpenAIRealtimePlatformAuth({ - configuredApiKey: this.config.apiKey, - cfg: this.config.cfg, - }); - return this.resolveApiKeyConnectionParams(auth.value, model); - } - - private resolveApiKeyConnectionParams( - apiKey: string, - model: string, - ): { url: string; headers: Record } { - const cfg = this.config; - if (cfg.azureEndpoint) { - const base = cfg.azureEndpoint - .replace(/\/$/, "") - .replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`); - const url = `${base}/v1/realtime?model=${encodeURIComponent(model)}`; - return { - url, - headers: resolveProviderRequestHeaders({ - provider: "openai", - baseUrl: url, - capability: "audio", - transport: "websocket", - defaultHeaders: { Authorization: `Bearer ${apiKey}` }, - }) ?? { Authorization: `Bearer ${apiKey}` }, - }; - } - - const url = cfg.callId - ? buildOpenAIRealtimeSidebandUrl(cfg.callId) - : `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`; - return { - url, - headers: resolveProviderRequestHeaders({ - provider: "openai", - baseUrl: url, - capability: "audio", - transport: "websocket", - defaultHeaders: { - Authorization: `Bearer ${apiKey}`, - }, - }) ?? { - Authorization: `Bearer ${apiKey}`, - }, - }; - } - - private async attemptReconnect( - reason: string, - connection: RealtimeVoiceSessionConnection, - ): Promise { - const retry = this.lifecycle.retry( - connection, - OpenAIRealtimeVoiceBridge.MAX_RECONNECT_ATTEMPTS, - ); - if (!retry) { - return; - } - if (retry === "exhausted") { - this.config.onEvent?.({ - direction: "client", - type: "session.reconnect.exhausted", - detail: `reason=${reason} attempts=${OpenAIRealtimeVoiceBridge.MAX_RECONNECT_ATTEMPTS}`, - }); - if (this.lifecycle.failure(connection)) { - this.resetTerminalState(); - } - this.notifyClose(connection, "error"); - return; - } - const attempt = retry.attempt; - const delay = OpenAIRealtimeVoiceBridge.BASE_RECONNECT_DELAY_MS * 2 ** (attempt - 1); - if (attempt === 1) { - // OpenAI reconnects start a fresh provider generation. Reset consumers - // before backoff so stale async work cannot satisfy reused call ids. - this.resetRealtimeSessionState(); - this.config.onEvent?.({ - direction: "client", - type: "session.continuity.reset", - }); - } - this.config.onEvent?.({ - direction: "client", - type: "session.reconnect.scheduled", - detail: `reason=${reason} attempt=${attempt} delayMs=${delay}`, - }); - try { - await sleepWithAbort(delay, retry.signal); - } catch (error) { - if (!retry.signal.aborted) { - throw error; - } - return; - } - const nextConnection = this.lifecycle.reconnect(connection); - if (!nextConnection) { - return; - } - try { - await this.doConnect(nextConnection); - if (!this.lifecycle.isCurrent(nextConnection) || !this.lifecycle.isReady()) { - return; - } - this.config.onEvent?.({ - direction: "client", - type: "session.reconnect.ready", - detail: `reason=${reason} attempt=${attempt}`, - }); - } catch (error) { - if (!this.lifecycle.acceptsEvents(nextConnection)) { - return; - } - this.config.onError?.(toStringifiedError(error)); - await this.attemptReconnect(reason, nextConnection); - } - } - - private sendSessionUpdate(): void { - if (this.usesAzureDeploymentRealtimeApi()) { - this.sendEvent(this.buildAzureDeploymentSessionUpdate()); - return; - } - - this.sendEvent(this.buildGaSessionUpdate()); - } - - private buildGaSessionUpdate(): RealtimeGaSessionUpdate { - const cfg = this.config; - return { - type: "session.update", - session: - cfg.gaSessionPolicy ?? - buildOpenAIRealtimeGaSessionPolicy({ - audioFormat: this.audioFormat, - autoRespondToAudio: cfg.autoRespondToAudio, - instructions: cfg.instructions, - interruptResponseOnInputAudio: cfg.interruptResponseOnInputAudio, - language: cfg.language, - model: cfg.model ?? OpenAIRealtimeVoiceBridge.DEFAULT_MODEL, - noiseReduction: null, - prefixPaddingMs: cfg.prefixPaddingMs, - reasoningEffort: cfg.reasoningEffort, - silenceDurationMs: cfg.silenceDurationMs, - tools: normalizeOpenAIRealtimeTools(cfg.tools), - vadThreshold: cfg.vadThreshold, - voice: cfg.voice ?? "alloy", - }), - }; - } - - private usesAzureDeploymentRealtimeApi(): boolean { - return Boolean(this.config.azureEndpoint && this.config.azureDeployment); - } - - private buildAzureDeploymentSessionUpdate(): RealtimeAzureDeploymentSessionUpdate { - const cfg = this.config; - const format = this.resolveLegacyRealtimeAudioFormat(); - const tools = normalizeOpenAIRealtimeTools( - cfg.tools, - AZURE_OPENAI_REALTIME_TOOL_NAME_MAX_LENGTH, - ); - return { - type: "session.update", - session: { - modalities: ["text", "audio"], - instructions: cfg.instructions, - voice: cfg.voice ?? "alloy", - input_audio_format: format, - output_audio_format: format, - input_audio_transcription: { - model: "whisper-1", - ...(cfg.language ? { language: cfg.language } : {}), - }, - turn_detection: this.buildTurnDetectionConfig(), - temperature: cfg.temperature ?? 0.8, - ...(tools - ? { - tools, - tool_choice: "auto", - } - : {}), - }, - }; - } - - private buildTurnDetectionConfig(options?: { - createResponse?: boolean; - includeInterruptResponse?: boolean; - }): RealtimeTurnDetectionConfig { - return buildOpenAIRealtimeTurnDetectionConfig({ - autoRespondToAudio: this.config.autoRespondToAudio, - createResponse: options?.createResponse, - includeInterruptResponse: options?.includeInterruptResponse, - interruptResponseOnInputAudio: this.config.interruptResponseOnInputAudio, - prefixPaddingMs: this.config.prefixPaddingMs, - silenceDurationMs: this.config.silenceDurationMs, - vadThreshold: this.config.vadThreshold, - }); - } - - private sendAutoResponseSessionUpdate(createResponse: boolean): void { - const azureDeployment = this.usesAzureDeploymentRealtimeApi(); - const turnDetection = this.buildTurnDetectionConfig({ - createResponse, - includeInterruptResponse: !azureDeployment, - }); - if (azureDeployment) { - this.sendEvent({ type: "session.update", session: { turn_detection: turnDetection } }); - return; - } - this.sendEvent({ - type: "session.update", - session: { type: "realtime", audio: { input: { turn_detection: turnDetection } } }, - }); - } - - private resolveLegacyRealtimeAudioFormat(): "g711_ulaw" | "pcm16" { - return this.audioFormat.encoding === "pcm16" ? "pcm16" : "g711_ulaw"; - } - - private markSessionReady(connection: RealtimeVoiceSessionConnection): void { - if (!this.lifecycle.ready(connection)) { - return; - } - if (this.activeConnectionReason) { - this.config.onEvent?.({ - direction: "server", - type: "session.rotation.ready", - detail: `reason=${this.activeConnectionReason}`, - }); - this.activeConnectionReason = undefined; - } - if (!this.sessionReadyFired) { - this.sessionReadyFired = true; - this.config.onReady?.(); - } - for (const chunk of this.lifecycle.drainPendingAudio()) { - this.sendAudio(chunk); - } - } - - private handleEvent(event: RealtimeEvent, connection: RealtimeVoiceSessionConnection): void { - const emitServerEvent = () => - this.config.onEvent?.({ - direction: "server", - type: event.type, - detail: this.describeServerEvent(event), - ...(event.item_id ? { itemId: event.item_id } : {}), - ...((event.response_id ?? event.response?.id) - ? { responseId: event.response_id ?? event.response?.id } - : {}), - }); - if ( - event.type === "error" && - isOpenAIRealtimeMaxSessionDurationError(readRealtimeErrorDetail(event.error)) - ) { - this.reconnectReason = "max-duration"; - this.activeConnectionReason = "max-duration"; - this.config.onEvent?.({ - direction: "server", - type: "session.rotation", - detail: "reason=max-duration", - }); - this.ws?.close(1000, "max-duration rotation"); - return; - } - if (event.type === "response.done") { - this.handleResponseDone(event, connection, emitServerEvent); - return; - } - if (event.type === "response.cancelled") { - try { - emitServerEvent(); - } finally { - this.releaseResponseState(); - } - return; - } - emitServerEvent(); - switch (event.type) { - case "session.created": - return; - - case "session.updated": { - this.markSessionReady(connection); - return; - } - - case "response.created": - this.responseActive = true; - this.responseCreateInFlight = false; - return; - - case "conversation.output_audio.delta": - case "response.audio.delta": - case "response.output_audio.delta": { - const audioDelta = event.delta ?? event.data; - if (!audioDelta) { - return; - } - const audio = base64ToBuffer(audioDelta); - this.config.onAudio(audio); - if (event.item_id && event.item_id !== this.lastAssistantItemId) { - this.lastAssistantItemId = event.item_id; - this.responseStartTimestamp = this.latestMediaTimestamp; - } else if (this.responseStartTimestamp === null) { - this.responseStartTimestamp = this.latestMediaTimestamp; - } - this.responseActive = true; - this.sendMark(); - return; - } - - case "input_audio_buffer.speech_started": - if (this.config.interruptResponseOnInputAudio ?? this.config.autoRespondToAudio ?? true) { - this.handleBargeIn(); - } - return; - - case "conversation.output_transcript.delta": - case "response.text.delta": - case "response.output_text.delta": - case "response.audio_transcript.delta": - case "response.output_audio_transcript.delta": - if (event.delta) { - this.config.onTranscript?.("assistant", event.delta, false); - } - return; - - case "response.text.done": - case "response.output_text.done": - case "response.audio_transcript.done": - case "response.output_audio_transcript.done": - { - const transcript = event.transcript ?? event.text; - if (transcript) { - this.config.onTranscript?.("assistant", transcript, true); - } - } - return; - - case "conversation.input_transcript.delta": - case "conversation.item.input_audio_transcription.delta": - if (event.delta) { - this.config.onTranscript?.("user", event.delta, false); - } - return; - - case "conversation.item.input_audio_transcription.completed": - if (event.transcript) { - this.config.onTranscript?.("user", event.transcript, true); - } - return; - - case "conversation.item.input_audio_transcription.failed": - this.config.onError?.(new Error(readRealtimeErrorDetail(event.error))); - break; - - case "conversation.item.added": - break; - - case "response.function_call_arguments.delta": - case "response.function_call_arguments.done": - case "conversation.item.done": - // These events are provisional and can also arrive for interrupted, - // incomplete, or cancelled responses. Successful response.done output - // is the sole execution boundary. - return; - - case "error": { - const detail = readRealtimeErrorDetail(event.error); - const rejectedEventId = readRealtimeErrorEventId(event.error); - if (rejectedEventId && rejectedEventId === this.standaloneSpeechEventId) { - this.responseCreateInFlight = false; - this.standaloneSpeechActive = false; - this.standaloneSpeechEventId = null; - this.config.onError?.(new Error(detail)); - if (this.standaloneSpeechQueue.length > 0) { - this.flushStandaloneSpeech(); - } else if (this.responseCreatePending) { - this.flushPendingResponseCreate(); - } - return; - } - const rejectsManualResponseCreate = - this.manualResponseCreateEventId !== null && - readRealtimeErrorEventId(event.error) === this.manualResponseCreateEventId; - if ( - rejectsManualResponseCreate && - detail.startsWith(OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX) - ) { - this.responseActive = true; - this.responseCreateInFlight = false; - this.manualResponseCreateEventId = null; - this.responseCreatePending = true; - return; - } - const rejectsManualResponseCancel = - this.manualResponseCancelEventId !== null && - readRealtimeErrorEventId(event.error) === this.manualResponseCancelEventId; - if (detail === OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR) { - if (!rejectsManualResponseCancel) { - return; - } - this.responseActive = false; - this.responseCancelInFlight = false; - this.manualResponseCancelEventId = null; - if (this.responseCreatePending) { - this.flushPendingResponseCreate(); - } else { - this.restoreAutoRespondAfterManualResponse(); - } - return; - } - if (rejectsManualResponseCreate) { - this.responseCreateInFlight = false; - this.manualResponseCreateEventId = null; - if (this.responseCreatePending) { - this.flushPendingResponseCreate(); - } else { - this.restoreAutoRespondAfterManualResponse(); - } - } - this.config.onError?.(new Error(detail)); - } - - default: - } - } - - private releaseResponseState(options: { drain?: boolean } = {}): void { - this.responseActive = false; - this.responseCreateInFlight = false; - this.manualResponseCreateEventId = null; - this.responseCancelInFlight = false; - this.manualResponseCancelEventId = null; - if (this.standaloneSpeechActive) { - this.standaloneSpeechActive = false; - this.standaloneSpeechEventId = null; - } - if (options.drain === false) { - return; - } - if (this.standaloneSpeechQueue.length > 0) { - this.flushStandaloneSpeech(); - } else if (this.responseCreatePending) { - this.flushPendingResponseCreate(); - } else { - this.restoreAutoRespondAfterManualResponse(); - } - } - - handleBargeIn(options?: RealtimeVoiceBargeInOptions): void { - const assistantItemId = this.lastAssistantItemId; - const responseStartTimestamp = this.responseStartTimestamp; - const force = options?.force === true; - const shouldInterruptProvider = - assistantItemId !== null && - ((responseStartTimestamp !== null && - (this.oldestOutstandingMarkSequence !== null || options?.audioPlaybackActive === true)) || - force); - const audioEndMs = shouldInterruptProvider - ? Math.max( - 0, - responseStartTimestamp === null - ? this.latestMediaTimestamp - : this.latestMediaTimestamp - responseStartTimestamp, - ) - : null; - const minBargeInAudioEndMs = - this.config.minBargeInAudioEndMs ?? OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS; - if (!force && audioEndMs !== null && audioEndMs < minBargeInAudioEndMs) { - this.config.onEvent?.({ - direction: "client", - type: "conversation.item.truncate.skipped", - detail: `reason=barge-in audioEndMs=${audioEndMs} minAudioEndMs=${minBargeInAudioEndMs}`, - }); - return; - } - if ( - options?.audioPlaybackActive === true && - this.responseActive && - !this.responseCancelInFlight - ) { - const eventId = `openclaw-response-cancel-${randomUUID()}`; - this.manualResponseCancelEventId = eventId; - this.sendEvent({ type: "response.cancel", event_id: eventId }, "reason=barge-in"); - this.responseCancelInFlight = true; - } - if (shouldInterruptProvider) { - this.sendEvent( - { - type: "conversation.item.truncate", - item_id: assistantItemId, - content_index: 0, - audio_end_ms: audioEndMs, - }, - `reason=barge-in audioEndMs=${audioEndMs}`, - ); - this.config.onClearAudio("barge-in"); - this.clearOutstandingMarks(); - this.lastAssistantItemId = null; - this.responseStartTimestamp = null; - return; - } - this.config.onClearAudio("barge-in"); - } - - private handleCompletedResponse( - event: RealtimeEvent, - connection: RealtimeVoiceSessionConnection, - ): boolean { - if ( - event.type !== "response.done" || - event.response?.status !== "completed" || - !Array.isArray(event.response.output) || - !this.config.onToolCall - ) { - return false; - } - for (const output of event.response.output) { - if (!this.lifecycle.acceptsEvents(connection) || this.ws?.readyState !== WebSocket.OPEN) { - return true; - } - if ( - !isRecord(output) || - output.type !== "function_call" || - (output.status !== undefined && output.status !== "completed") - ) { - continue; - } - const itemId = typeof output.id === "string" ? output.id.trim() || undefined : undefined; - const callId = typeof output.call_id === "string" ? output.call_id.trim() : ""; - const name = typeof output.name === "string" ? output.name.trim() : ""; - if (!callId || !name || this.completedToolCallIds.has(callId)) { - continue; - } - if (this.completedToolCallIds.size >= OpenAIRealtimeVoiceBridge.MAX_COMPLETED_TOOL_CALL_IDS) { - const ws = this.ws; - if (ws) { - this.failConnection( - new Error( - `OpenAI realtime tool-call session limit exceeded (${OpenAIRealtimeVoiceBridge.MAX_COMPLETED_TOOL_CALL_IDS})`, - ), - ws, - connection, - { code: 1008, reason: "Tool-call session limit exceeded" }, - ); - } - return true; - } - this.completedToolCallIds.add(callId); - this.pendingToolCallIds.add(callId); - if (typeof output.arguments !== "string") { - this.rejectToolCallArguments({ - itemId, - callId, - reason: "invalid-json-type", - message: "Invalid tool arguments: expected a JSON object.", - }); - continue; - } - const rawArgs = output.arguments; - if (Buffer.byteLength(rawArgs, "utf8") > OpenAIRealtimeVoiceBridge.MAX_TOOL_ARGUMENT_BYTES) { - this.rejectToolCallArguments({ - itemId, - callId, - reason: "too-large", - message: `Realtime tool arguments exceed the ${OpenAIRealtimeVoiceBridge.MAX_TOOL_ARGUMENT_BYTES}-byte UTF-8 limit`, - }); - continue; - } - let args: unknown; - try { - args = JSON.parse(rawArgs || "{}"); - } catch { - this.rejectToolCallArguments({ - itemId, - callId, - reason: "malformed-json", - message: "Invalid tool arguments: expected a JSON object.", - }); - continue; - } - if (!isRecord(args)) { - this.rejectToolCallArguments({ - itemId, - callId, - reason: "non-object-json", - message: "Invalid tool arguments: expected a JSON object.", - }); - continue; - } - this.config.onToolCall({ itemId: itemId ?? callId, callId, name, args }); - } - return false; - } - - private handleResponseDone( - event: RealtimeEvent, - connection: RealtimeVoiceSessionConnection, - emitServerEvent: () => void, - ): void { - const outcome = normalizeRealtimeVoiceResponseOutcome({ - providerLabel: "OpenAI realtime voice", - response: event.response, - responseId: event.response_id, - }); - let callbackError: unknown; - let providerTerminated = false; - const invoke = (callback: () => void) => { - try { - callback(); - } catch (error) { - callbackError ??= error; - } - }; - try { - invoke(() => this.config.onResponseDone?.(outcome)); - invoke(emitServerEvent); - invoke(() => { - providerTerminated = this.handleCompletedResponse(event, connection); - }); - } finally { - // response.done owns response state regardless of observer success. A fatal tool - // boundary still clears state, but must not start queued work on a closing socket. - const canDrain = - !providerTerminated && - this.lifecycle.acceptsEvents(connection) && - this.ws?.readyState === WebSocket.OPEN; - this.releaseResponseState({ drain: canDrain }); - } - if (callbackError) { - throw callbackError instanceof Error - ? callbackError - : new Error("OpenAI realtime response callback failed", { cause: callbackError }); - } - } - - private rejectToolCallArguments(params: { - itemId?: string; - callId: string; - reason: string; - message: string; - }): void { - this.config.onEvent?.({ - direction: "server", - type: "tool_call.arguments.rejected", - detail: `reason=${params.reason}`, - itemId: params.itemId, - }); - this.submitToolResult(params.callId, { error: params.message }); - } - - private requestResponseCreate(options?: OpenAIRealtimeUserMessageOptions): void { - if ( - this.responseActive || - this.responseCreateInFlight || - this.responseCancelInFlight || - this.continuingToolCallIds.size > 0 || - this.pendingToolCallIds.size > 0 - ) { - this.responseCreatePending = true; - return; - } - this.responseCreatePending = false; - this.responseCreateInFlight = true; - this.suppressAutoRespondForManualResponse(); - const eventId = `openclaw-response-create-${randomUUID()}`; - // Realtime errors can describe unrelated client events. Keep this id until - // the manual turn settles so only its rejection may release VAD suppression. - this.manualResponseCreateEventId = eventId; - this.sendEvent({ - type: "response.create", - event_id: eventId, - ...(options?.toolChoice - ? { response: { output_modalities: ["audio"], tool_choice: options.toolChoice } } - : {}), - }); - } - - private flushStandaloneSpeech(): void { - if ( - this.standaloneSpeechActive || - this.responseActive || - this.responseCreateInFlight || - this.responseCancelInFlight - ) { - return; - } - const text = this.standaloneSpeechQueue.shift(); - if (!text) { - return; - } - const eventId = `openclaw-standalone-speech-${randomUUID()}`; - this.standaloneSpeechActive = true; - this.standaloneSpeechEventId = eventId; - this.responseCreateInFlight = true; - this.sendEvent({ - type: "response.create", - event_id: eventId, - response: { - conversation: "none", - output_modalities: ["audio"], - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text }], - }, - ], - }, - }); - } - - private suppressAutoRespondForManualResponse(): void { - if (this.config.autoRespondToAudio === false || this.autoRespondSuppressedForManualResponse) { - return; - } - // Manual response.create owns this turn. Keep VAD events and interruption active, - // but prevent a second server-owned response until all queued manual work finishes. - this.autoRespondSuppressedForManualResponse = true; - this.sendAutoResponseSessionUpdate(false); - } - - private restoreAutoRespondAfterManualResponse(): void { - if (!this.autoRespondSuppressedForManualResponse) { - return; - } - this.autoRespondSuppressedForManualResponse = false; - this.sendAutoResponseSessionUpdate(true); - } - - private flushPendingResponseCreate(): void { - if (!this.responseCreatePending) { - return; - } - this.responseCreatePending = false; - this.requestResponseCreate(); - } - - private resetRealtimeSessionState(): void { - this.clearOutstandingMarks(); - this.responseStartTimestamp = null; - this.responseActive = false; - this.responseCreateInFlight = false; - this.manualResponseCreateEventId = null; - this.responseCancelInFlight = false; - this.manualResponseCancelEventId = null; - this.responseCreatePending = false; - this.autoRespondSuppressedForManualResponse = false; - this.continuingToolCallIds.clear(); - this.pendingToolCallIds.clear(); - this.lastAssistantItemId = null; - this.completedToolCallIds.clear(); - this.standaloneSpeechQueue = []; - this.standaloneSpeechActive = false; - this.standaloneSpeechEventId = null; - } - - private resetTerminalState(): void { - // Transport retries preserve readiness and rotation attribution. A terminal - // session clears both so explicit bridge reuse starts as a new session. - this.sessionReadyFired = false; - this.reconnectReason = undefined; - this.activeConnectionReason = undefined; - this.resetRealtimeSessionState(); - } - - private failConnection( - error: Error, - ws: WebSocket, - connection: RealtimeVoiceSessionConnection, - close: { code: number; reason: string }, - ): void { - if (this.terminalError) { - return; - } - this.terminalError = error; - this.lifecycle.failure(connection); - this.resetTerminalState(); - try { - this.config.onError?.(error); - } finally { - if (ws.readyState !== WebSocket.CLOSED) { - ws.close(close.code, close.reason); - } else { - this.notifyClose(connection, "error"); - } - } - } - - private notifyClose( - connection: RealtimeVoiceSessionConnection, - outcome: "completed" | "error", - ): void { - const terminalOutcome = this.lifecycle.close(connection, outcome); - if (!terminalOutcome) { - return; - } - this.resetTerminalState(); - this.config.onClose?.(terminalOutcome); - } - - private sendMark(): void { - const sequence = this.nextMarkSequence; - this.nextMarkSequence += 1; - if (this.oldestOutstandingMarkSequence === null) { - this.oldestOutstandingMarkSequence = sequence; - } - this.latestOutstandingMarkSequence = sequence; - const markName = `audio-${sequence}`; - this.config.onMark?.(markName); - } - - private clearOutstandingMarks(): void { - this.oldestOutstandingMarkSequence = null; - this.latestOutstandingMarkSequence = null; - } - - private sendEvent(event: unknown, detail?: string): void { - if (this.ws?.readyState === WebSocket.OPEN) { - const type = - event && typeof event === "object" && typeof (event as { type?: unknown }).type === "string" - ? (event as { type: string }).type - : "unknown"; - this.config.onEvent?.({ direction: "client", type, ...(detail ? { detail } : {}) }); - const payload = JSON.stringify(event); - captureWsEvent({ - url: this.connectionUrl, - direction: "outbound", - kind: "ws-frame", - flowId: this.flowId, - payload, - meta: { - provider: "openai", - capability: "realtime-voice", - }, - }); - this.ws.send(payload); - } - } - - private describeServerEvent(event: RealtimeEvent): string | undefined { - if ( - event.type === "error" || - event.type === "conversation.item.input_audio_transcription.failed" - ) { - return readRealtimeErrorDetail(event.error); - } - if (event.type === "session.created" || event.type === "session.updated") { - const session = isRecord(event.session) ? event.session : undefined; - const tools = Array.isArray(session?.tools) ? session.tools.length : 0; - const rawToolChoice = session?.tool_choice; - const toolChoice = - typeof rawToolChoice === "string" - ? rawToolChoice - : isRecord(rawToolChoice) && typeof rawToolChoice.type === "string" - ? rawToolChoice.type - : "unset"; - return `tools=${tools} toolChoice=${toolChoice}`; - } - if ( - (event.type === "conversation.item.added" || event.type === "conversation.item.done") && - event.item?.type - ) { - return [ - `itemType=${event.item.type}`, - event.item.name ? `name=${event.item.name}` : undefined, - ] - .filter(Boolean) - .join(" "); - } - if (event.type === "response.done") { - const status = event.response?.status; - const details = - event.response?.status_details === undefined - ? undefined - : JSON.stringify(event.response.status_details); - return ( - [status ? `status=${status}` : undefined, details].filter(Boolean).join(" ") || undefined - ); - } - if (event.type === "response.cancelled") { - return "cancelled"; - } - return undefined; - } -} + OPENAI_REALTIME_CAPABILITIES, + OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED, + OPENAI_REALTIME_DEFAULT_MODEL, + OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL, + OPENAI_REALTIME_MODELS, + OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED, + OPENAI_REALTIME_VOICES, + buildOpenAIRealtimeGaSessionPolicy, + hasOpenAIChatGptSubscriptionAuthInput, + hasOpenAIRealtimeApiKeyInput, + hasOpenAIRealtimePlatformAuthInput, + normalizeOpenAIRealtimeTools, + normalizeOpenAIRealtimeVoice, + normalizeProviderConfig, + requireOpenAIRealtimePlatformAuth, + resolveOpenAIRealtimePlatformAuth, + resolveOpenAIQuicksilverBridgeAuth, + type OpenAIRealtimeVoice, + type OpenAIRealtimeVoiceProviderConfig, +} from "./realtime-voice-session-policy.js"; function resolveOpenAIRealtimeBrowserOfferHeaders(): Record | undefined { const headers = resolveProviderRequestHeaders({ @@ -2221,7 +211,7 @@ async function createOpenAIRealtimeBrowserSession( gaSideband: { session: sessionConfig, createBridge: ({ apiKey, callId, onTerminal }) => { - const bridge = new OpenAIRealtimeVoiceBridge({ + const bridge = new OpenAIRealtimeBridge({ cfg: req.cfg, providerConfig: req.providerConfig, apiKey, @@ -2268,32 +258,12 @@ async function createOpenAIRealtimeBrowserSession( instructions: buildOpenAIQuicksilverInstructions(req.instructions), ...(req.voice ? {} : configuredVoice ? { voice: configuredVoice } : {}), }; - const subscriptionAuth = await resolveOpenAIChatGptSubscriptionAuth({ - cfg: req.cfg, - agentDir: req.cfg ? resolveAgentDir(req.cfg, req.agentId) : undefined, - }); - if (subscriptionAuth) { - return await quicksilverBroker.createBrowserSession(quicksilverRequest, subscriptionAuth); - } - const auth = await resolveOpenAIRealtimePlatformAuth({ + const auth = await resolveOpenAIQuicksilverBridgeAuth({ configuredApiKey: config.apiKey, cfg: req.cfg, + agentId: req.agentId, }); - if (auth.status === "available") { - return await quicksilverBroker.createBrowserSession(quicksilverRequest, { - type: "api-key", - token: auth.value, - }); - } - if ( - hasOpenAIRealtimePlatformAuthInput({ - configuredApiKey: config.apiKey, - cfg: req.cfg, - }) - ) { - throw new Error(OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE); - } - throw new Error(OPENAI_GPT_LIVE_AUTH_REQUIRED); + return await quicksilverBroker.createBrowserSession(quicksilverRequest, auth); } const auth = await resolveOpenAIRealtimePlatformAuth({ configuredApiKey: config.apiKey, @@ -2349,14 +319,6 @@ async function createOpenAIRealtimeBrowserSession( }; } -async function cancelOpenAIRealtimeBrowserSession( - quicksilverBroker: OpenAIQuicksilverBrowserSessionBroker | undefined, - _req: OpenAIInternalRealtimeBrowserSessionCreateRequest, - session: RealtimeVoiceBrowserSession, -): Promise { - await quicksilverBroker?.cancelBrowserSession(session); -} - export function buildOpenAIRealtimeVoiceProvider(options?: { quicksilverBrowserSessionBroker?: OpenAIQuicksilverBrowserSessionBroker; logger?: Pick; @@ -2427,7 +389,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { }), }); } - return new OpenAIRealtimeVoiceBridge({ + return new OpenAIRealtimeBridge({ ...req, apiKey: config.apiKey, model: config.model, @@ -2530,12 +492,8 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { } return undefined; }, - cancelBrowserSession: (request, session) => - cancelOpenAIRealtimeBrowserSession( - options?.quicksilverBrowserSessionBroker, - request, - session, - ), + cancelBrowserSession: (_request, session) => + options?.quicksilverBrowserSessionBroker?.cancelBrowserSession(session), }; Object.defineProperty(provider, INTERNAL_REALTIME_VOICE_PROVIDER, { configurable: true, @@ -2543,4 +501,3 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { }); return provider; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/openai/realtime-voice-response-control.test.ts b/extensions/openai/realtime-voice-response-control.test.ts new file mode 100644 index 000000000000..3fa31c456aa9 --- /dev/null +++ b/extensions/openai/realtime-voice-response-control.test.ts @@ -0,0 +1,648 @@ +// Openai tests cover realtime voice provider plugin behavior. +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + FakeWebSocket, + execFileSyncMock, + fetchWithSsrFGuardMock, + isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKeyMock, +} = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void; + + class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly listeners = new Map(); + readyState = 0; + sent: string[] = []; + closed = false; + terminated = false; + deferClose = false; + deferredClose: (() => void) | undefined; + args: unknown[]; + + constructor(...args: unknown[]) { + this.args = args; + MockWebSocket.instances.push(this); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + + send(payload: string): void { + this.sent.push(payload); + } + + close(code?: number, reason?: string): void { + this.closed = true; + this.readyState = MockWebSocket.CLOSED; + const emitClose = () => this.emit("close", code ?? 1000, Buffer.from(reason ?? "")); + if (this.deferClose) { + this.deferredClose = emitClose; + return; + } + emitClose(); + } + + terminate(): void { + this.terminated = true; + this.close(1006, "terminated"); + } + + emitDeferredClose(): void { + const emitClose = this.deferredClose; + this.deferredClose = undefined; + emitClose?.(); + } + } + + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + isProviderAuthProfileConfiguredMock: vi.fn(), + resolveProviderAuthProfileApiKeyMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("ws", () => ({ + default: FakeWebSocket, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock, + resolveProviderAuthProfileApiKey: resolveProviderAuthProfileApiKeyMock, +})); +import { createOpenAIRealtimeTestSupport } from "./realtime-voice-test-support.js"; + +const { + parseSent, + createNativeBridge, + requireSocket, + beginBridgeConnection, + openSocket, + emitServerEvent, + emitSessionUpdated, + emitCompletedToolCalls, + connectReadyBridge, + expectedResponseCreateEvent, + requireNestedRecord, + expectRecordFields, +} = createOpenAIRealtimeTestSupport({ FakeWebSocket, fetchWithSsrFGuardMock }); + +describe("OpenAI realtime voice response control", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubEnv("OPENAI_API_KEY", ""); + execFileSyncMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReset(); + isProviderAuthProfileConfiguredMock.mockReturnValue(false); + resolveProviderAuthProfileApiKeyMock.mockReset(); + resolveProviderAuthProfileApiKeyMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("suppresses auto responses before draining queued initial greeting audio", async () => { + const bridgeRef: { current?: RealtimeVoiceBridge } = {}; + const onReady = vi.fn(() => { + bridgeRef.current?.triggerGreeting?.("Say exactly: hello from explicit speech."); + }); + const bridge = createNativeBridge({ + instructions: "Be helpful.", + onReady, + }); + bridgeRef.current = bridge; + const { connecting, socket } = beginBridgeConnection(bridge); + + openSocket(socket); + await Promise.resolve(); + + bridge.sendAudio(Buffer.from("before-ready")); + emitSessionUpdated(socket); + await connecting; + + const sent = parseSent(socket); + expect(sent.map((event) => event.type)).toEqual([ + "session.update", + "conversation.item.create", + "session.update", + "response.create", + "input_audio_buffer.append", + ]); + expect(sent[2]).toEqual({ + type: "session.update", + session: { + type: "realtime", + audio: { + input: { + turn_detection: { + type: "server_vad", + threshold: 0.5, + prefix_padding_ms: 300, + silence_duration_ms: 500, + create_response: false, + interrupt_response: true, + }, + }, + }, + }, + }); + expect(sent[4]).toEqual({ + type: "input_audio_buffer.append", + audio: Buffer.from("before-ready").toString("base64"), + }); + expect(sent.filter((event) => event.type === "response.create")).toHaveLength(1); + expect(onReady).toHaveBeenCalledTimes(1); + + emitServerEvent(socket, { type: "response.done" }); + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("creates an explicit user item and response for manual speech", async () => { + const onEvent = vi.fn(); + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); + + bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); + + const sent = parseSent(socket); + expect(sent[1]).toEqual({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: "Say exactly: hello from explicit speech.", + }, + ], + }, + }); + expectRecordFields( + requireNestedRecord(sent[2]?.session, ["audio", "input", "turn_detection"]), + "manual response turn detection", + { + create_response: false, + interrupt_response: true, + }, + ); + expect(sent[3]).toEqual(expectedResponseCreateEvent()); + expect(JSON.stringify(parseSent(socket).at(-1))).not.toContain("output_modalities"); + expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "conversation.item.create" }); + expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "response.create" }); + + emitServerEvent(socket, { type: "response.done" }); + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("forces one host-selected function on an otherwise automatic response", async () => { + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); + + bridge.sendUserMessage?.("Run the deterministic check.", { + toolChoice: { type: "function", name: "lookup_weather" }, + }); + + expect(parseSent(socket).at(-1)).toEqual({ + type: "response.create", + event_id: expect.stringMatching(/^openclaw-response-create-/), + response: { + output_modalities: ["audio"], + tool_choice: { type: "function", name: "lookup_weather" }, + }, + }); + }); + + it("defers manual response.create while a realtime response is active", async () => { + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + + bridge.sendUserMessage?.("queued manual response"); + + expect(parseSent(socket).slice(-1)).toEqual([ + { + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "queued manual response" }], + }, + }, + ]); + + emitServerEvent(socket, { type: "response.done" }); + + expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); + }); + + it("restores automatic audio responses when a manual response is rejected", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + + bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); + + const responseCreateEvent = parseSent(socket).findLast( + (event) => event.type === "response.create", + ); + if (!responseCreateEvent?.event_id) { + throw new Error("expected response.create event id"); + } + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-2)?.session, ["audio", "input", "turn_detection"]), + "suppressed turn detection", + { + create_response: false, + interrupt_response: true, + }, + ); + + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + event_id: responseCreateEvent.event_id, + message: "bad response request", + }, + }), + ), + ); + + expect(onError).toHaveBeenCalledWith(new Error("bad response request")); + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("keeps automatic audio suppressed for unrelated errors during a manual response", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + + bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); + const sessionUpdatesBeforeError = parseSent(socket).filter( + (event) => event.type === "session.update", + ); + + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { event_id: "unrelated-audio-event", message: "bad audio append" }, + }), + ), + ); + + expect(onError).toHaveBeenCalledWith(new Error("bad audio append")); + expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( + sessionUpdatesBeforeError.length, + ); + + emitServerEvent(socket, { type: "response.done" }); + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("flushes a queued manual response after the prior request is rejected", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + + bridge.triggerGreeting?.("Say exactly: first greeting."); + const firstResponseCreate = parseSent(socket).findLast( + (event) => event.type === "response.create", + ); + if (!firstResponseCreate?.event_id) { + throw new Error("expected first response.create event id"); + } + const sessionUpdateCount = parseSent(socket).filter( + (event) => event.type === "session.update", + ).length; + + bridge.sendUserMessage?.("Say exactly: queued follow-up."); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + event_id: firstResponseCreate.event_id, + message: "bad response request", + }, + }), + ), + ); + + const responseCreates = parseSent(socket).filter((event) => event.type === "response.create"); + expect(responseCreates).toHaveLength(2); + expect(responseCreates[1]).toEqual(expectedResponseCreateEvent()); + expect(responseCreates[1]?.event_id).not.toBe(firstResponseCreate.event_id); + expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( + sessionUpdateCount, + ); + expect(onError).toHaveBeenCalledWith(new Error("bad response request")); + + emitServerEvent(socket, { type: "response.done" }); + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("serializes standalone control speech while an agent tool call is pending", async () => { + const bridge = createNativeBridge({ onToolCall: vi.fn() }); + const socket = await connectReadyBridge(bridge); + emitCompletedToolCalls(socket); + + for (const text of ["status", "steer", "cancel"]) { + bridge.sendUserMessage?.(text); + } + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); + + for (let index = 0; index < 3; index += 1) { + socket.emit( + "message", + Buffer.from( + JSON.stringify({ type: "response.created", response: { id: `resp_control_${index}` } }), + ), + ); + emitServerEvent(socket, { + type: "response.done", + response: { id: `resp_control_${index}`, status: "completed", output: [] }, + }); + expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength( + Math.min(index + 2, 3), + ); + } + }); + + it("drains deferred response.create after response.cancelled", async () => { + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + + bridge.sendUserMessage?.("queued after cancellation"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "response.cancelled" }))); + + expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); + }); + + it("drains deferred response.create after a no-active-response cancellation error", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + + bridge.sendUserMessage?.("queued after cancellation error"); + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + const responseCancelEvent = parseSent(socket).findLast( + (event) => event.type === "response.cancel", + ); + if (!responseCancelEvent?.event_id) { + throw new Error("expected response.cancel event id"); + } + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + event_id: responseCancelEvent.event_id, + message: "Cancellation failed: no active response found", + }, + }), + ), + ); + + expect(onError).not.toHaveBeenCalled(); + expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); + }); + + it("ignores a stale cancellation error after a newer manual response starts", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + bridge.setMediaTimestamp(1000); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "response.audio.delta", + item_id: "item_1", + delta: Buffer.from("assistant audio").toString("base64"), + }), + ), + ); + bridge.setMediaTimestamp(1300); + + bridge.handleBargeIn?.({ audioPlaybackActive: true }); + const responseCancelEvent = parseSent(socket).findLast( + (event) => event.type === "response.cancel", + ); + if (!responseCancelEvent?.event_id) { + throw new Error("expected response.cancel event id"); + } + bridge.sendUserMessage?.("queued newer response"); + emitServerEvent(socket, { type: "response.done" }); + const sessionUpdateCount = parseSent(socket).filter( + (event) => event.type === "session.update", + ).length; + + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + event_id: responseCancelEvent.event_id, + message: "Cancellation failed: no active response found", + }, + }), + ), + ); + + expect(onError).not.toHaveBeenCalled(); + expect(parseSent(socket).filter((event) => event.type === "session.update")).toHaveLength( + sessionUpdateCount, + ); + expect(parseSent(socket).at(-1)).toEqual(expectedResponseCreateEvent()); + + emitServerEvent(socket, { type: "response.done" }); + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); + + it("resets deferred response guards after websocket reconnect", async () => { + vi.useFakeTimers(); + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); + socket.emit( + "message", + Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), + ); + bridge.sendUserMessage?.("queued before reconnect"); + + expect(parseSent(socket).slice(-1)[0]?.type).toBe("conversation.item.create"); + + socket.emit("close", 1006, Buffer.from("transient drop")); + await vi.advanceTimersByTimeAsync(1000); + const reconnectedSocket = requireSocket(1); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); + bridge.sendUserMessage?.("Say hello after reconnect."); + + expect(parseSent(reconnectedSocket).slice(-3)).toEqual([ + { + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Say hello after reconnect." }], + }, + }, + expect.objectContaining({ type: "session.update" }), + expectedResponseCreateEvent(), + ]); + }); + + it("turns active-response errors into a deferred response.create retry", async () => { + const onError = vi.fn(); + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); + + bridge.sendUserMessage?.("trigger active-response retry"); + const responseCreateEvent = parseSent(socket).findLast( + (event) => event.type === "response.create", + ); + if (!responseCreateEvent?.event_id) { + throw new Error("expected response.create event id"); + } + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + event_id: responseCreateEvent.event_id, + message: "Conversation already has an active response in progress: resp_1", + }, + }), + ), + ); + const afterError = parseSent(socket); + expect(afterError.filter((event) => event.type === "session.update")).toHaveLength(2); + expectRecordFields( + requireNestedRecord(afterError.at(-2)?.session, ["audio", "input", "turn_detection"]), + "still suppressed turn detection", + { + create_response: false, + interrupt_response: true, + }, + ); + + emitServerEvent(socket, { type: "response.done" }); + + expect(onError).not.toHaveBeenCalled(); + expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); + + emitServerEvent(socket, { type: "response.done" }); + + expectRecordFields( + requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), + "restored turn detection", + { + create_response: true, + interrupt_response: true, + }, + ); + }); +}); diff --git a/extensions/openai/realtime-voice-session-policy.ts b/extensions/openai/realtime-voice-session-policy.ts new file mode 100644 index 000000000000..284de9e583b5 --- /dev/null +++ b/extensions/openai/realtime-voice-session-policy.ts @@ -0,0 +1,643 @@ +import { execFileSync } from "node:child_process"; +import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { + isProviderAuthProfileConfigured, + resolveProviderAuthProfileApiKey, +} from "openclaw/plugin-sdk/provider-auth"; +import type { + RealtimeVoiceAudioFormat, + RealtimeVoiceBrowserSessionCreateRequest, + RealtimeVoiceBridgeCreateRequest, + RealtimeVoiceProviderCapabilities, + RealtimeVoiceProviderConfig, + RealtimeVoiceTool, +} from "openclaw/plugin-sdk/realtime-voice"; +import { + REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, +} from "openclaw/plugin-sdk/realtime-voice"; +import { warn } from "openclaw/plugin-sdk/runtime-env"; +import { + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; +import { + asFiniteNumber, + asFiniteNumberInRange, + asSafeIntegerInRange, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + readRealtimeErrorDetail, + resolveOpenAIProviderConfigRecord, +} from "./realtime-provider-shared.js"; +import { resolveOpenAIChatGptSubscriptionAuth } from "./realtime-quicksilver-session.js"; +import { OPENAI_GPT_LIVE_MODELS } from "./realtime-quicksilver.js"; + +export type OpenAIRealtimeVoice = + | "alloy" + | "ash" + | "ballad" + | "cedar" + | "coral" + | "echo" + | "marin" + | "sage" + | "shimmer" + | "verse"; + +export type OpenAIRealtimeUserMessageOptions = { + toolChoice?: { type: "function"; name: string }; +}; + +export type OpenAIRealtimeVoiceProviderConfig = { + apiKey?: string; + model?: string; + voice?: OpenAIRealtimeVoice; + temperature?: number; + vadThreshold?: number; + silenceDurationMs?: number; + prefixPaddingMs?: number; + interruptResponseOnInputAudio?: boolean; + minBargeInAudioEndMs?: number; + reasoningEffort?: string; + azureEndpoint?: string; + azureDeployment?: string; + azureApiVersion?: string; +}; + +export type OpenAIRealtimeVoiceBridgeConfig = RealtimeVoiceBridgeCreateRequest & { + apiKey?: string; + callId?: string; + gaSessionPolicy?: RealtimeGaSessionPolicy; + model?: string; + voice?: OpenAIRealtimeVoice; + temperature?: number; + vadThreshold?: number; + silenceDurationMs?: number; + prefixPaddingMs?: number; + interruptResponseOnInputAudio?: boolean; + minBargeInAudioEndMs?: number; + reasoningEffort?: string; + azureEndpoint?: string; + azureDeployment?: string; + azureApiVersion?: string; +}; + +export const OPENAI_REALTIME_DEFAULT_MODEL = "gpt-realtime-2.1"; +// Picker suggestions surfaced through talk.catalog; each value is live-verified +// against the OpenAI realtime APIs. Free-form model values are still accepted. +export const OPENAI_REALTIME_MODELS = [ + "gpt-realtime-2.1", + "gpt-realtime-2.1-mini", + "gpt-realtime-2", + ...OPENAI_GPT_LIVE_MODELS, +] as const; +export const OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe"; +export const OPENAI_REALTIME_CAPABILITIES: RealtimeVoiceProviderCapabilities = { + transports: ["webrtc", "gateway-relay"], + inputAudioFormats: [ + REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + ], + outputAudioFormats: [ + REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + ], + supportsBrowserSession: true, + supportsBargeIn: true, + handlesInputAudioBargeIn: true, + supportsToolCalls: true, + supportsVideoFrames: true, +}; +export const OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX = + "Conversation already has an active response in progress:"; +export const OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR = + "Cancellation failed: no active response found"; +const OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT = "maximum duration"; +export const OPENAI_VOICE_WS_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; +export const OPENAI_REALTIME_SIDEBAND_STARTUP_MAX_BYTES = 1024 * 1024; +export const OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS = 250; +// Realtime validates this character set but accepts names beyond the 64-character +// cap used by other OpenAI tool surfaces. +const OPENAI_REALTIME_TOOL_NAME_RE = /^[A-Za-z0-9_-]+$/; +export const AZURE_OPENAI_REALTIME_TOOL_NAME_MAX_LENGTH = 64; +export const OPENAI_REALTIME_VOICES = [ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "sage", + "shimmer", + "verse", + "marin", + "cedar", +] as const satisfies readonly OpenAIRealtimeVoice[]; + +export function normalizeOpenAIRealtimeVoice(value: unknown): OpenAIRealtimeVoice | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + return OPENAI_REALTIME_VOICES.includes(normalized as OpenAIRealtimeVoice) + ? (normalized as OpenAIRealtimeVoice) + : undefined; +} + +export type RealtimeEvent = { + type: string; + delta?: string; + data?: string; + text?: string; + transcript?: string; + item_id?: string; + response_id?: string; + call_id?: string; + name?: string; + arguments?: string; + session?: unknown; + item?: { + id?: string; + type?: string; + name?: string; + call_id?: string; + arguments?: string; + }; + response?: { + id?: string; + status?: string; + status_details?: unknown; + output?: unknown[]; + }; + error?: unknown; +}; + +export type RealtimeTurnDetectionConfig = { + type: "server_vad"; + threshold: number; + prefix_padding_ms: number; + silence_duration_ms: number; + create_response: boolean; + interrupt_response?: boolean; +}; + +type RealtimeGaSessionPolicy = { + type: "realtime"; + model: string; + instructions?: string; + output_modalities: string[]; + audio: { + input: { + format: OpenAIRealtimeAudioFormatConfig; + turn_detection: RealtimeTurnDetectionConfig; + noise_reduction: { type: "near_field" } | null; + transcription: { model: string; language?: string }; + }; + output: { + format: OpenAIRealtimeAudioFormatConfig; + voice: OpenAIRealtimeVoice; + }; + }; + reasoning?: { effort: string }; + tools?: RealtimeVoiceTool[]; + tool_choice?: string; +}; + +export type RealtimeGaSessionUpdate = { + type: "session.update"; + session: RealtimeGaSessionPolicy; +}; + +export type RealtimeAzureDeploymentSessionUpdate = { + type: "session.update"; + session: { + modalities: string[]; + instructions?: string; + voice: OpenAIRealtimeVoice; + input_audio_format: "g711_ulaw" | "pcm16"; + output_audio_format: "g711_ulaw" | "pcm16"; + input_audio_transcription?: { model: string; language?: string }; + turn_detection: RealtimeTurnDetectionConfig; + temperature: number; + tools?: RealtimeVoiceTool[]; + tool_choice?: string; + }; +}; + +type OpenAIRealtimeAudioFormatConfig = + | { + type: "audio/pcm"; + rate: 24000; + } + | { + type: "audio/pcmu"; + }; + +export function normalizeProviderConfig( + config: RealtimeVoiceProviderConfig, +): OpenAIRealtimeVoiceProviderConfig { + const raw = resolveOpenAIProviderConfigRecord(config); + return { + apiKey: normalizeResolvedSecretInputString({ + value: raw?.apiKey, + path: "plugins.entries.voice-call.config.realtime.providers.openai.apiKey", + }), + model: normalizeOptionalString(raw?.model), + voice: normalizeOpenAIRealtimeVoice(raw?.speakerVoice ?? raw?.voice), + temperature: asFiniteNumber(raw?.temperature), + vadThreshold: asUnitInterval(raw?.vadThreshold), + silenceDurationMs: asNonNegativeInteger(raw?.silenceDurationMs), + prefixPaddingMs: asNonNegativeInteger(raw?.prefixPaddingMs), + interruptResponseOnInputAudio: + typeof raw?.interruptResponseOnInputAudio === "boolean" + ? raw.interruptResponseOnInputAudio + : undefined, + minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs), + reasoningEffort: normalizeOptionalString(raw?.reasoningEffort), + azureEndpoint: normalizeOptionalString(raw?.azureEndpoint), + azureDeployment: normalizeOptionalString(raw?.azureDeployment), + azureApiVersion: normalizeOptionalString(raw?.azureApiVersion), + }; +} + +function asNonNegativeInteger(value: unknown): number | undefined { + return asSafeIntegerInRange(value, { min: 0 }); +} + +function asUnitInterval(value: unknown): number | undefined { + return asFiniteNumberInRange(value, { min: 0, max: 1 }); +} + +type OpenAIRealtimeApiKeyResolution = + | { status: "available"; value: string } + | { status: "missing" }; + +export const OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED = + "OpenAI Realtime voice requires an OpenAI Platform API key"; +const OPENAI_GPT_LIVE_AUTH_REQUIRED = + "GPT-Live Talk requires either an OpenAI Platform API key or a ChatGPT OAuth subscription profile"; +const OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE = + "GPT-Live Talk requires a working OpenAI Platform API key or ChatGPT OAuth subscription profile. The selected Platform API-key source could not be resolved, so OAuth fallback was not used; fix or remove it."; +export const OPENAI_REALTIME_API_KEY_REQUIRED = "OpenAI Realtime voice requires an API key"; +export const OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; +const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; +const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; +const resolvedKeychainSecretRefCache = new Map(); + +export function isDirectOpenAIRealtimeWebSocketUrl(value: string): boolean { + try { + return new URL(value).hostname === "api.openai.com"; + } catch { + return false; + } +} + +export function isOpenAIRealtimeStartupAuthFailure(error: unknown): boolean { + const record = + typeof error === "object" && error !== null ? (error as Record) : undefined; + const status = record?.status ?? record?.statusCode; + const rawCode = record?.code ?? record?.errorCode; + const code = typeof rawCode === "string" ? rawCode.toLowerCase() : ""; + const message = readRealtimeErrorDetail(error).toLowerCase(); + return ( + status === 401 || + code === "invalid_api_key" || + message.includes("invalid_api_key") || + message.includes("incorrect api key provided") || + message.includes("unexpected server response: 401") + ); +} + +function resolveKeychainSecretRef(value: string): string | undefined { + const trimmed = value.trim(); + const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed); + if (!match) { + return trimmed || undefined; + } + const cached = resolvedKeychainSecretRefCache.get(trimmed); + if (cached) { + return cached; + } + const [, service, account] = match; + if (!service || !account) { + return undefined; + } + try { + const resolved = + execFileSync( + "/usr/bin/security", + ["find-generic-password", "-s", service, "-a", account, "-w"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: KEYCHAIN_LOOKUP_TIMEOUT_MS, + }, + ).trim() || undefined; + if (resolved) { + resolvedKeychainSecretRefCache.set(trimmed, resolved); + } + return resolved; + } catch { + return undefined; + } +} + +export function resolveOpenAIRealtimeSecretInput( + configuredApiKey: string | undefined, +): OpenAIRealtimeApiKeyResolution { + const configured = normalizeSecretInputString(configuredApiKey); + if (configured) { + const value = resolveKeychainSecretRef(configured); + return value ? { status: "available", value } : { status: "missing" }; + } + + return { status: "missing" }; +} + +export function resolveOpenAIRealtimeEnvApiKey(): OpenAIRealtimeApiKeyResolution { + const envValue = normalizeSecretInputString(process.env.OPENAI_API_KEY); + if (!envValue) { + return { status: "missing" }; + } + const value = resolveKeychainSecretRef(envValue); + return value ? { status: "available", value } : { status: "missing" }; +} + +function resolveOpenAIRealtimeApiKey( + configuredApiKey: string | undefined, +): OpenAIRealtimeApiKeyResolution { + const configured = resolveOpenAIRealtimeSecretInput(configuredApiKey); + if ( + configured.status === "available" || + hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey) + ) { + return configured; + } + return resolveOpenAIRealtimeEnvApiKey(); +} + +export function requireOpenAIRealtimeApiKey( + configuredApiKey: string | undefined, + errorMessage = OPENAI_REALTIME_API_KEY_REQUIRED, +): string { + const resolved = resolveOpenAIRealtimeApiKey(configuredApiKey); + if (resolved.status === "available") { + return resolved.value; + } + throw new Error(errorMessage); +} + +export function hasOpenAIRealtimeConfiguredApiKeyInput( + configuredApiKey: string | undefined, +): boolean { + return Boolean(normalizeSecretInputString(configuredApiKey)); +} + +export function hasOpenAIRealtimeApiKeyInput(configuredApiKey: string | undefined): boolean { + return Boolean( + normalizeSecretInputString(configuredApiKey) ?? + normalizeSecretInputString(process.env.OPENAI_API_KEY), + ); +} + +export function normalizeOpenAIRealtimeTools( + tools: RealtimeVoiceTool[] | undefined, + maxNameLength?: number, +): RealtimeVoiceTool[] | undefined { + const normalized: RealtimeVoiceTool[] = []; + let omitted = 0; + for (const tool of tools ?? []) { + try { + const name = tool.name; + if (typeof name !== "string") { + omitted += 1; + continue; + } + const exceedsLengthLimit = maxNameLength !== undefined && name.length > maxNameLength; + if (exceedsLengthLimit || !OPENAI_REALTIME_TOOL_NAME_RE.test(name)) { + omitted += 1; + continue; + } + normalized.push({ + type: "function", + name, + description: tool.description, + parameters: tool.parameters, + }); + } catch { + omitted += 1; + } + } + if (omitted > 0) { + warn(`openai realtime: omitted ${omitted} tool definition(s) with unsupported names`); + } + return normalized.length > 0 ? normalized : undefined; +} + +function resolveOpenAIRealtimeAudioFormat( + audioFormat: RealtimeVoiceAudioFormat = REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, +): OpenAIRealtimeAudioFormatConfig { + return audioFormat.encoding === "pcm16" + ? { type: "audio/pcm", rate: 24000 } + : { type: "audio/pcmu" }; +} + +export function buildOpenAIRealtimeTurnDetectionConfig(params: { + autoRespondToAudio?: boolean; + createResponse?: boolean; + includeInterruptResponse?: boolean; + interruptResponseOnInputAudio?: boolean; + prefixPaddingMs?: number; + silenceDurationMs?: number; + vadThreshold?: number; +}): RealtimeTurnDetectionConfig { + const configuredAutoResponse = params.autoRespondToAudio ?? true; + return { + type: "server_vad", + threshold: params.vadThreshold ?? 0.5, + prefix_padding_ms: params.prefixPaddingMs ?? 300, + silence_duration_ms: params.silenceDurationMs ?? 500, + create_response: params.createResponse ?? configuredAutoResponse, + ...(params.includeInterruptResponse + ? { + interrupt_response: params.interruptResponseOnInputAudio ?? configuredAutoResponse, + } + : {}), + }; +} + +export function buildOpenAIRealtimeGaSessionPolicy(params: { + audioFormat?: RealtimeVoiceAudioFormat; + autoRespondToAudio?: boolean; + instructions?: string; + interruptResponseOnInputAudio?: boolean; + language?: string; + model: string; + noiseReduction: { type: "near_field" } | null; + prefixPaddingMs?: number; + reasoningEffort?: string; + silenceDurationMs?: number; + tools?: RealtimeVoiceTool[]; + vadThreshold?: number; + voice: OpenAIRealtimeVoice; +}): RealtimeGaSessionPolicy { + const format = resolveOpenAIRealtimeAudioFormat(params.audioFormat); + return { + type: "realtime", + model: params.model, + ...(params.instructions !== undefined ? { instructions: params.instructions } : {}), + output_modalities: ["audio"], + audio: { + input: { + format, + noise_reduction: params.noiseReduction, + transcription: { + model: OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL, + ...(params.language ? { language: params.language } : {}), + }, + turn_detection: buildOpenAIRealtimeTurnDetectionConfig({ + autoRespondToAudio: params.autoRespondToAudio, + includeInterruptResponse: true, + interruptResponseOnInputAudio: params.interruptResponseOnInputAudio, + prefixPaddingMs: params.prefixPaddingMs, + silenceDurationMs: params.silenceDurationMs, + vadThreshold: params.vadThreshold, + }), + }, + output: { + format, + voice: params.voice, + }, + }, + ...(params.reasoningEffort ? { reasoning: { effort: params.reasoningEffort } } : {}), + ...(params.tools ? { tools: params.tools, tool_choice: "auto" } : {}), + }; +} + +export async function resolveOpenAIRealtimePlatformAuth(params: { + configuredApiKey: string | undefined; + cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; +}): Promise { + const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey); + if ( + configured.status === "available" || + hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey) + ) { + return configured; + } + + const profileApiKey = await resolveProviderAuthProfileApiKey({ + provider: "openai", + cfg: params.cfg, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }); + if (profileApiKey) { + return { status: "available", value: profileApiKey }; + } + const envApiKey = resolveOpenAIRealtimeEnvApiKey(); + if (envApiKey.status === "available") { + return envApiKey; + } + return { status: "missing" }; +} + +export async function requireOpenAIRealtimePlatformAuth(params: { + configuredApiKey: string | undefined; + cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; +}): Promise> { + const resolved = await resolveOpenAIRealtimePlatformAuth(params); + if (resolved.status === "available") { + return resolved; + } + throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); +} + +export async function resolveOpenAIQuicksilverBridgeAuth(params: { + configuredApiKey: string | undefined; + cfg: RealtimeVoiceBridgeCreateRequest["cfg"] | undefined; + agentId?: string; +}) { + const subscriptionAuth = await resolveOpenAIChatGptSubscriptionAuth({ + cfg: params.cfg, + agentDir: + params.cfg && params.agentId ? resolveAgentDir(params.cfg, params.agentId) : undefined, + }); + if (subscriptionAuth) { + return subscriptionAuth; + } + const platformAuth = await resolveOpenAIRealtimePlatformAuth(params); + if (platformAuth.status === "available") { + return { type: "api-key" as const, token: platformAuth.value }; + } + if ( + hasOpenAIRealtimePlatformAuthInput({ + configuredApiKey: params.configuredApiKey, + cfg: params.cfg, + }) + ) { + throw new Error(OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE); + } + throw new Error(OPENAI_GPT_LIVE_AUTH_REQUIRED); +} + +export function hasOpenAIRealtimePlatformAuthInput(params: { + configuredApiKey: string | undefined; + cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; +}): boolean { + if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) { + return true; + } + if ( + isProviderAuthProfileConfigured({ + provider: "openai", + cfg: params.cfg, + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }) + ) { + return true; + } + return hasOpenAIRealtimeApiKeyInput(undefined); +} + +export function hasOpenAIChatGptSubscriptionAuthInput(params: { + cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; + agentId?: string; +}): boolean { + return isProviderAuthProfileConfigured({ + provider: "openai", + cfg: params.cfg, + agentDir: + params.cfg && params.agentId ? resolveAgentDir(params.cfg, params.agentId) : undefined, + profileTypes: ["oauth"], + includeExternalCliAuth: false, + }); +} + +export function isOpenAIRealtimeMaxSessionDurationError(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("session") && + normalized.includes(OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT) + ); +} + +export function readRealtimeErrorEventId(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const eventId = (error as Record).event_id; + return typeof eventId === "string" ? eventId : undefined; +} + +export function parsePlaybackMarkSequence(markName: string): number | undefined { + const match = /^audio-(\d+)$/u.exec(markName); + if (!match) { + return undefined; + } + const sequence = Number(match[1]); + return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined; +} diff --git a/extensions/openai/realtime-voice-test-support.ts b/extensions/openai/realtime-voice-test-support.ts new file mode 100644 index 000000000000..326445f9ddd4 --- /dev/null +++ b/extensions/openai/realtime-voice-test-support.ts @@ -0,0 +1,318 @@ +import type { + RealtimeVoiceBridge, + RealtimeVoiceBridgeCreateRequest, + RealtimeVoiceTool, +} from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { expect, vi } from "vitest"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +type FakeWebSocketLike = { + sent: string[]; + readyState: number; + emit(event: string, ...args: unknown[]): void; +}; + +type FakeWebSocketConstructor = { + new (...args: unknown[]): T; + readonly OPEN: number; + instances: T[]; +}; + +export function createOpenAIRealtimeTestSupport(deps: { + FakeWebSocket: FakeWebSocketConstructor; + fetchWithSsrFGuardMock: ReturnType; +}) { + const { FakeWebSocket, fetchWithSsrFGuardMock } = deps; + type FakeWebSocketInstance = T; + type SentRealtimeEvent = { + type: string; + event_id?: string; + audio?: string; + item_id?: string; + item?: unknown; + content_index?: number; + audio_end_ms?: number; + session?: { + type?: string; + model?: string; + modalities?: string[]; + instructions?: string; + voice?: string; + input_audio_format?: string; + output_audio_format?: string; + input_audio_transcription?: Record; + turn_detection?: { + create_response?: boolean; + }; + output_modalities?: string[]; + tools?: Array<{ name?: string }>; + audio?: { + input?: { + format?: Record; + noise_reduction?: Record | null; + transcription?: Record; + turn_detection?: { + create_response?: boolean; + interrupt_response?: boolean; + }; + }; + output?: { + format?: Record; + voice?: string; + }; + }; + }; + }; + + function parseSent(socket: FakeWebSocketInstance): SentRealtimeEvent[] { + return socket.sent.map((payload: string) => JSON.parse(payload) as SentRealtimeEvent); + } + + function createNativeBridge( + overrides: Partial = {}, + ): RealtimeVoiceBridge { + return buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { apiKey: "test-api-key-test" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + ...overrides, + }); + } + + function requireSocket(index = 0): FakeWebSocketInstance { + const socket = FakeWebSocket.instances[index]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + return socket; + } + + function beginBridgeConnection( + bridge: RealtimeVoiceBridge, + socketIndex = 0, + ): { connecting: Promise; socket: FakeWebSocketInstance } { + const connecting = bridge.connect(); + return { connecting, socket: requireSocket(socketIndex) }; + } + + function openSocket(socket: FakeWebSocketInstance): void { + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + } + + function emitServerEvent(socket: FakeWebSocketInstance, event: Record): void { + socket.emit("message", Buffer.from(JSON.stringify(event))); + } + + function emitSessionUpdated(socket: FakeWebSocketInstance): void { + emitServerEvent(socket, { type: "session.updated" }); + } + + function emitCompletedToolCalls( + socket: FakeWebSocketInstance, + callIds: string[] = ["call_1"], + ): void { + emitServerEvent(socket, { + type: "response.done", + response: { + id: "response_tools", + status: "completed", + output: callIds.map((callId, index) => ({ + id: `item_${index + 1}`, + type: "function_call", + status: "completed", + call_id: callId, + name: "lookup_weather", + arguments: "{}", + })), + }, + }); + } + + function emitFunctionOutputAdded(socket: FakeWebSocketInstance, callId: string): void { + emitServerEvent(socket, { + type: "conversation.item.added", + item: { type: "function_call_output", call_id: callId }, + }); + } + + function expectedFunctionOutput(callId: string, result: unknown) { + return expect.objectContaining({ + type: "conversation.item.create", + item: { + type: "function_call_output", + call_id: callId, + output: JSON.stringify(result), + }, + }); + } + + async function connectReadyBridge( + bridge: RealtimeVoiceBridge, + socketIndex = 0, + ): Promise { + const { connecting, socket } = beginBridgeConnection(bridge, socketIndex); + openSocket(socket); + emitSessionUpdated(socket); + await connecting; + return socket; + } + + function expectedResponseCreateEvent() { + return expect.objectContaining({ + type: "response.create", + event_id: expect.stringMatching(/^openclaw-response-create-/), + }); + } + + function expectedResponseCancelEvent() { + return expect.objectContaining({ + type: "response.cancel", + event_id: expect.stringMatching(/^openclaw-response-cancel-/), + }); + } + + function createJsonResponse(body: unknown, init?: { status?: number }): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { + "Content-Type": "application/json", + }, + }); + } + + function requireRecord(value: unknown, label: string): Record { + expect(isRecord(value), `${label} must be an object`).toBe(true); + return value as Record; + } + + function requireNestedRecord( + value: unknown, + path: readonly string[], + label = path.join("."), + ): Record { + let current = requireRecord(value, label); + for (const key of path) { + current = requireRecord(current[key], `${label}.${key}`); + } + return current; + } + + function expectRecordFields( + value: unknown, + label: string, + expected: Record, + ): Record { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key], `${label}.${key}`).toEqual(expectedValue); + } + return record; + } + + function firstMockCall( + mock: { mock: { calls: Array } }, + label: string, + ): readonly unknown[] { + const call = mock.mock.calls[0]; + if (!call) { + throw new Error(`expected ${label} call`); + } + return call; + } + + function requireFetchRequest(callIndex = 0): Record { + return requireRecord(fetchWithSsrFGuardMock.mock.calls[callIndex]?.[0], "fetch request"); + } + + function requireFetchInit(callIndex = 0): Record { + return requireRecord(requireFetchRequest(callIndex).init, "fetch init"); + } + + function requireFetchHeaders(callIndex = 0): Record { + return requireRecord(requireFetchInit(callIndex).headers, "fetch headers"); + } + + function requireFetchJsonBody(callIndex = 0): Record { + const body = requireFetchInit(callIndex).body; + expect(typeof body, "fetch body must be a JSON string").toBe("string"); + return requireRecord(JSON.parse(body as string), "fetch JSON body"); + } + + function requireSession(socket: FakeWebSocketInstance, index = 0): Record { + return requireRecord(parseSent(socket)[index]?.session, "session"); + } + + function hasSentEventType(socket: FakeWebSocketInstance, type: string): boolean { + return parseSent(socket).some((event) => event.type === type); + } + + function createRealtimeTool(name: string): RealtimeVoiceTool { + return { + type: "function", + name, + description: "Contract test tool", + parameters: { type: "object", properties: {} }, + }; + } + + function createUnreadableToolName(): RealtimeVoiceTool { + return { + type: "function", + get name(): string { + throw new Error("unreadable tool name"); + }, + description: "Contract test tool", + parameters: { type: "object", properties: {} }, + }; + } + + function createMalformedToolName(name: unknown): RealtimeVoiceTool { + return { + type: "function", + name, + description: "Contract test tool", + parameters: { type: "object", properties: {} }, + } as unknown as RealtimeVoiceTool; + } + + function createTestJwt(payload: Record): string { + return [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "test-signature", + ].join("."); + } + + return { + parseSent, + createNativeBridge, + requireSocket, + beginBridgeConnection, + openSocket, + emitServerEvent, + emitSessionUpdated, + emitCompletedToolCalls, + emitFunctionOutputAdded, + expectedFunctionOutput, + connectReadyBridge, + expectedResponseCreateEvent, + expectedResponseCancelEvent, + createJsonResponse, + requireRecord, + requireNestedRecord, + expectRecordFields, + firstMockCall, + requireFetchRequest, + requireFetchInit, + requireFetchHeaders, + requireFetchJsonBody, + requireSession, + hasSentEventType, + createRealtimeTool, + createUnreadableToolName, + createMalformedToolName, + createTestJwt, + }; +} From 1c46d517c43b36ba72627577a763a4e90cace12a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:07:35 -0700 Subject: [PATCH 020/165] fix(ui): confirm WhatsApp logout (#122437) Require an account-bound confirmation before deleting WhatsApp credentials, and revalidate Gateway ownership plus account state after the modal resolves. --- .../e2e/channels-whatsapp-logout.e2e.test.ts | 93 ++++++++++++++++++- ui/src/i18n/locales/en.ts | 3 + ui/src/pages/channels/channels-page.ts | 21 ++++- ui/src/pages/channels/whatsapp-logout.ts | 60 ++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 ui/src/pages/channels/whatsapp-logout.ts diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index 5faa0274ef87..05747ba50946 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover WhatsApp logout feedback against a mocked Gateway. import { expect, it } from "vitest"; -import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { installMockGateway, waitForConfirmModal } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ @@ -13,7 +13,7 @@ const QR_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlY9Z8AAAAASUVORK5CYII="; suite.define(() => { - it("keeps the QR visible and explains a no-op logout", async () => { + it("confirms the explicit default account and preserves a no-op logout", async () => { await suite.withPage( { locale: "en-US", @@ -72,6 +72,30 @@ suite.define(() => { await expect(qr.getAttribute("src")).resolves.toBe(QR_DATA_URL); await detail.getByRole("button", { name: "Logout" }).click(); + await expect.poll(async () => gateway.getRequests("channels.logout")).toHaveLength(0); + const firstConfirm = await waitForConfirmModal(page); + await expect(firstConfirm.textContent()).resolves.toContain( + "Log out of WhatsApp account default?", + ); + await expect(firstConfirm.textContent()).resolves.toContain( + "Logging out of account default stops its listener and deletes its saved credentials.", + ); + await firstConfirm.getByRole("button", { name: "Cancel" }).click(); + await expect.poll(() => page.locator("openclaw-modal-dialog").count()).toBe(1); + await expect.poll(async () => gateway.getRequests("channels.logout")).toHaveLength(0); + await expect(qr.getAttribute("src")).resolves.toBe(QR_DATA_URL); + await expect + .poll(() => + detail + .locator("dt", { hasText: "Linked" }) + .locator("xpath=following-sibling::dd[1]") + .textContent(), + ) + .toContain("Yes"); + + await detail.getByRole("button", { name: "Logout" }).click(); + const secondConfirm = await waitForConfirmModal(page); + await secondConfirm.getByRole("button", { name: "Logout" }).click(); await expect .poll(async () => detail.locator(".settings-row__desc").allTextContents()) .toContain( @@ -80,11 +104,76 @@ suite.define(() => { await expect(qr.getAttribute("src")).resolves.toBe(QR_DATA_URL); await expect(detail.getByText("Logged out.", { exact: true }).count()).resolves.toBe(0); await expect.poll(async () => gateway.getRequests("channels.logout")).toHaveLength(1); + expect((await gateway.getRequests("channels.logout"))[0]?.params).toEqual({ + channel: "whatsapp", + accountId: "default", + }); await expect.poll(async () => gateway.getRequests("channels.status")).toHaveLength(3); }, ); }); + it("rejects a captured custom-account logout after the Gateway reconnects", async () => { + await suite.withPage({ locale: "en-US", serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "channels.status": { + ts: Date.now(), + channelOrder: ["whatsapp"], + channelLabels: { whatsapp: "WhatsApp" }, + channels: { + whatsapp: { + configured: true, + linked: true, + running: true, + connected: true, + reconnectAttempts: 0, + }, + }, + channelAccounts: { + whatsapp: [ + { + accountId: "work", + configured: true, + linked: true, + running: true, + connected: true, + }, + ], + }, + channelDefaultAccountId: { whatsapp: "work" }, + }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, + "channels.logout": { + channel: "whatsapp", + accountId: "work", + cleared: true, + loggedOut: true, + }, + }, + }); + + await page.goto(`${suite.server.baseUrl}settings/channels`); + await page.locator(".channels-item", { hasText: "WhatsApp" }).first().click(); + const detail = page.locator(".channels-detail"); + await detail.waitFor(); + await detail.getByRole("button", { name: "Logout" }).click(); + const confirm = await waitForConfirmModal(page); + await expect(confirm.textContent()).resolves.toContain("work"); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1012, "Reconnect during logout confirmation"); + await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(socketCount); + await confirm.getByRole("button", { name: "Logout" }).click(); + await expect.poll(() => page.locator("openclaw-modal-dialog").count()).toBe(1); + await expect.poll(async () => gateway.getRequests("channels.logout")).toHaveLength(0); + }); + }); + it("preserves standard channel details and the complete Telegram setup wizard", async () => { await suite.withPage({ locale: "en-US", serviceWorkers: "block" }, async ({ page }) => { const channelEntries = [ diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 01268476e9ea..a8a8dc9e9f0d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -292,6 +292,9 @@ export const en: TranslationMap = { subtitle: "Link WhatsApp Web and monitor connection health.", phoneNumber: "Phone number", loggedOut: "Logged out.", + logoutConfirmTitle: "Log out of WhatsApp account {accountId}?", + logoutConfirmMessage: + "Logging out of account {accountId} stops its listener and deletes its saved credentials.", logoutNotCleared: "No stored WhatsApp session was cleared. It may already be absent, or its auth directory may require manual cleanup.", }, diff --git a/ui/src/pages/channels/channels-page.ts b/ui/src/pages/channels/channels-page.ts index ce5834ab2a13..010861083e92 100644 --- a/ui/src/pages/channels/channels-page.ts +++ b/ui/src/pages/channels/channels-page.ts @@ -27,6 +27,7 @@ import { importNostrProfile, parseValidationErrors, putNostrProfile } from "./no import { createNostrProfileFormState } from "./view.nostr-profile-form.ts"; import { renderChannels } from "./view.ts"; import type { ChannelPairingPrompt } from "./view.types.ts"; +import { runWhatsAppLogoutConfirmation } from "./whatsapp-logout.ts"; import { ChannelWizardHost } from "./wizard-host.ts"; type NostrProfileFormState = ReturnType | null; @@ -283,6 +284,23 @@ class ChannelsPage extends OpenClawLightDomElement { await context.channels.refresh(true); } + private async confirmWhatsAppLogout() { + const context = this.context; + const channels = context.channels; + const scope = this.gateway.capture(); + if (!scope || this.channelsSource !== channels) { + return; + } + await runWhatsAppLogoutConfirmation({ + channels, + getWizardAccountId: () => this.wizardHost.whatsappAccountId, + isCurrent: () => + this.gateway.isCurrent(scope) && + this.context === context && + this.channelsSource === channels, + }); + } + private resolveNostrAccountId(): string { const accounts = this.context?.channels.state.channelsSnapshot?.channelAccounts?.nostr ?? []; return this.nostrProfileAccountId ?? accounts[0]?.accountId ?? "default"; @@ -706,8 +724,7 @@ class ChannelsPage extends OpenClawLightDomElement { void context.channels.startWhatsApp(force, this.wizardHost.whatsappAccountId), onWhatsAppWait: () => void context.channels.waitWhatsApp(this.wizardHost.whatsappAccountId), - onWhatsAppLogout: () => - void context.channels.logoutWhatsApp(this.wizardHost.whatsappAccountId), + onWhatsAppLogout: () => void this.confirmWhatsAppLogout(), onShowAdvancedSettings: (enabled) => this.setShowAdvancedSettings(enabled), onConfigPatch: (path, value) => context.runtimeConfig.patchForm(path, value), onConfigSave: () => void this.saveChannelConfig(), diff --git a/ui/src/pages/channels/whatsapp-logout.ts b/ui/src/pages/channels/whatsapp-logout.ts new file mode 100644 index 000000000000..ec41d236a3c1 --- /dev/null +++ b/ui/src/pages/channels/whatsapp-logout.ts @@ -0,0 +1,60 @@ +// Page-side WhatsApp logout confirmation preserves the selected account and +// Gateway owner across the operator's awaited decision. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import type { ApplicationContext } from "../../app/context.ts"; +import { showConfirmDialog } from "../../components/confirm-dialog.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; + +type WhatsAppLogoutParams = { + channels: ApplicationContext["channels"]; + getWizardAccountId: () => string | undefined; + isCurrent: () => boolean; +}; + +function resolveWhatsAppLogoutAccount( + channels: ApplicationContext["channels"], + wizardAccountId: string | undefined, +) { + const snapshot = channels.state.channelsSnapshot; + const accountId = wizardAccountId ?? snapshot?.channelDefaultAccountId.whatsapp ?? "default"; + const account = resolveChannelAccounts(snapshot?.channelAccounts, "whatsapp").find( + (candidate) => candidate.accountId === accountId, + ); + if (!account && wizardAccountId !== undefined) { + return null; + } + return { + accountId, + linked: + account?.linked ?? + (wizardAccountId === undefined + ? asNullableRecord(snapshot?.channels.whatsapp)?.linked + : undefined), + }; +} + +export async function runWhatsAppLogoutConfirmation(params: WhatsAppLogoutParams): Promise { + const account = resolveWhatsAppLogoutAccount(params.channels, params.getWizardAccountId()); + if (!account || !params.isCurrent()) { + return; + } + const confirmed = await showConfirmDialog({ + title: t("channels.whatsapp.logoutConfirmTitle", { accountId: account.accountId }), + message: t("channels.whatsapp.logoutConfirmMessage", { accountId: account.accountId }), + confirmLabel: t("common.logout"), + danger: true, + }); + if (!confirmed || !params.isCurrent()) { + return; + } + const currentAccount = resolveWhatsAppLogoutAccount(params.channels, params.getWizardAccountId()); + if ( + !currentAccount || + currentAccount.accountId !== account.accountId || + currentAccount.linked !== account.linked + ) { + return; + } + await params.channels.logoutWhatsApp(account.accountId); +} From bad4d34982db98be6a51fa4374d70de945ef37bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:08:42 -0700 Subject: [PATCH 021/165] test(agents): remove aggregate-lane noise from src/agents suites (#122432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supervisor-capture tests asserted real telegram/slack channel ids, so their pass/fail depended on which channel plugins the selected vitest lane loads (stubs when focused, real normalizers under unit-fast); switch to a neutral synthetic channel. Two embedded-runner tests carried a shared literal /tmp/openclaw.sqlite storePath — one actually opened it, colliding with stale artifacts across parallel runs; both now use realpath'd auto-cleaned per-test temp dirs. Evidence in PR #122164 body; pre-fix aggregates failed 12/1202 (cli) and 4/4750 (embedded), post-fix green in default and shuffled order. --- .../execute.supervisor-capture.test.ts | 64 ++++++++++--------- .../run.overflow-compaction.test.ts | 11 +++- ...tempt-transcript-lifecycle-prepare.test.ts | 11 +++- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 47bfee3206c8..6a81f9dcc74f 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -46,6 +46,8 @@ vi.mock("../../gateway/mcp-http.loopback-runtime.js", async (importOriginal) => type ProcessSupervisor = ReturnType; type SupervisorSpawnInput = Parameters[0]; +const TEST_MESSAGE_CHANNEL = "test-channel"; + function recordMcpLoopbackToolCallResult(params: { captureKey: string; toolName: string; @@ -1680,7 +1682,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -1702,7 +1704,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -1732,7 +1734,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", text: "done", }), @@ -1752,7 +1754,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", text: "done", }, @@ -1792,7 +1794,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", text: "done", }), @@ -1806,7 +1808,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: `chat${index}`, message: "done", }, @@ -1856,7 +1858,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: `chat${index}`, message: "done", }, @@ -1918,7 +1920,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -1965,7 +1967,7 @@ describe("executePreparedCliRun supervisor output capture", () => { name: "mcp__openclaw__message", input: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", dryRun: true, @@ -2011,7 +2013,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -2073,7 +2075,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "edit", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -2103,7 +2105,7 @@ describe("executePreparedCliRun supervisor output capture", () => { it("preserves the current provider for implicit message send targets", async () => { const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" }); context.mcpDeliveryCapture = true; - context.params.messageChannel = "slack"; + context.params.messageChannel = TEST_MESSAGE_CHANNEL; context.params.currentChannelId = "C123"; context.params.currentThreadTs = "1700000000.000100"; supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { @@ -2136,7 +2138,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ - provider: "slack", + provider: TEST_MESSAGE_CHANNEL, to: "C123", }), ]); @@ -2152,7 +2154,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", mediaUrl: "https://example.com/photo.png", @@ -2180,7 +2182,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", text: "done", mediaUrls: ["https://example.com/photo.png"], @@ -2198,7 +2200,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -2224,7 +2226,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", text: "done", }), @@ -2241,7 +2243,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "poll", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], @@ -2268,7 +2270,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", }), ]); @@ -2279,7 +2281,7 @@ describe("executePreparedCliRun supervisor output capture", () => { action: "reply", args: { action: "reply", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, @@ -2288,7 +2290,7 @@ describe("executePreparedCliRun supervisor output capture", () => { action: "sticker", args: { action: "sticker", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", stickerId: "sticker-1", }, @@ -2326,7 +2328,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", }), ]); @@ -2342,7 +2344,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "thread-create", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "new thread", }, @@ -2368,7 +2370,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", }), ]); @@ -2377,7 +2379,7 @@ describe("executePreparedCliRun supervisor output capture", () => { it("records current-target evidence for confirmed implicit reply delivery", async () => { const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" }); context.mcpDeliveryCapture = true; - context.params.messageChannel = "telegram"; + context.params.messageChannel = TEST_MESSAGE_CHANNEL; context.params.currentChannelId = "chat123"; supervisorSpawnMock.mockImplementationOnce(async (...spawnArgs: unknown[]) => { const input = spawnArgs[0] as SupervisorSpawnInput; @@ -2410,7 +2412,7 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.messagingToolSentTargets).toEqual([ expect.objectContaining({ tool: "message", - provider: "telegram", + provider: TEST_MESSAGE_CHANNEL, to: "chat123", }), ]); @@ -2508,7 +2510,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "x".repeat(20 * 1024), }, @@ -2532,7 +2534,11 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.didSendViaMessagingTool).toBe(true); expect(result.messagingToolSentTargets).toEqual([ - expect.objectContaining({ tool: "message", provider: "telegram", to: "chat123" }), + expect.objectContaining({ + tool: "message", + provider: TEST_MESSAGE_CHANNEL, + to: "chat123", + }), ]); }); @@ -2583,7 +2589,7 @@ describe("executePreparedCliRun supervisor output capture", () => { toolName: "message", args: { action: "send", - channel: "telegram", + channel: TEST_MESSAGE_CHANNEL, target: "chat123", message: "done", }, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 43581fc592cf..94afb30819f3 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js"; import type { ContextEngine } from "../../context-engine/types.js"; @@ -12,6 +14,8 @@ import { createEmbeddedRunContextRecoveryState } from "./run/context-recovery-st import type { PreparedEmbeddedRunInput } from "./run/execution-context.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + // Keep this dedicated leaf on the compaction composition boundary. Runtime/auth/lane policy is // covered at its direct owners so this shard never reloads the complete public runner graph. const baseRunParams = { @@ -167,7 +171,10 @@ describe("createEmbeddedRunCompactionRuntime", () => { agentId: "main", sessionId: "session-1", sessionKey: "agent:main:session-1", - storePath: "/tmp/openclaw.sqlite", + storePath: path.join( + tempDirs.make("openclaw-overflow-compaction-session-"), + "openclaw.sqlite", + ), }, adoptSessionId: vi.fn((sessionId?: string) => { if (sessionId) { diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts index 8d46654d5423..d233fcb189de 100644 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; import { prepareEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle-prepare.js"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + describe("prepareEmbeddedAttemptTranscriptLifecycle", () => { it("carries the admitted writer fence into nested transcript writes", async () => { const externalAbortController = { @@ -20,7 +24,10 @@ describe("prepareEmbeddedAttemptTranscriptLifecycle", () => { expectedWriterRunId: "run-a", sessionId: "session-a", sessionKey: "agent:main:test", - storePath: "/tmp/openclaw.sqlite", + storePath: path.join( + tempDirs.make("openclaw-attempt-transcript-lifecycle-"), + "openclaw.sqlite", + ), }, }, externalAbortController, From a7e4065dd7a28808fdacde29cd81444bdbe7f757 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:08:54 -0700 Subject: [PATCH 022/165] fix: compaction resolves undated model refs (#122422) * fix(agents): unify tiered model resolution for chat and compaction Manual /compact failed with Unknown_model when the agent model was configured as an undated ref (e.g. anthropic/claude-haiku-4-5): the chat run resolved models through a two-tier path (discovery-free lookup, then prepared stores with bundled static-catalog fallback) while both compaction entry points made a single bare resolveModelAsync call and dead-ended before the static catalog could resolve the undated id. resolveTieredModel is now the canonical resolution owner used by the chat run, direct compaction, and queued compaction; the duplicated chat-only tier logic and both bare compaction lookups are removed. Regression test proves chat and manual compaction resolve the same undated configured model through the shared owner. * fix(agents): satisfy lint and test-types on tiered resolution call sites Drop the redundant ?? {} spread fallbacks (spreading undefined is a no-op) and give the test registry mock its real (provider, modelId) signature for check:test-types. --- .../compact.hooks.test.ts | 5 +- .../embedded-agent-runner/compact.queued.ts | 15 +- .../direct-compaction-preparation.ts | 28 +-- .../model-resolution-consistency.test.ts | 214 ++++++++++++++++++ .../embedded-agent-runner/model-resolution.ts | 86 +++++++ .../embedded-agent-runner/run/model-setup.ts | 88 ++----- 6 files changed, 342 insertions(+), 94 deletions(-) create mode 100644 src/agents/embedded-agent-runner/model-resolution-consistency.test.ts create mode 100644 src/agents/embedded-agent-runner/model-resolution.ts diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 9dce2cd30198..b2f610a75c60 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -2420,7 +2420,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { } }); - it("uses the acquired gateway runtime generation for queued model resolution", async () => { + it("uses the acquired gateway runtime generation for queued tiered model resolution", async () => { await compactEmbeddedAgentSession( wrappedCompactionArgs({ allowGatewaySubagentBinding: true, @@ -2434,9 +2434,8 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { : undefined; expect(snapshot).toBeDefined(); expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toMatchObject({ - authStorage: {}, - modelRegistry: {}, preparedModelRuntime: snapshot, + skipAgentDiscovery: true, }); }); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 288df555d10c..85be4523eb47 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -56,6 +56,7 @@ import { resolveContextEngineCapabilities } from "./context-engine-capabilities. import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; +import { resolveTieredModel } from "./model-resolution.js"; import { resolveModelAsync } from "./model.js"; import type { EmbeddedAgentQueueHandle } from "./run-state.js"; import { @@ -437,7 +438,6 @@ async function compactResolvedContextEngine( let preparedHarnessRuntime = selectedHarnessRuntime; let preparedParams = params; try { - const preparedStores = preparedModelRuntime.createStores(); // Ensure the policy-selected harness plugin so selection can pick implicit codex. await ensureSelectedAgentHarnessPlugin({ config: params.config, @@ -450,15 +450,16 @@ async function compactResolvedContextEngine( workspaceDir: resolvedWorkspaceDir, pluginRegistry: requireActivePluginRegistry(), }); - const { - model: ceModel, - authStorage, - modelRegistry, - } = await resolveModelAsync(ceRuntimeProvider, ceModelId, agentDir, params.config, { + const { resolution: modelResolution } = await resolveTieredModel({ + provider: ceRuntimeProvider, + modelId: ceModelId, + agentDir, + config: params.config, + workspaceDir: resolvedWorkspaceDir, ...initialModelAuth, - ...preparedStores, preparedModelRuntime, }); + const { model: ceModel, authStorage, modelRegistry } = modelResolution; const ceRuntimeModel = ceModel as ProviderRuntimeModel | undefined; // Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware // selection can pick the credential-owning harness (codex for ChatGPT OAuth). diff --git a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts index 442d2c472ee3..44aa89ff9bdf 100644 --- a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts +++ b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts @@ -42,6 +42,7 @@ import { resolveCompactionRuntimeSelection, } from "./compaction-runtime-preparation.js"; import { log } from "./logger.js"; +import { resolveTieredModel } from "./model-resolution.js"; import { resolveModelAsync } from "./model.js"; import type { EmbeddedAgentCompactResult } from "./types.js"; @@ -135,25 +136,26 @@ export async function prepareDirectCompactionAttempt( }; }; const preparedModelRuntime = params.preparedModelRuntime; - const modelResolutionOptions = { - ...preparedModelRuntime.createStores(), - preparedModelRuntime, - workspaceDir: resolvedWorkspace, - }; - const { model, error, authStorage, modelRegistry } = await resolveModelAsync( - runtimeProvider, + const { resolution: modelResolution } = await resolveTieredModel({ + provider: runtimeProvider, modelId, agentDir, - params.config, - { - ...initialModelAuth, - ...modelResolutionOptions, - }, - ); + config: params.config, + workspaceDir: resolvedWorkspace, + ...initialModelAuth, + preparedModelRuntime, + }); + const { model, error, authStorage, modelRegistry } = modelResolution; if (!model) { const reason = error ?? `Unknown model: ${runtimeProvider}/${modelId}`; return { ok: false as const, result: fail(reason) }; } + const modelResolutionOptions = { + authStorage, + modelRegistry, + preparedModelRuntime, + workspaceDir: resolvedWorkspace, + }; // Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware // selection can pick the credential-owning harness (codex for ChatGPT OAuth); native // transcript compaction stays gated on the selected prepared harness. diff --git a/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts b/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts new file mode 100644 index 000000000000..43450f70e011 --- /dev/null +++ b/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveInitialEmbeddedRunModel } from "./run/runtime-resolution.js"; + +const STATIC_MODEL_ID = "claude-haiku-4-5"; +const PROVIDER = "anthropic"; + +const emptyModelRegistry = { + find: vi.fn((_provider: string, _modelId: string) => null), +}; +const authStorage = { + setRuntimeApiKey: vi.fn(), +}; +const staticCatalogModel = { + provider: PROVIDER, + id: STATIC_MODEL_ID, + name: "Claude Haiku 4.5", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + contextWindow: 200_000, + maxTokens: 64_000, +}; + +const resolveModelAsyncMock = vi.fn( + async ( + provider: string, + modelId: string, + _agentDir?: string, + _config?: unknown, + options?: { + allowBundledStaticCatalogFallback?: boolean; + authStorage?: unknown; + modelRegistry?: unknown; + }, + ) => { + const stores = { + authStorage: options?.authStorage ?? authStorage, + modelRegistry: options?.modelRegistry ?? emptyModelRegistry, + }; + if (options?.allowBundledStaticCatalogFallback) { + return { ...stores, model: staticCatalogModel }; + } + return { + ...stores, + error: `Unknown model: ${provider}/${modelId}`, + }; + }, +); + +vi.mock("./model.js", () => ({ + createEmptyAgentDiscoveryStores: () => ({ authStorage, modelRegistry: emptyModelRegistry }), + resolveModelAsync: resolveModelAsyncMock, +})); + +vi.mock("../harness/runtime-plugin.js", () => ({ + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), +})); + +vi.mock("../harness/selection.js", () => ({ + selectAgentHarness: vi.fn(() => ({ + id: "openclaw", + label: "OpenClaw", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + })), +})); + +vi.mock("../openai-routing.js", () => ({ + resolveSelectedOpenAIRuntimeProvider: ({ provider }: { provider: string }) => provider, +})); + +vi.mock("../prepared-model-runtime.js", () => ({ + prepareModelRuntimeSnapshot: vi.fn(), +})); + +vi.mock("./run/setup.js", () => ({ + buildBeforeModelResolveAttachments: vi.fn(() => []), + createNativeModelOwnedRuntimeModel: vi.fn(), + resolveHookModelSelection: vi.fn( + async ({ provider, modelId }: { provider: string; modelId: string }) => ({ + provider, + modelId, + }), + ), + resolveNativeModelOwnedHarnessId: vi.fn(() => undefined), +})); + +vi.mock("./compaction-runtime-preparation.js", () => ({ + resolveCompactionRuntimeSelection: ({ + provider, + modelId, + }: { + provider: string; + modelId: string; + }) => ({ + runtimePolicySessionKey: "agent:main:test", + runtimePolicyAgentId: "main", + boundHarnessRuntime: undefined, + selectedHarnessRuntimeOverride: undefined, + runtimeModelAuth: { plan: undefined, authProfileId: undefined, modelAuth: undefined }, + provider, + runtimeProvider: provider, + contextConfigProvider: provider, + modelId, + }), + prepareCompactionHarnessAuth: vi.fn(async () => ({ + runtimeAuthProfileStore: {}, + runtimeAuthPreparation: { + plan: { selectedAuthMode: "api-key" }, + attempts: [{ kind: "direct", plan: { selectedAuthMode: "api-key" } }], + }, + selectedPreparedHarness: { id: "openclaw" }, + providerUsesProfileScopedModelMetadata: false, + })), +})); + +vi.mock("../runtime-plan/resolve-auth.js", () => ({ + resolvePreparedRuntimeAuthAttempts: vi.fn(async ({ model, attempts }) => ({ + model, + auth: { apiKey: "test-api-key", mode: "api_key", source: "test" }, + plan: attempts[0].plan, + })), + resolvePreparedRuntimeModelAuth: vi.fn(), +})); + +vi.mock("../../plugins/provider-runtime.js", () => ({ + prepareProviderRuntimeAuth: vi.fn(async () => undefined), +})); + +vi.mock("../provider-secret-egress.js", () => ({ + protectPreparedProviderRuntimeAuth: (value: unknown) => value, + unwrapSecretSentinelsForProviderEgress: (value: unknown) => value, +})); + +vi.mock("../provider-request-config.js", () => ({ + applyPreparedRuntimeAuthToModel: (model: unknown) => model, +})); + +vi.mock("../sandbox.js", () => ({ + resolveSandboxContext: vi.fn(async () => undefined), +})); + +vi.mock("./compaction-runtime-context.js", () => ({ + resolveEmbeddedCompactionThinkingLevel: vi.fn(() => "off"), +})); + +vi.mock("./logger.js", () => ({ + log: { warn: vi.fn() }, +})); + +const { resolveEmbeddedRunModelSetup } = await import("./run/model-setup.js"); +const { prepareDirectCompactionAttempt } = await import("./direct-compaction-preparation.js"); + +describe("embedded model resolution consistency", () => { + it("resolves the same undated configured model for chat and manual compaction", async () => { + const config = { + agents: { + defaults: { + model: { primary: `${PROVIDER}/${STATIC_MODEL_ID}` }, + }, + }, + }; + const target = resolveInitialEmbeddedRunModel({ config }); + const preparedModelRuntime = { + agentDir: "/tmp/agents/main/agent", + config, + workspaceDir: "/tmp/openclaw-model-resolution", + pluginRegistry: {}, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => ({ authStorage, modelRegistry: emptyModelRegistry }), + }; + + const chat = await resolveEmbeddedRunModelSetup({ + runParams: { + config, + prompt: "hello", + sessionId: "chat-session", + agentId: "main", + } as never, + ...target, + agentDir: preparedModelRuntime.agentDir, + workspaceDir: preparedModelRuntime.workspaceDir, + globalLane: "test", + hookRunner: undefined, + hookContext: {} as never, + onHooksResolved: vi.fn(), + preparedModelRuntime: preparedModelRuntime as never, + }); + expect(chat.model).toMatchObject({ provider: PROVIDER, id: STATIC_MODEL_ID }); + + const compaction = await prepareDirectCompactionAttempt({ + config, + provider: target.provider, + model: target.modelId, + agentId: "main", + sessionId: "compact-session", + sessionKey: "agent:main:compact-session", + sessionFile: "agent:main:compact-session", + workspaceDir: preparedModelRuntime.workspaceDir, + preparedModelRuntime: preparedModelRuntime as never, + }); + + expect(emptyModelRegistry.find(PROVIDER, STATIC_MODEL_ID)).toBeNull(); + if (!compaction.ok) { + throw new Error(`manual compaction failed: ${compaction.result.reason}`); + } + expect(compaction.value.runtimeModel).toMatchObject({ + provider: PROVIDER, + id: STATIC_MODEL_ID, + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/model-resolution.ts b/src/agents/embedded-agent-runner/model-resolution.ts new file mode 100644 index 000000000000..9a2f38d69eaf --- /dev/null +++ b/src/agents/embedded-agent-runner/model-resolution.ts @@ -0,0 +1,86 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveDefaultAgentDir } from "../agent-scope.js"; +import type { AuthProfileCredential } from "../auth-profiles/types.js"; +import { + prepareModelRuntimeSnapshot, + type PreparedModelRuntimeSnapshot, +} from "../prepared-model-runtime.js"; +import { resolveModelAsync } from "./model.js"; + +type ModelResolution = Awaited>; + +/** Resolves embedded-run models through discovery first, then the prepared static catalog. */ +export async function resolveTieredModel(params: { + provider: string; + fallbackProvider?: string; + modelId: string; + agentDir: string; + config?: OpenClawConfig; + workspaceDir: string; + authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; + staticCatalogOwnsTransport?: boolean; +}): Promise<{ provider: string; resolution: ModelResolution }> { + const providers = + params.fallbackProvider && params.fallbackProvider !== params.provider + ? [params.provider, params.fallbackProvider] + : [params.provider]; + let firstResolution: ModelResolution | undefined; + const resolveCandidates = async (options: Parameters[4]) => { + for (const provider of providers) { + const resolution = await resolveModelAsync( + provider, + params.modelId, + params.agentDir, + params.config, + options, + ); + firstResolution ??= resolution; + if (resolution.model) { + return { provider, resolution }; + } + } + return undefined; + }; + const firstTier = await resolveCandidates({ + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: params.staticCatalogOwnsTransport, + preferBundledStaticCatalogTransport: params.staticCatalogOwnsTransport, + preparedModelRuntime: params.preparedModelRuntime, + workspaceDir: params.workspaceDir, + authProfileId: params.authProfileId, + authProfileMode: params.authProfileMode, + }); + if (firstTier) { + return firstTier; + } + if (params.staticCatalogOwnsTransport) { + return { + provider: params.fallbackProvider ?? params.provider, + resolution: firstResolution!, + }; + } + const config = params.config ?? {}; + const preparedModelRuntime = + params.preparedModelRuntime ?? + (await prepareModelRuntimeSnapshot({ + config, + agentDir: params.agentDir, + inheritedAuthDir: resolveDefaultAgentDir(config), + workspaceDir: params.workspaceDir, + })); + return ( + (await resolveCandidates({ + ...preparedModelRuntime.createStores(), + workspaceDir: params.workspaceDir, + authProfileId: params.authProfileId, + authProfileMode: params.authProfileMode, + allowBundledStaticCatalogFallback: true, + preparedModelRuntime, + })) ?? { + provider: params.fallbackProvider ?? params.provider, + resolution: firstResolution!, + } + ); +} diff --git a/src/agents/embedded-agent-runner/run/model-setup.ts b/src/agents/embedded-agent-runner/run/model-setup.ts index a3c1330b3c3b..4a484f350a21 100644 --- a/src/agents/embedded-agent-runner/run/model-setup.ts +++ b/src/agents/embedded-agent-runner/run/model-setup.ts @@ -1,14 +1,11 @@ import { requireActivePluginRegistry } from "../../../plugins/runtime.js"; -import { resolveDefaultAgentDir } from "../../agent-scope.js"; import { FailoverError } from "../../failover-error.js"; import { ensureSelectedAgentHarnessPlugin } from "../../harness/runtime-plugin.js"; import { selectAgentHarness } from "../../harness/selection.js"; import { resolveSelectedOpenAIRuntimeProvider } from "../../openai-routing.js"; -import { - prepareModelRuntimeSnapshot, - type PreparedModelRuntimeSnapshot, -} from "../../prepared-model-runtime.js"; -import { createEmptyAgentDiscoveryStores, resolveModelAsync } from "../model.js"; +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; +import { resolveTieredModel } from "../model-resolution.js"; +import { createEmptyAgentDiscoveryStores } from "../model.js"; import type { RunEmbeddedAgentParams } from "./params.js"; import { resolveRequestStreamTransportOverrides } from "./runtime-resolution.js"; import { @@ -99,8 +96,7 @@ export async function resolveEmbeddedRunModelSetup(params: { const nativeModelOwned = nativeModelOwnedHarnessId !== undefined; const modelConfigProvider = provider; let resolvedModelProvider = provider; - let firstModelResolution: Awaited> | undefined; - let modelResolution: Awaited> | undefined; + let modelResolution; if (nativeModelOwned) { modelResolution = { model: createNativeModelOwnedRuntimeModel({ provider, modelId }), @@ -116,69 +112,19 @@ export async function resolveEmbeddedRunModelSetup(params: { config: runParams.config, workspaceDir: params.workspaceDir, }); - const modelResolutionProviders = - selectedRuntimeProvider !== provider ? [selectedRuntimeProvider, provider] : [provider]; - for (const candidateProvider of modelResolutionProviders) { - const candidateResolution = await resolveModelAsync( - candidateProvider, - modelId, - params.agentDir, - runParams.config, - { - // Dynamic hooks can resolve an explicit model without generating models.json first. - skipAgentDiscovery: true, - allowBundledStaticCatalogFallback: pluginHarnessOwnsTransport, - preferBundledStaticCatalogTransport: pluginHarnessOwnsTransport, - preparedModelRuntime: params.preparedModelRuntime, - workspaceDir: params.workspaceDir, - authProfileId: runParams.authProfileId, - }, - ); - firstModelResolution ??= candidateResolution; - if (candidateResolution.model) { - resolvedModelProvider = candidateProvider; - modelResolution = candidateResolution; - break; - } - } - if (!modelResolution && pluginHarnessOwnsTransport) { - modelResolution = firstModelResolution; - } - if (!modelResolution) { - const config = runParams.config ?? {}; - const preparedModelRuntime = - params.preparedModelRuntime ?? - (await prepareModelRuntimeSnapshot({ - config, - agentDir: params.agentDir, - inheritedAuthDir: resolveDefaultAgentDir(config), - workspaceDir: params.workspaceDir, - })); - const preparedStores = preparedModelRuntime.createStores(); - for (const candidateProvider of modelResolutionProviders) { - const candidateResolution = await resolveModelAsync( - candidateProvider, - modelId, - params.agentDir, - runParams.config, - { - authStorage: preparedStores.authStorage, - modelRegistry: preparedStores.modelRegistry, - workspaceDir: params.workspaceDir, - authProfileId: runParams.authProfileId, - allowBundledStaticCatalogFallback: true, - preparedModelRuntime, - }, - ); - firstModelResolution ??= candidateResolution; - if (candidateResolution.model) { - resolvedModelProvider = candidateProvider; - modelResolution = candidateResolution; - break; - } - } - } - modelResolution ??= firstModelResolution; + const tieredResolution = await resolveTieredModel({ + provider: selectedRuntimeProvider, + ...(selectedRuntimeProvider !== provider ? { fallbackProvider: provider } : {}), + modelId, + agentDir: params.agentDir, + config: runParams.config, + workspaceDir: params.workspaceDir, + authProfileId: runParams.authProfileId, + preparedModelRuntime: params.preparedModelRuntime, + staticCatalogOwnsTransport: pluginHarnessOwnsTransport, + }); + resolvedModelProvider = tieredResolution.provider; + modelResolution = tieredResolution.resolution; } if (!modelResolution) { throw new FailoverError(`Unknown model: ${provider}/${modelId}`, { From 00fc1bd123820eb759bfe65ba778c37c1f4c0f9f Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Tue, 11 Aug 2026 22:09:46 -0700 Subject: [PATCH 023/165] fix(slack): scope enterprise channel and user policies by workspace (#122346) * fix(slack): scope channel policies by workspace * fix(slack): scope user policies by workspace * fix(slack): preserve workspace policies at ingress * test(slack): use canonical workspace ids * chore(slack): remove stale allowlist import * fix(slack): require workspace-scoped grid policies * test(slack): scope enterprise policy fixtures * style(slack): format workspace policy changes * fix(slack): preserve workspace policy wildcards * fix(slack): retain workspace system-event policies * fix(slack): scope bot policy identities * fix(slack): retain workspace in group policy * fix(slack): preserve workspace DM allowlists --------- Co-authored-by: Sarah Fortune --- docs/channels/slack.md | 33 +++-- docs/gateway/config-channels.md | 8 +- extensions/slack/src/action-runtime.ts | 19 ++- extensions/slack/src/doctor.test.ts | 18 ++- extensions/slack/src/doctor.ts | 20 ++- extensions/slack/src/group-policy.test.ts | 89 ++++++++++++++ extensions/slack/src/group-policy.ts | 22 +++- .../slack/src/monitor/allow-list.test.ts | 61 ++++++++++ extensions/slack/src/monitor/allow-list.ts | 66 +++++++++- extensions/slack/src/monitor/auth.test.ts | 69 ++++++++++- extensions/slack/src/monitor/auth.ts | 114 +++++++++++++----- .../slack/src/monitor/channel-config.ts | 19 ++- extensions/slack/src/monitor/context.test.ts | 42 +++++++ extensions/slack/src/monitor/context.ts | 11 +- extensions/slack/src/monitor/dm-auth.test.ts | 25 ++++ extensions/slack/src/monitor/dm-auth.ts | 2 + .../src/monitor/enterprise-install.test.ts | 35 +++++- .../slack/src/monitor/enterprise-install.ts | 30 +++-- .../slack/src/monitor/events/channels.ts | 1 + .../events/interactions.block-actions.ts | 3 + .../slack/src/monitor/events/reactions.ts | 4 + .../monitor/message-handler/prepare.test.ts | 52 ++++++++ .../src/monitor/message-handler/prepare.ts | 6 +- extensions/slack/src/monitor/monitor.test.ts | 87 +++++++++++++ extensions/slack/src/monitor/slash.ts | 4 + extensions/slack/src/resolve-channels.test.ts | 15 +++ extensions/slack/src/resolve-channels.ts | 39 +++++- extensions/slack/src/resolve-users.test.ts | 15 +++ extensions/slack/src/resolve-users.ts | 31 ++++- extensions/slack/src/security-doctor.ts | 17 ++- extensions/slack/src/target-parsing.ts | 6 +- extensions/slack/src/targets.test.ts | 10 ++ 32 files changed, 881 insertions(+), 92 deletions(-) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 2b4d0d5ce92e..41cee93e5d7b 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -268,13 +268,17 @@ the enterprise account with the same Request URL path: allowFrom: ["*"], groupPolicy: "allowlist", channels: { - C0123456789: { requireMention: true }, + "team:T0123456789:channel:C0123456789": { requireMention: true }, }, }, }, } ``` +For each selected workspace, open it in Slack's web app and copy the `T...` +workspace ID from `https://app.slack.com/client/T.../...`. Use that workspace ID +with the channel's `C...` ID in every qualified policy key, as shown above. + At startup, OpenClaw uses Slack `auth.test` to detect whether the token belongs to a workspace installation or an Enterprise Grid org-wide installation. No installation-mode setting is required. Slack remains the source of truth for @@ -324,14 +328,18 @@ validated listener-owned client remains in the active event turn. The in-memory send queue and thread-participation records are partitioned by that event's workspace; the client itself is never serialized or persisted. -Channel policy keys accept raw stable Slack channel IDs, `channel:`, or the -`"*"` wildcard. `dm.groupChannels` accepts raw stable channel IDs or -`channel:`, but not `"*"`. OpenClaw normalizes the ID forms to the raw -channel ID for runtime matching; the channel prefixes `slack:`, `group:`, and -`mpim:` fail startup. +Enterprise channel policy keys must use +`team::channel:` or the `"*"` wildcard. +`dm.groupChannels` requires the workspace-qualified form and does not accept +`"*"`. A delivered Enterprise event never falls back from its qualified +workspace and channel identity to a bare channel ID. Workspace installations +retain raw stable channel IDs and `channel:` compatibility. The channel +prefixes `slack:`, `group:`, and `mpim:` fail startup. -User policy entries in `allowFrom`, `reactionAllowlist`, and per-channel `users` -accept raw stable Slack user IDs, `slack:`, `user:`, or `"*"`. +Enterprise user policy entries in `allowFrom`, `reactionAllowlist`, and +per-channel `users` must use `team::user:` or `"*"`. A +workspace-scoped sender never matches a bare user ID. Workspace installations +retain raw stable user IDs, `slack:`, and `user:` compatibility. Enterprise `toolsBySender` keys accept raw stable user IDs, `id:`, `channel:slack:`, or `"*"`. Names, slugs, display names, and email addresses fail startup. IDs must use Slack's canonical uppercase prefix and body @@ -351,8 +359,9 @@ rejected before authorization or system-event handling. Enterprise DMs support the same `disabled`, `open`, `allowlist`, and `pairing` policies as workspace installs. Pairing approvals are stored as `team::user:` and are applied only to events from that -workspace. Explicit account `allowFrom` entries remain organization-wide; -channel and sender policy continues to apply to channel messages. +workspace. Explicit account `allowFrom` entries use the same qualified form and +apply only to that workspace; channel and sender policy continues to apply to +channel messages. ## Install @@ -1303,7 +1312,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r - `allowlist` - `disabled` - Channel allowlist lives under `channels.slack.channels` and **must use stable Slack channel IDs** (for example `C12345678`) as config keys. + Channel allowlist lives under `channels.slack.channels` and **must use stable Slack channel IDs** (for example `C12345678`) as config keys. Enterprise Grid org installs require `team::channel:` so policies cannot cross workspace boundaries. Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). @@ -1936,7 +1945,7 @@ Primary reference: [Configuration reference - Slack](/gateway/config-channels#sl Check, in order: - `groupPolicy` - - channel allowlist (`channels.slack.channels`) — **keys must be channel IDs** (`C12345678`), not names (`#channel-name`). Name-based keys silently fail under `groupPolicy: "allowlist"` because channel routing is ID-first by default. To find an ID: right-click the channel in Slack → **Copy link** — the `C...` value at the end of the URL is the channel ID. + - channel allowlist (`channels.slack.channels`) — **keys must be channel IDs** (`C12345678`) or workspace-qualified channel targets (`team::channel:`), not names (`#channel-name`). Name-based keys silently fail under `groupPolicy: "allowlist"` because channel routing is ID-first by default. To find an ID: right-click the channel in Slack → **Copy link** — the `C...` value at the end of the URL is the channel ID. - `requireMention` - per-channel `users` allowlist - `messages.groupChat.visibleReplies`: normal group/channel requests default to `"automatic"`. If you opted into `"message_tool"` and logs show assistant text with no `message(action=send)` call, the model missed the visible message-tool path. Final text stays private in this mode; inspect the gateway verbose log for suppressed payload metadata, or set it to `"automatic"` if you want every normal assistant final reply posted through the legacy path. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 87234c2fc71f..c4b074c9d5e8 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -468,9 +468,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Slack detects Enterprise Grid org-wide installations automatically from the bot token with `auth.test`; no installation-mode setting is required. Enterprise DMs support `disabled`, `open`, `allowlist`, and workspace-scoped - `pairing`. Channel and user policies must use stable Slack IDs; mutable names - and unsupported channel prefixes fail startup. Mention-pattern channel - scopes and static route-binding peers use workspace-qualified Slack targets. + `pairing`. Channel and user policies must use + `team::channel:` or `team::user:`; + bare IDs, mutable names, and unsupported channel prefixes fail startup. + Mention-pattern channel scopes and static route-binding peers use + workspace-qualified Slack targets. Direct Socket Mode or HTTP messages, mentions, workspace-qualified actions, deferred delivery, proactive sends, supported event listeners and interactions, static route bindings, and Slack-native approvals from diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 387cee5112f5..e6cb787549df 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -11,6 +11,8 @@ import type { ResolvedSlackAccount } from "./accounts.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import type { SlackConversationInfo } from "./channel-type.js"; import { assertSlackDetachedTargetAllowed } from "./detached-target-admission.js"; +import { buildSlackChannelIdCandidates } from "./group-policy.js"; +import { getSlackInstallationKind } from "./installation-identity-state.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { resolveSlackChannelConfig } from "./monitor/channel-config.js"; import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js"; @@ -282,6 +284,7 @@ function resolveSlackChannelReadPolicy(params: { account: ResolvedSlackAccount; cfg: OpenClawConfig; channelId: string; + teamId?: string; channelName?: string; conversationReadOrigin?: ConversationReadInvocationOrigin; metadataResolved?: boolean; @@ -290,6 +293,8 @@ function resolveSlackChannelReadPolicy(params: { const channels = params.account.config.channels; const channelKeys = Object.keys(channels ?? {}); const channelConfig = resolveSlackChannelConfig({ + teamId: params.teamId, + allowUnscoped: getSlackInstallationKind(params.account.accountId) !== "enterprise", channelId: params.channelId, channelName: params.channelName, channels, @@ -344,7 +349,7 @@ function resolveSlackChannelReadPolicy(params: { params.account.config.dm?.enabled !== false && params.account.config.dm?.groupEnabled === true && (params.currentConversation || - isSlackGroupDmTargetConfigured(params.account, params.channelId)), + isSlackGroupDmTargetConfigured(params.account, params.channelId, params.teamId)), shouldResolveName, }; } @@ -461,16 +466,26 @@ async function assertSlackReadTargetAllowed(params: { } } -function isSlackGroupDmTargetConfigured(account: ResolvedSlackAccount, channelId: string): boolean { +function isSlackGroupDmTargetConfigured( + account: ResolvedSlackAccount, + channelId: string, + teamId?: string, +): boolean { const entries = account.config.dm?.groupChannels ?? []; if (entries.length === 0) { return true; } + const candidates = new Set( + buildSlackChannelIdCandidates(channelId, teamId, { + allowUnscoped: getSlackInstallationKind(account.accountId) !== "enterprise", + }).map((candidate) => candidate.toLowerCase()), + ); const target = channelId.trim().toLowerCase(); return entries.some((entry) => { const candidate = String(entry).trim().toLowerCase(); return ( candidate === "*" || + candidates.has(candidate) || candidate === target || candidate === `slack:${target}` || candidate === `channel:${target}` || diff --git a/extensions/slack/src/doctor.test.ts b/extensions/slack/src/doctor.test.ts index 1b8f6615f490..2080b49bf8de 100644 --- a/extensions/slack/src/doctor.test.ts +++ b/extensions/slack/src/doctor.test.ts @@ -174,6 +174,19 @@ describe("slack doctor", () => { ).toBe(true); }); + it("accepts workspace-qualified channel and user ids as stable policy entries", async () => { + const warnings = await collectSlackWarnings({ + allowFrom: ["team:T11111111:user:U01234567"], + channels: { + "team:T11111111:channel:C01234567": { + users: ["team:T11111111:user:U01234567"], + }, + }, + }); + + expect(warnings).toEqual([]); + }); + it("warns for name-keyed allowlist channels but accepts routed ID forms (#81665)", async () => { const warnings = await collectSlackWarnings({ channels: { @@ -183,9 +196,11 @@ describe("slack doctor", () => { c0al2gdua7k: {}, "channel:C0AL2GDUA7L": {}, "channel:c0al2gdua7m": {}, + "team:T11111111:channel:C0AL2GDUA7S": {}, D0AL2GDUA7Q: {}, "channel:d0al2gdua7r": {}, "channel:dabcdefgh": {}, + "team:T11111111:channel:D0AL2GDUA7T": {}, "channel:customers": {}, "CHANNEL:C0AL2GDUA7N": {}, "channel:C0al2gdua7p": {}, @@ -208,10 +223,11 @@ describe("slack doctor", () => { const dmWarnings = warnings.filter((warning) => warning.includes("is a Slack DM conversation ID"), ); - expect(dmWarnings).toHaveLength(3); + expect(dmWarnings).toHaveLength(4); expect(dmWarnings[0]).toContain('channels.slack.channels."D0AL2GDUA7Q"'); expect(dmWarnings[1]).toContain('channels.slack.channels."channel:d0al2gdua7r"'); expect(dmWarnings[2]).toContain('channels.slack.channels."channel:dabcdefgh"'); + expect(dmWarnings[3]).toContain('channels.slack.channels."team:T11111111:channel:D0AL2GDUA7T"'); expect(dmWarnings[0]).toContain("channels.slack.dmPolicy"); }); diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index 177dbf925d91..15524809b900 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -14,6 +14,7 @@ import { } from "./doctor-contract.js"; import { probeSlack } from "./probe.js"; import { isSlackMutableAllowEntry } from "./security-doctor.js"; +import { parseSlackTarget } from "./target-parsing.js"; const collectSlackMutableAllowlistWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({ @@ -45,7 +46,9 @@ const SLACK_CHANNEL_NAME_RE = /^[\p{L}\p{M}\p{N}_-]{1,80}$/u; const SLACK_CHANNEL_NAME_ALPHANUMERIC_RE = /[\p{L}\p{N}]/u; function looksLikeSlackChannelId(channelKey: string): boolean { + const workspaceChannelId = parseWorkspaceQualifiedChannelId(channelKey); return ( + (workspaceChannelId !== undefined && /^[CG]/i.test(workspaceChannelId)) || SLACK_CANONICAL_CHANNEL_ID_RE.test(channelKey) || SLACK_LOWERCASE_CHANNEL_ID_RE.test(channelKey) || SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE.test(channelKey) || @@ -54,11 +57,26 @@ function looksLikeSlackChannelId(channelKey: string): boolean { } function looksLikeSlackDmId(channelKey: string): boolean { + const workspaceChannelId = parseWorkspaceQualifiedChannelId(channelKey); return ( - SLACK_CANONICAL_DM_ID_RE.test(channelKey) || SLACK_PREFIXED_LOWERCASE_DM_ID_RE.test(channelKey) + (workspaceChannelId !== undefined && /^D/i.test(workspaceChannelId)) || + SLACK_CANONICAL_DM_ID_RE.test(channelKey) || + SLACK_PREFIXED_LOWERCASE_DM_ID_RE.test(channelKey) ); } +function parseWorkspaceQualifiedChannelId(channelKey: string): string | undefined { + if (!/^team:/i.test(channelKey)) { + return undefined; + } + try { + const target = parseSlackTarget(channelKey); + return target?.kind === "channel" && target.teamId ? target.id : undefined; + } catch { + return undefined; + } +} + function looksLikeSlackChannelNameKey(channelKey: string): boolean { const name = channelKey.startsWith("#") ? channelKey.slice(1) : channelKey; return ( diff --git a/extensions/slack/src/group-policy.test.ts b/extensions/slack/src/group-policy.test.ts index a1a84436858d..b81faad5fd53 100644 --- a/extensions/slack/src/group-policy.test.ts +++ b/extensions/slack/src/group-policy.test.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; +import { registerSlackInstallationState } from "./installation-identity-state.js"; const cfg = { channels: { @@ -100,6 +101,94 @@ describe("slack group policy", () => { }, ); + it("scopes Enterprise mention and tool policies to the event workspace", () => { + const installationState = registerSlackInstallationState("default", "enterprise"); + const enterpriseCfg = { + channels: { + slack: { + channels: { + "team:T11111111:channel:C01234567": { + requireMention: false, + tools: { allow: ["message.send"] }, + }, + "team:T22222222:channel:C01234567": { + requireMention: true, + tools: { deny: ["exec"] }, + }, + }, + }, + }, + } as OpenClawConfig; + + try { + expect( + resolveSlackGroupRequireMention({ + cfg: enterpriseCfg, + groupId: "C01234567", + groupSpace: "T11111111", + }), + ).toBe(false); + expect( + resolveSlackGroupToolPolicy({ + cfg: enterpriseCfg, + groupId: "C01234567", + groupSpace: "T11111111", + }), + ).toEqual({ allow: ["message.send"] }); + expect( + resolveSlackGroupRequireMention({ + cfg: enterpriseCfg, + groupId: "C01234567", + groupSpace: "T22222222", + }), + ).toBe(true); + expect( + resolveSlackGroupToolPolicy({ + cfg: enterpriseCfg, + groupId: "C01234567", + groupSpace: "T22222222", + }), + ).toEqual({ deny: ["exec"] }); + } finally { + installationState.release(); + } + }); + + it("retains bare channel policy matching for workspace installs", () => { + const installationState = registerSlackInstallationState("default", "workspace"); + const workspaceCfg = { + channels: { + slack: { + channels: { + C01234567: { + requireMention: false, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + } as OpenClawConfig; + + try { + expect( + resolveSlackGroupRequireMention({ + cfg: workspaceCfg, + groupId: "C01234567", + groupSpace: "T11111111", + }), + ).toBe(false); + expect( + resolveSlackGroupToolPolicy({ + cfg: workspaceCfg, + groupId: "C01234567", + groupSpace: "T11111111", + }), + ).toEqual({ allow: ["message.send"] }); + } finally { + installationState.release(); + } + }); + it("prefers the exact channel ID when case variants have different policies", () => { const caseSensitiveCfg = { channels: { diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index f357c79e0703..7b9dd1e8a607 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -12,6 +12,7 @@ import { import { buildChannelKeyCandidates } from "openclaw/plugin-sdk/channel-targets"; import { normalizeHyphenSlug } from "openclaw/plugin-sdk/string-normalization-runtime"; import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; +import { getSlackInstallationKind } from "./installation-identity-state.js"; type SlackChannelPolicyEntry = { requireMention?: boolean; @@ -19,15 +20,31 @@ type SlackChannelPolicyEntry = { toolsBySender?: GroupToolPolicyBySenderConfig; }; -export function buildSlackChannelIdCandidates(channelId: string | null | undefined): string[] { +export function buildSlackChannelIdCandidates( + channelId: string | null | undefined, + teamId?: string | null, + options?: { allowUnscoped?: boolean }, +): string[] { const trimmedId = channelId?.trim(); if (!trimmedId) { return []; } const lowercaseId = trimmedId.toLowerCase(); const uppercaseId = trimmedId.toUpperCase(); + const exactTeamId = teamId || undefined; + const lowercaseTeamId = exactTeamId?.toLowerCase(); + const uppercaseTeamId = exactTeamId?.toUpperCase(); // Inbound Slack IDs are uppercase, but persisted session group IDs are lowercase. + const scopedCandidates = buildChannelKeyCandidates( + exactTeamId ? `team:${exactTeamId}:channel:${trimmedId}` : undefined, + lowercaseTeamId ? `team:${lowercaseTeamId}:channel:${lowercaseId}` : undefined, + uppercaseTeamId ? `team:${uppercaseTeamId}:channel:${uppercaseId}` : undefined, + ); + if (exactTeamId && options?.allowUnscoped !== true) { + return scopedCandidates; + } return buildChannelKeyCandidates( + ...scopedCandidates, trimmedId, lowercaseId, uppercaseId, @@ -69,8 +86,9 @@ function resolveSlackGroupPolicyScope(params: ChannelGroupContext) { | Record | undefined; const channelName = params.groupChannel?.replace(/^#/, ""); + const allowUnscoped = getSlackInstallationKind(accountId) !== "enterprise"; const candidates = buildChannelKeyCandidates( - ...buildSlackChannelIdCandidates(params.groupId), + ...buildSlackChannelIdCandidates(params.groupId, params.groupSpace, { allowUnscoped }), channelName ? `#${channelName}` : undefined, channelName, normalizeHyphenSlug(channelName), diff --git a/extensions/slack/src/monitor/allow-list.test.ts b/extensions/slack/src/monitor/allow-list.test.ts index 3761b12342f4..a056a8c0402c 100644 --- a/extensions/slack/src/monitor/allow-list.test.ts +++ b/extensions/slack/src/monitor/allow-list.test.ts @@ -63,4 +63,65 @@ describe("slack/allow-list", () => { false, ); }); + + it("matches a workspace-qualified user only in that workspace", () => { + const allowList = ["team:t11111111:user:u01234567"]; + + expect( + resolveSlackAllowListMatch({ + allowList, + teamId: "T11111111", + id: "U01234567", + }), + ).toEqual({ + allowed: true, + matchKey: "team:t11111111:user:u01234567", + matchSource: "workspace-id", + }); + expect( + resolveSlackAllowListMatch({ + allowList, + teamId: "T22222222", + id: "U01234567", + }), + ).toEqual({ allowed: false }); + expect( + resolveSlackAllowListMatch({ + allowList: ["u01234567"], + teamId: "T22222222", + id: "U01234567", + }), + ).toEqual({ allowed: false }); + expect( + resolveSlackAllowListMatch({ + allowList: ["u01234567"], + teamId: "T22222222", + id: "U01234567", + allowUnscoped: true, + }), + ).toEqual({ allowed: true, matchKey: "u01234567", matchSource: "id" }); + }); + + it("matches a workspace-qualified bot only in that workspace", () => { + const allowList = ["team:t11111111:user:b01234567"]; + + expect( + resolveSlackAllowListMatch({ + allowList, + teamId: "T11111111", + id: "B01234567", + }), + ).toEqual({ + allowed: true, + matchKey: "team:t11111111:user:b01234567", + matchSource: "workspace-id", + }); + expect( + resolveSlackAllowListMatch({ + allowList, + teamId: "T22222222", + id: "B01234567", + }), + ).toEqual({ allowed: false }); + }); }); diff --git a/extensions/slack/src/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts index 6281629a62b5..c184f1b59624 100644 --- a/extensions/slack/src/monitor/allow-list.ts +++ b/extensions/slack/src/monitor/allow-list.ts @@ -10,6 +10,7 @@ import { normalizeStringEntries, normalizeStringEntriesLower, } from "openclaw/plugin-sdk/string-normalization-runtime"; +import { parseSlackTarget } from "../target-parsing.js"; const SLACK_SLUG_CACHE_MAX = 512; const slackSlugCache = new Map(); @@ -44,26 +45,50 @@ export function normalizeSlackAllowOwnerEntry(entry: string): string | undefined if (!trimmed || trimmed === "*") { return undefined; } + try { + const target = parseSlackTarget(trimmed); + if (target?.kind === "user" && target.teamId) { + return target.id.toLowerCase(); + } + } catch { + return undefined; + } const withoutPrefix = trimmed.replace(/^(slack:|user:)/, ""); return /^u[a-z0-9]+$/.test(withoutPrefix) ? withoutPrefix : undefined; } export type SlackAllowListMatch = AllowlistMatch< - "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "prefixed-name" | "slug" + | "wildcard" + | "workspace-id" + | "id" + | "prefixed-id" + | "prefixed-user" + | "name" + | "prefixed-name" + | "slug" >; type SlackAllowListSource = Exclude; export function resolveSlackAllowListMatch(params: { allowList: readonly string[]; + teamId?: string; id?: string; name?: string; allowNameMatching?: boolean; + allowUnscoped?: boolean; }): SlackAllowListMatch { const compiledAllowList = compileAllowlist(params.allowList); + const teamId = normalizeOptionalLowercaseString(params.teamId); const id = normalizeOptionalLowercaseString(params.id); const name = normalizeOptionalLowercaseString(params.name); const slug = normalizeSlackSlug(name); - const candidates: Array<{ value?: string; source: SlackAllowListSource }> = [ + const scopedCandidates: Array<{ value?: string; source: SlackAllowListSource }> = [ + { + value: teamId && id ? `team:${teamId}:user:${id}` : undefined, + source: "workspace-id", + }, + ]; + const unscopedCandidates: Array<{ value?: string; source: SlackAllowListSource }> = [ { value: id, source: "id" }, { value: id ? `slack:${id}` : undefined, source: "prefixed-id" }, { value: id ? `user:${id}` : undefined, source: "prefixed-user" }, @@ -75,6 +100,10 @@ export function resolveSlackAllowListMatch(params: { ] satisfies Array<{ value?: string; source: SlackAllowListSource }>) : []), ]; + const candidates = + teamId && params.allowUnscoped !== true + ? scopedCandidates + : [...scopedCandidates, ...unscopedCandidates]; return resolveCompiledAllowlistMatch({ compiledAllowlist: compiledAllowList, candidates, @@ -83,18 +112,22 @@ export function resolveSlackAllowListMatch(params: { export function allowListMatches(params: { allowList: string[]; + teamId?: string; id?: string; name?: string; allowNameMatching?: boolean; + allowUnscoped?: boolean; }) { return resolveSlackAllowListMatch(params).allowed; } export function resolveSlackUserAllowed(params: { allowList?: Array; + teamId?: string; userId?: string; userName?: string; allowNameMatching?: boolean; + allowUnscoped?: boolean; }) { const allowList = normalizeAllowListLower(params.allowList); if (allowList.length === 0) { @@ -102,8 +135,37 @@ export function resolveSlackUserAllowed(params: { } return allowListMatches({ allowList, + teamId: params.teamId, id: params.userId, name: params.userName, allowNameMatching: params.allowNameMatching, + allowUnscoped: params.allowUnscoped, + }); +} + +export function resolveSlackUserAllowListForTeam(params: { + allowList?: Array; + teamId?: string; + preserveUnmatchedScopedEntries?: boolean; + allowUnscoped?: boolean; +}): string[] { + const allowList = normalizeAllowListLower(params.allowList); + const teamId = normalizeOptionalLowercaseString(params.teamId); + return allowList.flatMap((entry) => { + if (entry === "*") { + return [entry]; + } + if (!entry.startsWith("team:")) { + return params.allowUnscoped === true || params.preserveUnmatchedScopedEntries ? [entry] : []; + } + try { + const target = parseSlackTarget(entry); + if (target?.kind === "user" && target.teamId?.toLowerCase() === teamId) { + return params.allowUnscoped === true ? [target.id.toLowerCase()] : [entry]; + } + return params.preserveUnmatchedScopedEntries ? [entry] : []; + } catch { + return params.preserveUnmatchedScopedEntries ? [entry] : []; + } }); } diff --git a/extensions/slack/src/monitor/auth.test.ts b/extensions/slack/src/monitor/auth.test.ts index d9b716446fd6..a7845cfc366e 100644 --- a/extensions/slack/src/monitor/auth.test.ts +++ b/extensions/slack/src/monitor/auth.test.ts @@ -53,6 +53,7 @@ function makeAuthorizeCtx(params?: { resolveChannelName?: ( channelId: string, ) => Promise<{ name?: string; type?: "im" | "mpim" | "channel" | "group" }>; + installationIdentity?: SlackMonitorContext["installationIdentity"]; }) { return { allowFrom: params?.allowFrom ?? [], @@ -63,6 +64,10 @@ function makeAuthorizeCtx(params?: { channelsConfig: params?.channelsConfig ?? {}, channelsConfigKeys: Object.keys(params?.channelsConfig ?? {}), defaultRequireMention: true, + installationIdentity: params?.installationIdentity ?? { + kind: "workspace", + teamId: "T_MAIN", + }, isChannelAllowed: vi.fn(() => true), resolveUserName: vi.fn( params?.resolveUserName ?? ((_) => Promise.resolve({ name: undefined })), @@ -184,16 +189,33 @@ describe("resolveSlackEffectiveAllowFrom", () => { includePairingStore: true, eventScope: { teamId: "T11111111", client: {} as never }, }), - ).resolves.toEqual(["uconfig123", "u11111111"]); + ).resolves.toEqual(["team:t11111111:user:u11111111"]); await expect( resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true, eventScope: { teamId: "T22222222", client: {} as never }, }), - ).resolves.toEqual(["uconfig123", "u22222222"]); + ).resolves.toEqual(["team:t22222222:user:u22222222"]); await expect( resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }), - ).resolves.toEqual(["uconfig123"]); + ).resolves.toEqual([]); + }); + + it("keeps only configured users for the current Enterprise workspace", async () => { + const ctx = makeSlackCtx(["team:T11111111:user:U01234567"]); + ctx.installationIdentity = { kind: "enterprise", enterpriseId: "E11111111" }; + + await expect( + resolveSlackEffectiveAllowFrom(ctx, { + eventScope: { teamId: "T11111111", client: {} as never }, + }), + ).resolves.toEqual(["team:t11111111:user:u01234567"]); + await expect( + resolveSlackEffectiveAllowFrom(ctx, { + eventScope: { teamId: "T22222222", client: {} as never }, + }), + ).resolves.toEqual([]); + await expect(resolveSlackEffectiveAllowFrom(ctx)).resolves.toEqual([]); }); }); @@ -402,6 +424,47 @@ describe("authorizeSlackSystemEventSender", () => { }); describe("resolveSlackCommandIngress", () => { + it.each([ + ["allows the workspace-qualified user in its workspace", "T11111111", "allow", true], + ["blocks the same bare user ID in another workspace", "T22222222", "block", false], + ] as const)("%s", async (_name, teamId, decision, allowed) => { + const result = await resolveSlackCommandIngress({ + ctx: makeAuthorizeCtx({ + installationIdentity: { kind: "enterprise", enterpriseId: "E11111111" }, + }), + teamId, + senderId: "U01234567", + channelType: "channel", + channelId: "C01234567", + ownerAllowFromLower: [], + channelUsers: ["team:T11111111:user:U01234567"], + allowTextCommands: false, + hasControlCommand: false, + }); + + expect(result.senderAccess.decision).toBe(decision); + expect(result.senderAccess.gate?.allowed).toBe(allowed); + }); + + it("does not authorize a bare user ID for an Enterprise workspace event", async () => { + const result = await resolveSlackCommandIngress({ + ctx: makeAuthorizeCtx({ + installationIdentity: { kind: "enterprise", enterpriseId: "E11111111" }, + }), + teamId: "T11111111", + senderId: "U01234567", + channelType: "channel", + channelId: "C01234567", + ownerAllowFromLower: [], + channelUsers: ["U01234567"], + allowTextCommands: false, + hasControlCommand: false, + }); + + expect(result.senderAccess.decision).toBe("block"); + expect(result.senderAccess.gate?.allowed).toBe(false); + }); + it("does not authorize commands when sender denial stops before the command gate", async () => { const result = await resolveSlackCommandIngress({ ctx: makeAuthorizeCtx(), diff --git a/extensions/slack/src/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts index bf837c1056e5..32442020ff30 100644 --- a/extensions/slack/src/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -19,10 +19,10 @@ import { collectSlackCursorPages } from "../cursor-pages.js"; import { parseSlackTarget } from "../target-parsing.js"; import { allowListMatches, - normalizeAllowList, normalizeAllowListLower, normalizeSlackAllowOwnerEntry, normalizeSlackSlug, + resolveSlackUserAllowListForTeam, } from "./allow-list.js"; import { resolveSlackChannelConfig } from "./channel-config.js"; import { inferSlackChannelType } from "./channel-type.js"; @@ -80,6 +80,14 @@ function normalizeSlackStableEntry(entry: string): string | null { if (!normalized) { return null; } + try { + const target = parseSlackTarget(normalized); + if (target?.kind === "user" && target.teamId) { + return target.normalized; + } + } catch { + return null; + } const userId = normalizeSlackUserId(normalized); return isSlackStableUserId(userId) ? userId : null; } @@ -126,8 +134,17 @@ const slackIngressIdentity = defineStableChannelIngressIdentity({ })), }); -function createSlackIngressSubject(params: { senderId: string; senderName?: string }) { - const senderId = normalizeSlackUserId(params.senderId); +function createSlackIngressSubject(params: { + senderId: string; + senderName?: string; + teamId?: string; + workspaceScoped?: boolean; +}) { + const bareSenderId = normalizeSlackUserId(params.senderId); + const senderId = + params.workspaceScoped && params.teamId + ? `team:${params.teamId.toLowerCase()}:user:${bareSenderId}` + : bareSenderId; const senderName = params.senderName?.trim().toLowerCase(); const senderNameSlug = senderName ? normalizeSlackSlug(senderName) : undefined; return { @@ -179,15 +196,20 @@ function pruneChannelMembersCache(cache: Map { try { const target = parseSlackTarget(entry); - return target?.kind === "user" && target.teamId?.toLowerCase() === teamId ? [target.id] : []; + return target?.kind === "user" && target.teamId?.toLowerCase() === normalizedTeamId + ? [entry] + : []; } catch { return []; } @@ -318,9 +346,11 @@ export async function authorizeSlackBotRoomMessage(params: { channelUserAllowList.length > 0 && allowListMatches({ allowList: channelUserAllowList, + teamId: params.eventScope?.teamId ?? params.ctx.teamId, id: params.senderId, name: params.senderName, allowNameMatching: params.ctx.allowNameMatching, + allowUnscoped: params.ctx.installationIdentity?.kind !== "enterprise", }) ) { return true; @@ -366,6 +396,7 @@ function slackIngressConversationKind( export async function resolveSlackCommandIngress(params: { ctx: SlackMonitorContext; + teamId?: string; senderId: string; senderName?: string; channelType: SlackIngressChannelType; @@ -383,19 +414,29 @@ export async function resolveSlackCommandIngress(params: { }) { const isDirectMessage = params.channelType === "im"; const isGroupDm = params.channelType === "mpim"; - const channelUsers = normalizeAllowListLower(params.channelUsers); - const channelUsersConfigured = !isDirectMessage && !isGroupDm && channelUsers.length > 0; + const teamId = params.teamId ?? params.ctx.teamId; + const allowUnscoped = params.ctx.installationIdentity?.kind !== "enterprise"; + const ownerAllowFrom = resolveSlackUserAllowListForTeam({ + allowList: params.ownerAllowFromLower, + teamId, + allowUnscoped, + }); + const channelUsers = resolveSlackUserAllowListForTeam({ + allowList: params.channelUsers, + teamId, + allowUnscoped, + }); + const channelUsersConfigured = + !isDirectMessage && !isGroupDm && normalizeAllowListLower(params.channelUsers).length > 0; // MPIM ingress is group-shaped, but its sender policy is DM-owned. Callers // pass configured allowFrom without pairing-store approvals for this path. - const groupAllowFrom = isGroupDm - ? params.ownerAllowFromLower - : channelUsersConfigured - ? channelUsers - : []; + const groupAllowFrom = isGroupDm ? ownerAllowFrom : channelUsersConfigured ? channelUsers : []; const result = await createSlackIngressResolver(params.ctx).message({ subject: createSlackIngressSubject({ senderId: params.senderId, senderName: params.senderName, + teamId, + workspaceScoped: !allowUnscoped, }), conversation: { kind: slackIngressConversationKind(params.channelType), @@ -414,13 +455,13 @@ export async function resolveSlackCommandIngress(params: { ...(params.activation ? { activation: params.activation } : {}), }, mentionFacts: params.mentionFacts, - allowFrom: isDirectMessage ? ["*"] : params.ownerAllowFromLower, + allowFrom: isDirectMessage ? ["*"] : ownerAllowFrom, groupAllowFrom, command: { allowTextCommands: params.allowTextCommands, hasControlCommand: params.hasControlCommand, modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff, - ...(isDirectMessage ? { commandOwnerAllowFrom: params.ownerAllowFromLower } : {}), + ...(isDirectMessage ? { commandOwnerAllowFrom: ownerAllowFrom } : {}), }, }); return result; @@ -428,6 +469,7 @@ export async function resolveSlackCommandIngress(params: { async function decideSlackSystemIngress(params: { ctx: SlackMonitorContext; + teamId?: string; senderId: string; senderName?: string; channelType: SlackIngressChannelType; @@ -438,12 +480,24 @@ async function decideSlackSystemIngress(params: { }): Promise { const isDirectMessage = params.channelType === "im"; const isGroupDm = params.channelType === "mpim"; - const channelUsers = normalizeAllowListLower(params.channelUsers); - const channelUsersConfigured = !isDirectMessage && !isGroupDm && channelUsers.length > 0; + const teamId = params.teamId ?? params.ctx.teamId; + const allowUnscoped = params.ctx.installationIdentity?.kind !== "enterprise"; + const ownerAllowFromLower = resolveSlackUserAllowListForTeam({ + allowList: params.ownerAllowFromLower, + teamId, + allowUnscoped, + }); + const channelUsers = resolveSlackUserAllowListForTeam({ + allowList: params.channelUsers, + teamId, + allowUnscoped, + }); + const channelUsersConfigured = + !isDirectMessage && !isGroupDm && normalizeAllowListLower(params.channelUsers).length > 0; const ownerAllowFrom = params.interactiveEvent && channelUsersConfigured - ? params.ownerAllowFromLower.filter((entry) => entry !== "*") - : params.ownerAllowFromLower; + ? ownerAllowFromLower.filter((entry) => entry !== "*") + : ownerAllowFromLower; const hasAnyCommandAllowlist = ownerAllowFrom.length > 0 || channelUsersConfigured; const groupAllowFrom = (() => { if (isDirectMessage) { @@ -458,12 +512,14 @@ async function decideSlackSystemIngress(params: { if (channelUsersConfigured) { return channelUsers; } - return params.channelId ? ["*"] : wildcardWhenOpen(params.ownerAllowFromLower); + return params.channelId ? ["*"] : wildcardWhenOpen(ownerAllowFromLower); })(); const result = await createSlackIngressResolver(params.ctx).message({ subject: createSlackIngressSubject({ senderId: params.senderId, senderName: params.senderName, + teamId, + workspaceScoped: !allowUnscoped, }), conversation: { kind: slackIngressConversationKind(params.channelType), @@ -479,14 +535,14 @@ async function decideSlackSystemIngress(params: { ? "allowlist" : params.interactiveEvent && hasAnyCommandAllowlist ? "open" - : channelUsersConfigured || (!params.channelId && params.ownerAllowFromLower.length > 0) + : channelUsersConfigured || (!params.channelId && ownerAllowFromLower.length > 0) ? "allowlist" : "open", policy: { groupAllowFromFallbackToAllowFrom: false, mutableIdentifierMatching: params.ctx.allowNameMatching ? "enabled" : "disabled", }, - allowFrom: isDirectMessage ? wildcardWhenOpen(params.ownerAllowFromLower) : ownerAllowFrom, + allowFrom: isDirectMessage ? wildcardWhenOpen(ownerAllowFromLower) : ownerAllowFrom, groupAllowFrom, command: params.interactiveEvent && hasAnyCommandAllowlist @@ -541,6 +597,7 @@ export async function authorizeSlackSystemEventSender(params: { channelType = normalizeSlackChannelType(resolvedTypeSource, channelId); if ( !params.ctx.isChannelAllowed({ + teamId: params.eventScope?.teamId ?? params.ctx.teamId, channelId, channelName, channelType, @@ -598,6 +655,8 @@ export async function authorizeSlackSystemEventSender(params: { }); const channelConfig = channelId ? resolveSlackChannelConfig({ + teamId: params.eventScope?.teamId ?? params.ctx.teamId, + allowUnscoped: params.ctx.installationIdentity?.kind !== "enterprise", channelId, channelName, channels: params.ctx.channelsConfig, @@ -610,6 +669,7 @@ export async function authorizeSlackSystemEventSender(params: { Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; const decision = await decideSlackSystemIngress({ ctx: params.ctx, + teamId: params.eventScope?.teamId ?? params.ctx.teamId, senderId, senderName, channelType: ingressChannelType, diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts index 4064b1fc8b39..c4c6be5e2575 100644 --- a/extensions/slack/src/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -11,7 +11,7 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime"; import { buildSlackChannelIdCandidates, buildSlackChannelPolicyScope } from "../group-policy.js"; -import { normalizeSlackSlug } from "./allow-list.js"; +import { normalizeSlackSlug, resolveSlackUserAllowListForTeam } from "./allow-list.js"; export type SlackChannelConfigResolved = { allowed: boolean; @@ -63,6 +63,8 @@ export function resolveSlackChannelLabel(params: { channelId?: string; channelNa } export function resolveSlackChannelConfig(params: { + teamId?: string; + allowUnscoped?: boolean; channelId: string; channelName?: string; channels?: SlackChannelConfigEntries; @@ -83,7 +85,9 @@ export function resolveSlackChannelConfig(params: { const normalizedName = channelName ? normalizeSlackSlug(channelName) : ""; const directName = channelName ? channelName.trim() : ""; const candidates = buildChannelKeyCandidates( - ...buildSlackChannelIdCandidates(channelId), + ...buildSlackChannelIdCandidates(channelId, params.teamId, { + allowUnscoped: params.allowUnscoped, + }), allowNameMatching ? (channelName ? `#${directName}` : undefined) : undefined, allowNameMatching ? directName : undefined, allowNameMatching ? normalizedName : undefined, @@ -115,7 +119,14 @@ export function resolveSlackChannelConfig(params: { fallback?.botLoopProtection, matched?.botLoopProtection, ); - const users = firstDefined(resolved.users, fallback?.users); + const users = resolveSlackUserAllowListForTeam({ + allowList: firstDefined(resolved.users, fallback?.users), + teamId: params.teamId, + allowUnscoped: params.allowUnscoped, + // Keeping unmatched entries preserves the configured allowlist gate; strict + // workspace ingress treats bare and differently scoped values as non-matching. + preserveUnmatchedScopedEntries: true, + }); const skills = firstDefined(resolved.skills, fallback?.skills); const systemPrompt = firstDefined(resolved.systemPrompt, fallback?.systemPrompt); const presenceEvents = firstDefined(resolved.presenceEvents, fallback?.presenceEvents); @@ -126,7 +137,7 @@ export function resolveSlackChannelConfig(params: { replyToMode, allowBots, botLoopProtection, - users, + users: users.length > 0 ? users : undefined, skills, systemPrompt, presenceEvents, diff --git a/extensions/slack/src/monitor/context.test.ts b/extensions/slack/src/monitor/context.test.ts index f17e77a9932e..e8547d32f3b3 100644 --- a/extensions/slack/src/monitor/context.test.ts +++ b/extensions/slack/src/monitor/context.test.ts @@ -13,6 +13,7 @@ function createTestContext(params?: { groupDmChannels?: string[]; appClient?: App["client"]; apiAppId?: string; + channelsConfig?: Record; }) { return createSlackMonitorContext({ cfg: { @@ -38,6 +39,7 @@ function createTestContext(params?: { groupDmEnabled: params?.groupDmEnabled ?? false, groupDmChannels: params?.groupDmChannels ?? [], defaultRequireMention: true, + channelsConfig: params?.channelsConfig, groupPolicy: "allowlist", useAccessGroups: true, reactionMode: "off", @@ -150,6 +152,46 @@ describe("createSlackMonitorContext isChannelAllowed", () => { expect(ctx.isChannelAllowed({ channelId: "G456", channelType: "mpim" })).toBe(true); expect(ctx.isChannelAllowed({ channelId: "G999", channelType: "mpim" })).toBe(false); }); + + it("matches workspace-qualified channel and group DM policies", () => { + const ctx = createTestContext({ + groupDmEnabled: true, + groupDmChannels: ["team:T11111111:channel:G01234567"], + channelsConfig: { + "team:T11111111:channel:C01234567": { enabled: true }, + "team:T22222222:channel:C01234567": { enabled: false }, + }, + }); + + expect( + ctx.isChannelAllowed({ + teamId: "T11111111", + channelId: "C01234567", + channelType: "channel", + }), + ).toBe(true); + expect( + ctx.isChannelAllowed({ + teamId: "T22222222", + channelId: "C01234567", + channelType: "channel", + }), + ).toBe(false); + expect( + ctx.isChannelAllowed({ + teamId: "T11111111", + channelId: "G01234567", + channelType: "mpim", + }), + ).toBe(true); + expect( + ctx.isChannelAllowed({ + teamId: "T22222222", + channelId: "G01234567", + channelType: "mpim", + }), + ).toBe(false); + }); }); describe("createSlackMonitorContext resolveSlackSystemEventSessionKey", () => { diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index 9b8d900a8852..51957f07f7e2 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -18,6 +18,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatSlackError } from "../errors.js"; +import { buildSlackChannelIdCandidates } from "../group-policy.js"; import type { SlackMessageEvent } from "../types.js"; import { createSlackAgentViewState } from "./agent-view-state.js"; import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; @@ -116,6 +117,7 @@ export type SlackMonitorContext = { eventScope?: SlackEventScope; }) => string; isChannelAllowed: (params: { + teamId?: string; channelId?: string; channelName?: string; channelType?: SlackMessageEvent["channel_type"]; @@ -374,6 +376,7 @@ export function createSlackMonitorContext(params: { }); const isChannelAllowed = (p: { + teamId?: string; channelId?: string; channelName?: string; channelType?: SlackMessageEvent["channel_type"]; @@ -392,7 +395,9 @@ export function createSlackMonitorContext(params: { if (isGroupDm && groupDmChannels.length > 0) { const candidates = [ - p.channelId, + ...buildSlackChannelIdCandidates(p.channelId, p.teamId, { + allowUnscoped: params.installationIdentity?.kind !== "enterprise", + }), p.channelName ? `#${p.channelName}` : undefined, p.channelName, p.channelName ? normalizeSlackSlug(p.channelName) : undefined, @@ -409,6 +414,8 @@ export function createSlackMonitorContext(params: { if (isRoom && p.channelId) { const channelConfig = resolveSlackChannelConfig({ + teamId: p.teamId, + allowUnscoped: params.installationIdentity?.kind !== "enterprise", channelId: p.channelId, channelName: p.channelName, channels: params.channelsConfig, @@ -433,7 +440,7 @@ export function createSlackMonitorContext(params: { if (shouldDrop) { if (explicitlyDisabled) { const reason = "channel_not_allowed"; - const warningKey = `${params.accountId}:${p.channelId}:${reason}`; + const warningKey = `${params.accountId}:${p.teamId ? `${p.teamId}:` : ""}${p.channelId}:${reason}`; if (!channelDenialWarnings.peek(warningKey)) { channelDenialWarnings.check(warningKey); logger.warn( diff --git a/extensions/slack/src/monitor/dm-auth.test.ts b/extensions/slack/src/monitor/dm-auth.test.ts index e9df4881181b..acee17596483 100644 --- a/extensions/slack/src/monitor/dm-auth.test.ts +++ b/extensions/slack/src/monitor/dm-auth.test.ts @@ -76,6 +76,31 @@ describe("authorizeSlackDirectMessage", () => { }); }); + it("allows bare user ids for workspace-install DMs", async () => { + const params = makeParams("allowlist"); + params.ctx.installationIdentity = { kind: "workspace", teamId: "T11111111" }; + params.eventScope = { teamId: "T11111111", client: {} as never }; + params.allowFromLower = ["u123"]; + + await expect(authorizeSlackDirectMessage(params)).resolves.toBe(true); + + expect(params.onUnauthorized).not.toHaveBeenCalled(); + }); + + it("keeps bare user ids scoped out of Enterprise DMs", async () => { + const params = makeParams("allowlist"); + params.ctx.installationIdentity = { kind: "enterprise", enterpriseId: "E11111111" }; + params.eventScope = { teamId: "T11111111", client: {} as never }; + params.allowFromLower = ["u123"]; + + await expect(authorizeSlackDirectMessage(params)).resolves.toBe(false); + + expect(params.onUnauthorized).toHaveBeenCalledWith({ + allowMatchMeta: "matchKey=none matchSource=none", + senderName: "Alice", + }); + }); + it("creates independent pairing requests for the same user in two Grid workspaces", async () => { const pendingCodes = new Map(); upsertChannelPairingRequestMock.mockImplementation( diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts index 26371566796f..53b5b9a9c117 100644 --- a/extensions/slack/src/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -33,9 +33,11 @@ export async function authorizeSlackDirectMessage(params: { const senderName = sender?.name ?? undefined; const allowMatch = resolveSlackAllowListMatch({ allowList: params.allowFromLower, + teamId: params.eventScope?.teamId ?? params.ctx.teamId, id: params.senderId, name: senderName, allowNameMatching: params.ctx.allowNameMatching, + allowUnscoped: params.ctx.installationIdentity?.kind !== "enterprise", }); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); if (allowMatch.allowed) { diff --git a/extensions/slack/src/monitor/enterprise-install.test.ts b/extensions/slack/src/monitor/enterprise-install.test.ts index 8ae556e21445..51f6b4796e90 100644 --- a/extensions/slack/src/monitor/enterprise-install.test.ts +++ b/extensions/slack/src/monitor/enterprise-install.test.ts @@ -93,16 +93,18 @@ describe("assertEnterpriseSlackPolicyConfig", () => { assertEnterpriseSlackPolicyConfig({ accountId: "org", config: { - allowFrom: ["U01234567", "slack:W01234567", "user:U12345678"], - dm: { groupChannels: ["G01234567", "channel:G12345678"] }, + allowFrom: ["team:T01234567:user:U01234567"], + dm: { + groupChannels: ["team:T01234567:channel:G01234567"], + }, mentionPatterns: { mode: "allow", allowIn: ["team:T01234567:channel:C01234567"], denyIn: ["team:T12345678:channel:C12345678"], }, channels: { - C01234567: { - users: ["U01234567", "slack:W01234567", "user:U12345678"], + "team:T01234567:channel:C01234567": { + users: ["team:T01234567:user:U01234567", "team:T01234567:user:B01234567"], toolsBySender: { U01234567: {}, "id:W01234567": {}, @@ -110,9 +112,11 @@ describe("assertEnterpriseSlackPolicyConfig", () => { "*": {}, }, }, - "channel:C12345678": {}, + "team:T12345678:channel:C12345678": {}, "*": {}, }, + reactionNotifications: "allowlist", + reactionAllowlist: ["team:T01234567:user:U01234567"], }, }), ).not.toThrow(); @@ -139,6 +143,25 @@ describe("assertEnterpriseSlackPolicyConfig", () => { ).toThrow(/cannot use dangerouslyAllowNameMatching/); }); + it.each<[string, SlackAccountConfig]>([ + ["channel ID", { channels: { C01234567: {} } }], + ["allowFrom user ID", { allowFrom: ["U01234567"] }], + ["group DM channel ID", { dm: { groupChannels: ["G01234567"] } }], + ["reaction user ID", { reactionNotifications: "allowlist", reactionAllowlist: ["U01234567"] }], + [ + "per-channel user ID", + { + channels: { + "team:T01234567:channel:C01234567": { users: ["U01234567"] }, + }, + }, + ], + ])("rejects unscoped Enterprise %s", (_label, config) => { + expect(() => assertEnterpriseSlackPolicyConfig({ accountId: "org", config })).toThrow( + /Slack Enterprise Grid/, + ); + }); + it.each<[string, SlackAccountConfig]>([ ["channels key", { channels: { general: {} } }], ["prefixed channels key", { channels: { "channel:general": {} } }], @@ -191,7 +214,7 @@ describe("assertEnterpriseSlackPolicyConfig", () => { accountId: "org", config: { channels: { - C01234567: { + "team:T01234567:channel:C01234567": { toolsBySender: { [entry]: { deny: ["exec"] }, "*": { allow: ["exec"] }, diff --git a/extensions/slack/src/monitor/enterprise-install.ts b/extensions/slack/src/monitor/enterprise-install.ts index 820414378aba..4c6ff7e7ced9 100644 --- a/extensions/slack/src/monitor/enterprise-install.ts +++ b/extensions/slack/src/monitor/enterprise-install.ts @@ -42,9 +42,12 @@ export type SlackAuthTestIdentity = { }; const SLACK_CHANNEL_ID_RE = /^[CDG][A-Z0-9]{8,}$/; -const SLACK_USER_ID_RE = /^[UW][A-Z0-9]{8,}$/; +const SLACK_USER_ID_RE = /^[BUW][A-Z0-9]{8,}$/; -function isStableSlackChannelEntry(value: unknown, options?: { allowWildcard?: boolean }): boolean { +function isWorkspaceScopedSlackChannelEntry( + value: unknown, + options?: { allowWildcard?: boolean }, +): boolean { if (typeof value !== "string") { return false; } @@ -52,14 +55,10 @@ function isStableSlackChannelEntry(value: unknown, options?: { allowWildcard?: b if (normalized === "*") { return options?.allowWildcard === true; } - const prefixed = /^channel:([CDG][A-Z0-9]{8,})$/.exec(normalized); - if (prefixed?.[1]) { - return true; - } - return SLACK_CHANNEL_ID_RE.test(normalized); + return isWorkspaceQualifiedSlackTarget(normalized, "channel"); } -function isStableSlackAllowlistUserEntry(value: unknown): boolean { +function isWorkspaceScopedSlackAllowlistUserEntry(value: unknown): boolean { if (typeof value !== "string") { return false; } @@ -67,8 +66,7 @@ function isStableSlackAllowlistUserEntry(value: unknown): boolean { if (normalized === "*") { return true; } - const prefixed = /^(?:slack|user):([UW][A-Z0-9]{8,})$/.exec(normalized); - return Boolean(prefixed?.[1]) || SLACK_USER_ID_RE.test(normalized); + return isWorkspaceQualifiedSlackTarget(normalized, "user"); } function isStableSlackToolsBySenderEntry(value: unknown): boolean { @@ -137,30 +135,30 @@ export function assertEnterpriseSlackPolicyConfig(params: { assertStableEntries({ values: config.allowFrom, path: `channels.slack.accounts.${accountId}.allowFrom`, - predicate: isStableSlackAllowlistUserEntry, + predicate: isWorkspaceScopedSlackAllowlistUserEntry, }); assertStableEntries({ values: config.dm?.groupChannels, path: `channels.slack.accounts.${accountId}.dm.groupChannels`, - predicate: (value) => isStableSlackChannelEntry(value), + predicate: (value) => isWorkspaceScopedSlackChannelEntry(value), }); if (config.reactionNotifications === "allowlist") { assertStableEntries({ values: config.reactionAllowlist, path: `channels.slack.accounts.${accountId}.reactionAllowlist`, - predicate: isStableSlackAllowlistUserEntry, + predicate: isWorkspaceScopedSlackAllowlistUserEntry, }); } for (const [channelKey, channel] of Object.entries(config.channels ?? {})) { - if (!isStableSlackChannelEntry(channelKey, { allowWildcard: true })) { + if (!isWorkspaceScopedSlackChannelEntry(channelKey, { allowWildcard: true })) { throw new Error( - `Slack Enterprise Grid org installs require stable Slack channel IDs; invalid channels key ${JSON.stringify(channelKey)}`, + `Slack Enterprise Grid org installs require stable Slack channel IDs with workspace scope; invalid channels key ${JSON.stringify(channelKey)}`, ); } assertStableEntries({ values: channel?.users, path: `channels.slack.accounts.${accountId}.channels.${channelKey}.users`, - predicate: isStableSlackAllowlistUserEntry, + predicate: isWorkspaceScopedSlackAllowlistUserEntry, }); assertStableEntries({ values: Object.keys(channel?.toolsBySender ?? {}), diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index 009f9661b021..c6fce1b4a16a 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -35,6 +35,7 @@ export function registerSlackChannelEvents(params: { }) => { if ( !ctx.isChannelAllowed({ + teamId: paramsLocal.eventScope?.teamId ?? ctx.teamId, channelId: paramsLocal.channelId, channelName: paramsLocal.channelName, channelType: "channel", diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index 745d69501aef..aa861381ab1f 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -914,6 +914,8 @@ async function resolveSlackBlockActionCommandAuthorized(params: { let channelUsers: Array = []; if (isRoom && params.parsed.channelId) { const channelConfig = resolveSlackChannelConfig({ + teamId: params.eventScope?.teamId ?? params.ctx.teamId, + allowUnscoped: params.ctx.installationIdentity?.kind !== "enterprise", channelId: params.parsed.channelId, channelName: params.auth.channelName, channels: params.ctx.channelsConfig, @@ -926,6 +928,7 @@ async function resolveSlackBlockActionCommandAuthorized(params: { const commandIngress = await resolveSlackCommandIngress({ ctx: params.ctx, + teamId: params.eventScope?.teamId ?? params.ctx.teamId, senderId: params.parsed.userId, senderName, channelType: params.auth.channelType ?? "channel", diff --git a/extensions/slack/src/monitor/events/reactions.ts b/extensions/slack/src/monitor/events/reactions.ts index f2efc8a78a37..70ca13acdc4e 100644 --- a/extensions/slack/src/monitor/events/reactions.ts +++ b/extensions/slack/src/monitor/events/reactions.ts @@ -15,6 +15,7 @@ import { function shouldEmitSlackReactionNotification(params: { ctx: SlackMonitorContext; event: SlackReactionEvent; + eventScope?: SlackEventScope; actorName?: string; }) { const { ctx, event, actorName } = params; @@ -31,9 +32,11 @@ function shouldEmitSlackReactionNotification(params: { } return allowListMatches({ allowList, + teamId: params.eventScope?.teamId ?? ctx.teamId, id: event.user, name: actorName, allowNameMatching: ctx.allowNameMatching, + allowUnscoped: ctx.installationIdentity?.kind !== "enterprise", }); } return ctx.reactionMode === "all"; @@ -88,6 +91,7 @@ export function registerSlackReactionEvents(params: { !shouldEmitSlackReactionNotification({ ctx, event, + eventScope, actorName: actorInfo?.name, }) ) { diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 1b0ded3dac00..23090bf00bcf 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -574,6 +574,58 @@ describe("slack prepareSlackMessage inbound contract", () => { }); }); + it("applies workspace-qualified channel users during message ingress", async () => { + const channelsConfig = { + "team:T123ENTERPRISE:channel:C123CHANNEL": { + enabled: true, + requireMention: false, + users: ["team:T123ENTERPRISE:user:U123"], + }, + "team:T456ENTERPRISE:channel:C123CHANNEL": { + enabled: true, + requireMention: false, + users: ["team:T456ENTERPRISE:user:U456"], + }, + }; + const ctx = createInboundSlackCtx({ + cfg: { channels: { slack: { enabled: true, groupPolicy: "allowlist" } } }, + channelsConfig, + defaultRequireMention: false, + groupPolicy: "allowlist", + }); + ctx.resolveChannelName = async () => ({ name: "general", type: "channel" }); + ctx.resolveUserName = async () => ({ name: "Alice" }); + const account = createSlackAccount({ groupPolicy: "allowlist", channels: channelsConfig }); + const message = createSlackMessage({ + channel: "C123CHANNEL", + channel_type: "channel", + user: "U123", + text: "hello", + }); + + const allowed = await prepareSlackMessage({ + ctx, + account, + message, + opts: { + source: "message", + eventScope: { teamId: "T123ENTERPRISE", client: ctx.app.client }, + }, + }); + const blocked = await prepareSlackMessage({ + ctx, + account, + message, + opts: { + source: "message", + eventScope: { teamId: "T456ENTERPRISE", client: ctx.app.client }, + }, + }); + + assertPrepared(allowed, "workspace-qualified channel user"); + expect(blocked).toBeNull(); + }); + it("applies workspace-qualified Enterprise mention pattern policy", async () => { const cfg = { messages: { groupChat: { mentionPatterns: ["\\bbill\\b"] } }, diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 90c0fb976409..e447ee8a34e4 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -538,6 +538,8 @@ async function resolveSlackConversationContext(params: { const isRoomish = isRoom || isGroupDm; const channelConfig = isRoom ? resolveSlackChannelConfig({ + teamId: params.eventScope?.teamId ?? ctx.teamId, + allowUnscoped: ctx.installationIdentity?.kind !== "enterprise", channelId: message.channel, channelName, channels: ctx.channelsConfig, @@ -604,6 +606,7 @@ async function authorizeSlackInboundMessage(params: { if ( !ctx.isChannelAllowed({ + teamId: params.eventScope?.teamId ?? ctx.teamId, channelId: message.channel, channelName, channelType: resolvedChannelType, @@ -1136,6 +1139,7 @@ export async function prepareSlackMessage(params: { isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; const messageIngress = await resolveSlackCommandIngress({ ctx, + teamId: opts.eventScope?.teamId ?? ctx.teamId, senderId, senderName: senderNameForAuth, channelType: conversation.resolvedChannelType ?? "channel", @@ -1771,7 +1775,7 @@ export async function prepareSlackMessage(params: { const pinnedMainDmOwner = isDirectMessage ? resolvePinnedMainDmOwnerFromAllowlist({ dmScope: cfg.session?.dmScope, - allowFrom: ctx.allowFrom, + allowFrom: allowFromLower, normalizeEntry: normalizeSlackAllowOwnerEntry, }) : null; diff --git a/extensions/slack/src/monitor/monitor.test.ts b/extensions/slack/src/monitor/monitor.test.ts index 9d0cd1b38cc5..dd58881401e1 100644 --- a/extensions/slack/src/monitor/monitor.test.ts +++ b/extensions/slack/src/monitor/monitor.test.ts @@ -162,6 +162,93 @@ describe("resolveSlackChannelConfig", () => { }); }); + it("prefers a workspace-qualified channel over the same channel ID in another workspace", () => { + const channels = { + "team:T11111111:channel:C01234567": { enabled: true, requireMention: false }, + "team:T22222222:channel:C01234567": { enabled: false, requireMention: true }, + }; + + expectSlackChannelConfig( + resolveSlackChannelConfig({ + teamId: "T11111111", + channelId: "C01234567", + channels, + }), + { + allowed: true, + requireMention: false, + matchKey: "team:T11111111:channel:C01234567", + matchSource: "direct", + }, + ); + expectSlackChannelConfig( + resolveSlackChannelConfig({ + teamId: "T22222222", + channelId: "C01234567", + channels, + }), + { + allowed: false, + requireMention: true, + matchKey: "team:T22222222:channel:C01234567", + matchSource: "direct", + }, + ); + }); + + it("does not match a bare channel ID when workspace scope is required", () => { + const channels = { C01234567: { enabled: true, requireMention: false } }; + + expectSlackChannelConfig( + resolveSlackChannelConfig({ + teamId: "T11111111", + channelId: "C01234567", + channels, + }), + { allowed: false, requireMention: true }, + ); + expectSlackChannelConfig( + resolveSlackChannelConfig({ + teamId: "T11111111", + allowUnscoped: true, + channelId: "C01234567", + channels, + }), + { + allowed: true, + requireMention: false, + matchKey: "C01234567", + matchSource: "direct", + }, + ); + }); + + it("matches per-channel users only in their selected workspace", () => { + const channels = { + "team:T11111111:channel:C01234567": { + users: ["team:T11111111:user:U01234567", "team:T22222222:user:U12345678", "U23456789"], + }, + "team:T22222222:channel:C01234567": { + users: ["team:T11111111:user:U01234567", "team:T22222222:user:U12345678", "U23456789"], + }, + }; + + expect( + resolveSlackChannelConfig({ + teamId: "T11111111", + channelId: "C01234567", + channels, + })?.users, + ).toEqual(["team:t11111111:user:u01234567", "team:t22222222:user:u12345678", "u23456789"]); + expect( + resolveSlackChannelConfig({ + teamId: "T22222222", + channelId: "C01234567", + channels, + })?.users, + ).toEqual(["team:t11111111:user:u01234567", "team:t22222222:user:u12345678", "u23456789"]); + }); + it("blocks channel-name route matches by default", () => { const res = resolveSlackChannelConfig({ channelId: "C1", diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 49bd3572018b..0618104c811b 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -498,6 +498,7 @@ export async function registerSlackMonitorSlashCommands(params: { if ( !ctx.isChannelAllowed({ + teamId: eventScope?.teamId ?? ctx.teamId, channelId: command.channel_id, channelName: channelInfo?.name, channelType, @@ -557,6 +558,8 @@ export async function registerSlackMonitorSlashCommands(params: { if (isRoom) { channelConfig = resolveSlackChannelConfig({ + teamId: eventScope?.teamId ?? ctx.teamId, + allowUnscoped: ctx.installationIdentity?.kind !== "enterprise", channelId: command.channel_id, channelName: channelInfo?.name, channels: ctx.channelsConfig, @@ -598,6 +601,7 @@ export async function registerSlackMonitorSlashCommands(params: { const senderName = sender?.name ?? command.user_name ?? command.user_id; const slashIngress = await resolveSlackCommandIngress({ ctx, + teamId: eventScope?.teamId ?? ctx.teamId, senderId: command.user_id, senderName, channelType: channelType ?? "channel", diff --git a/extensions/slack/src/resolve-channels.test.ts b/extensions/slack/src/resolve-channels.test.ts index 132b2054323b..b9d9157393f9 100644 --- a/extensions/slack/src/resolve-channels.test.ts +++ b/extensions/slack/src/resolve-channels.test.ts @@ -45,6 +45,21 @@ describe("resolveSlackChannelAllowlist", () => { expect(list).not.toHaveBeenCalled(); }); + it("preserves workspace-qualified channel ids without listing a workspace", async () => { + const list = vi.fn(); + const res = await resolveSlackChannelAllowlist({ + token: "xoxb-test", + entries: ["team:T11111111:channel:C01234567", "team:T22222222:channel:C01234567"], + client: { conversations: { list } } as never, + }); + + expect(res.map((entry) => entry.id)).toEqual([ + "team:T11111111:channel:C01234567", + "team:T22222222:channel:C01234567", + ]); + expect(list).not.toHaveBeenCalled(); + }); + it("resolves by name and prefers active channels", async () => { const client = { conversations: { diff --git a/extensions/slack/src/resolve-channels.ts b/extensions/slack/src/resolve-channels.ts index f66f45363c73..cee5cc0b5a5d 100644 --- a/extensions/slack/src/resolve-channels.ts +++ b/extensions/slack/src/resolve-channels.ts @@ -4,6 +4,7 @@ import { resolveDirectoryAllowlistEntries } from "openclaw/plugin-sdk/directory- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createSlackLookupClient } from "./client.js"; import { collectSlackCursorPages } from "./cursor-pages.js"; +import { formatSlackTarget, parseSlackTarget } from "./target-parsing.js"; export type SlackChannelLookup = { id: string; @@ -20,6 +21,25 @@ export type SlackChannelResolution = { archived?: boolean; }; +function resolveWorkspaceQualifiedChannel(input: string): SlackChannelResolution | undefined { + if (!/^team:/i.test(input)) { + return undefined; + } + try { + const target = parseSlackTarget(input); + if (target?.kind !== "channel" || !target.teamId) { + return undefined; + } + return { + input, + resolved: true, + id: formatSlackTarget({ teamId: target.teamId, kind: "channel", id: target.id }), + }; + } catch { + return undefined; + } +} + function parseSlackChannelMention(raw: string): { id?: string; name?: string } { const trimmed = raw.trim(); if (!trimmed) { @@ -90,26 +110,35 @@ export async function resolveSlackChannelAllowlist(params: { entries: string[]; client?: WebClient; }): Promise { - const parsedEntries = params.entries.map((input) => ({ + const workspaceResolved = params.entries.map(resolveWorkspaceQualifiedChannel); + const lookupEntries = params.entries.filter((_, index) => !workspaceResolved[index]); + if (lookupEntries.length === 0) { + return workspaceResolved.filter( + (entry): entry is SlackChannelResolution => entry !== undefined, + ); + } + const parsedEntries = lookupEntries.map((input) => ({ input, parsed: parseSlackChannelMention(input), })); if (parsedEntries.every((entry) => Boolean(entry.parsed.id))) { - return parsedEntries.map(({ input, parsed }) => ({ + const resolved = parsedEntries.map(({ input, parsed }) => ({ input, resolved: true, id: parsed.id, name: parsed.name, })); + let resolvedIndex = 0; + return workspaceResolved.map((entry) => entry ?? resolved[resolvedIndex++]!); } const client = params.client ?? createSlackLookupClient(params.token); const channels = await listSlackChannels(client); - return resolveDirectoryAllowlistEntries< + const resolved = resolveDirectoryAllowlistEntries< { id?: string; name?: string }, SlackChannelLookup, SlackChannelResolution >({ - entries: params.entries, + entries: lookupEntries, lookup: channels, parseInput: parseSlackChannelMention, findById: (lookup, id) => lookup.find((channel) => channel.id === id), @@ -138,4 +167,6 @@ export async function resolveSlackChannelAllowlist(params: { }, buildUnresolved: (input) => ({ input, resolved: false }), }); + let resolvedIndex = 0; + return workspaceResolved.map((entry) => entry ?? resolved[resolvedIndex++]!); } diff --git a/extensions/slack/src/resolve-users.test.ts b/extensions/slack/src/resolve-users.test.ts index d3b3a5cd2d13..9d5c5c1f53c4 100644 --- a/extensions/slack/src/resolve-users.test.ts +++ b/extensions/slack/src/resolve-users.test.ts @@ -75,6 +75,21 @@ describe("resolveSlackUserAllowlist", () => { }); }); + it("preserves workspace-qualified user ids without listing a workspace", async () => { + const list = vi.fn(); + const res = await resolveSlackUserAllowlist({ + token: "xoxb-test", + entries: ["team:T11111111:user:U01234567", "team:T22222222:user:U01234567"], + client: { users: { list } } as never, + }); + + expect(res.map((entry) => entry.id)).toEqual([ + "team:T11111111:user:U01234567", + "team:T22222222:user:U01234567", + ]); + expect(list).not.toHaveBeenCalled(); + }); + it("keeps unresolved users", async () => { const client = { users: { diff --git a/extensions/slack/src/resolve-users.ts b/extensions/slack/src/resolve-users.ts index 4c97a76914ec..01b52ad2856b 100644 --- a/extensions/slack/src/resolve-users.ts +++ b/extensions/slack/src/resolve-users.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createSlackLookupClient } from "./client.js"; import { collectSlackCursorPages } from "./cursor-pages.js"; +import { formatSlackTarget, parseSlackTarget } from "./target-parsing.js"; export type SlackUserLookup = { id: string; @@ -30,6 +31,25 @@ export type SlackUserResolution = { note?: string; }; +function resolveWorkspaceQualifiedUser(input: string): SlackUserResolution | undefined { + if (!/^team:/i.test(input)) { + return undefined; + } + try { + const target = parseSlackTarget(input); + if (target?.kind !== "user" || !target.teamId) { + return undefined; + } + return { + input, + resolved: true, + id: formatSlackTarget({ teamId: target.teamId, kind: "user", id: target.id }), + }; + } catch { + return undefined; + } +} + function parseSlackUserInput(raw: string): { id?: string; name?: string; email?: string } { const trimmed = raw.trim(); if (!trimmed) { @@ -138,14 +158,19 @@ export async function resolveSlackUserAllowlist(params: { entries: string[]; client?: WebClient; }): Promise { + const workspaceResolved = params.entries.map(resolveWorkspaceQualifiedUser); + const lookupEntries = params.entries.filter((_, index) => !workspaceResolved[index]); + if (lookupEntries.length === 0) { + return workspaceResolved.filter((entry): entry is SlackUserResolution => entry !== undefined); + } const client = params.client ?? createSlackLookupClient(params.token); const users = await listSlackUsers(client); - return resolveDirectoryAllowlistEntries< + const resolved = resolveDirectoryAllowlistEntries< { id?: string; name?: string; email?: string }, SlackUserLookup, SlackUserResolution >({ - entries: params.entries, + entries: lookupEntries, lookup: users, parseInput: parseSlackUserInput, findById: (lookup, id) => lookup.find((user) => user.id === id), @@ -181,4 +206,6 @@ export async function resolveSlackUserAllowlist(params: { }, buildUnresolved: (input) => ({ input, resolved: false }), }); + let resolvedIndex = 0; + return workspaceResolved.map((entry) => entry ?? resolved[resolvedIndex++]!); } diff --git a/extensions/slack/src/security-doctor.ts b/extensions/slack/src/security-doctor.ts index 28398cf40cb4..85d21c614b82 100644 --- a/extensions/slack/src/security-doctor.ts +++ b/extensions/slack/src/security-doctor.ts @@ -1,7 +1,22 @@ // Slack plugin module implements security doctor behavior. import { buildMutableAllowEntryDetector } from "openclaw/plugin-sdk/channel-policy"; +import { parseSlackTarget } from "./target-parsing.js"; -export const isSlackMutableAllowEntry = buildMutableAllowEntryDetector({ +const isSlackMutableUnqualifiedAllowEntry = buildMutableAllowEntryDetector({ stableIdPattern: /^(?:(?:(?:[sS][lL][aA][cC][kK]|[uU][sS][eE][rR]):)?(?:[UWBCGDT][A-Z0-9]{2,}|[A-Za-z0-9]{8,})|<@[A-Za-z0-9]{8,}>)$/, }); + +export function isSlackMutableAllowEntry(entry: string): boolean { + if (/^team:/i.test(entry)) { + try { + const target = parseSlackTarget(entry); + if (target?.kind === "user" && target.teamId) { + return false; + } + } catch { + // Invalid qualified entries remain mutable so Doctor reports them. + } + } + return isSlackMutableUnqualifiedAllowEntry(entry); +} diff --git a/extensions/slack/src/target-parsing.ts b/extensions/slack/src/target-parsing.ts index 73b6415d7a9f..f8caaef9ba83 100644 --- a/extensions/slack/src/target-parsing.ts +++ b/extensions/slack/src/target-parsing.ts @@ -20,7 +20,7 @@ export type SlackTargetParseOptions = MessagingTargetParseOptions; // Letter-leading folded IDs are indistinguishable from supported channel names. // Doctor reports that ambiguity; runtime repairs only the digit-leading form. const SLACK_CHANNEL_API_ID_RE = /^[CDG][0-9][A-Z0-9]{7,}$/i; -const SLACK_USER_API_ID_RE = /^[UW][A-Z0-9]{8,}$/i; +const SLACK_USER_API_ID_RE = /^[BUW][A-Z0-9]{8,}$/i; const SLACK_QUALIFIED_TARGET_RE = /^team:([^:]+):(user|channel):([^:]+)$/i; function decodeSlackTargetPart(raw: string): string | undefined { @@ -44,7 +44,7 @@ function parseQualifiedSlackTarget(raw: string): SlackTarget | undefined { const teamId = decodeSlackTargetPart(match[1] ?? ""); const kind = match[2]?.toLowerCase() as SlackTargetKind | undefined; const id = decodeSlackTargetPart(match[3] ?? ""); - const idPattern = kind === "user" ? /^[UW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i; + const idPattern = kind === "user" ? /^[BUW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i; if (!teamId || !/^T[A-Z0-9]+$/i.test(teamId) || !kind || !id || !idPattern.test(id)) { throw new Error("Invalid Slack workspace-qualified target"); } @@ -68,7 +68,7 @@ export function formatSlackTarget(params: { if (!teamId) { return params.explicitKind ? `${params.kind}:${id}` : id; } - const idPattern = params.kind === "user" ? /^[UW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i; + const idPattern = params.kind === "user" ? /^[BUW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i; if (!/^T[A-Z0-9]+$/i.test(teamId) || !idPattern.test(id)) { throw new Error("Invalid Slack workspace-qualified target"); } diff --git a/extensions/slack/src/targets.test.ts b/extensions/slack/src/targets.test.ts index 50d269b2114a..e2a8676fafc4 100644 --- a/extensions/slack/src/targets.test.ts +++ b/extensions/slack/src/targets.test.ts @@ -65,6 +65,13 @@ describe("parseSlackTarget", () => { raw: "team:T789:user:U012", normalized: "team:t789:user:u012", }); + expect(parseSlackTarget("team:T789:user:B345")).toEqual({ + kind: "user", + id: "B345", + teamId: "T789", + raw: "team:T789:user:B345", + normalized: "team:t789:user:b345", + }); }); it("formats bare and structurally valid workspace-qualified targets", () => { @@ -72,6 +79,9 @@ describe("parseSlackTarget", () => { "team:T123:channel:C456", ); expect(formatSlackTarget({ kind: "channel", id: "C456" })).toBe("C456"); + expect(formatSlackTarget({ teamId: "T123", kind: "user", id: "B456" })).toBe( + "team:T123:user:B456", + ); expect(() => formatSlackTarget({ teamId: "E123", kind: "channel", id: "C456" })).toThrow( "Invalid Slack workspace-qualified target", ); From 61ab6a8f9d151c84377ba9f90152f257110cc5a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:12:53 -0700 Subject: [PATCH 024/165] feat(gateway): make suspend/resume operator-usable end to end (#122100) * feat(gateway): make suspend/resume operator-usable end to end A prepared Gateway now accepts authenticated WebSocket connects while keeping every method except gateway.suspend.* fenced, so a fresh CLI or controller process can resume a suspension instead of dead-ending on a rejected upgrade until the two-minute lease expires. Restart drain, worker ingress, and desktop-observe streams stay fully closed. The gateway client surfaces non-101 upgrade responses (bounded body read) as typed retryable errors instead of an opaque 1006 close, and new openclaw gateway suspend / resume commands drive the whole handshake, including bounded --wait polling with blocker output. Live-verified on an isolated dev gateway: prepare, SIGSTOP/SIGCONT freeze, resume, over-TTL expiry self-heal, conflict and mismatch paths. * refactor(gateway-client): move wire-client contract types to protocol-client-contract The connectError addition pushed protocol-client.ts over the 700-line max-lines gate; split the adapter-facing contract types into their own module instead of suppressing. * refactor(gateway-client): keep contract-internal option types unexported Knip deadcode gates reject exported types with no importer; the connect and close decision shapes are only referenced inside the contract module. * chore(plugin-sdk): refresh gateway-runtime API baseline after rebase * fix(gateway-client): preserve hello type after rebase * test(gateway): support websocket upgrade rejection events * test(gateway): expect connection errors in close info * fix(gateway): keep prepared-suspension connects control-only Address ClawSweeper review: node and worker connects stay refused while suspension is prepared (only operator control connects pass), and the CLI never issues another suspend prepare after its --wait deadline. --- .../gateway-runtime.json | 2 +- docs/cli/gateway.md | 30 ++++ docs/gateway/external-apps.md | 16 +- docs/gateway/protocol.md | 2 +- .../src/client.handshake.test.ts | 109 ++++++++++- packages/gateway-client/src/client.ts | 69 +++++++ .../src/protocol-client-contract.ts | 114 ++++++++++++ .../gateway-client/src/protocol-client.ts | 127 ++----------- .../register.option-collisions.test.ts | 44 ++++- src/cli/gateway-cli/register.ts | 49 +++++ src/cli/gateway-cli/suspend-cli.test.ts | 169 ++++++++++++++++++ src/cli/gateway-cli/suspend-cli.ts | 157 ++++++++++++++++ src/gateway/call.test.ts | 28 +++ src/gateway/call.ts | 5 + src/gateway/client.test.ts | 6 + src/gateway/probe.device-auth-scope.test.ts | 3 +- src/gateway/server-http.ts | 16 +- src/gateway/server.preauth-hardening.test.ts | 75 +++++++- ...ssage-handler.suspension-admission.test.ts | 162 ++++++++++++----- .../server/ws-connection/message-handler.ts | 35 +++- 20 files changed, 1034 insertions(+), 184 deletions(-) create mode 100644 packages/gateway-client/src/protocol-client-contract.ts create mode 100644 src/cli/gateway-cli/suspend-cli.test.ts create mode 100644 src/cli/gateway-cli/suspend-cli.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 296271a135ae..1751f4ed359a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"85a649a9621f78be5d7e26b01ec4b8d465f7c353109571be30530b5c8c4bd5ac","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"e31574637f3e276338e3e05e4774f7ad79981042c8021e49fb53df7ffe901917","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 046cb63cc8d0..6f0555978526 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -516,6 +516,36 @@ openclaw gateway call logs.tail --params '{"limit": 200}' `--params` must be valid JSON, and each method validates its own param shape (extra/misnamed fields are rejected). Use `--port` for a custom-port local Gateway; explicit `--url` targets still require explicit credentials. +### `gateway suspend` + +Prepare an idle Gateway for a cooperative host freeze or snapshot. Without +`--wait`, active work returns a nonzero exit with blocker details. With +`--wait`, the CLI retries until the bounded deadline using one stable request +ID. + +```bash +openclaw gateway suspend +openclaw gateway suspend --request-id snapshot-2026-08-11 --wait 30 +openclaw gateway suspend --port 18999 --json +``` + +The ready output includes the suspension ID, lease expiry, and the matching +resume command. Common RPC options such as `--url`, `--token`, `--password`, +`--timeout`, `--json`, and `--port` are supported. + +### `gateway resume ` + +Release a prepared suspension after thaw or when the host operation is +abandoned. + +```bash +openclaw gateway resume +openclaw gateway resume --port 18999 --json +``` + +An already expired or resumed lease is a successful no-op. A different active +suspension ID is rejected. + ## Manage the Gateway service ```bash diff --git a/docs/gateway/external-apps.md b/docs/gateway/external-apps.md index 9edae0d38813..4d7b4c2ca873 100644 --- a/docs/gateway/external-apps.md +++ b/docs/gateway/external-apps.md @@ -64,15 +64,15 @@ host-neutral suspension handshake: 4. If it is `ready`, save the returned `suspensionId`, then freeze or snapshot the process before `expiresAtMs`. 5. After thaw, or if suspension is abandoned, call `gateway.suspend.resume` - with that `suspensionId` over the existing WebSocket or Admin HTTP control - path. + with that `suspensionId` over the existing or a newly authenticated + WebSocket. The CLI equivalents are `openclaw gateway suspend` and + `openclaw gateway resume `. -A prepared Gateway rejects new WebSocket handshakes. A WebSocket controller -must keep its authenticated connection open across the host operation. If that -cannot be guaranteed, enable and use the -[Admin HTTP RPC plugin](/plugins/admin-http-rpc) before preparing. If the -control path is lost, wait for the two-minute lease to expire before -reconnecting; expiry reopens admission automatically. +A prepared Gateway accepts authenticated WebSocket connects, but fences every +method except `gateway.suspend.*`. Controllers may reconnect after thaw and +call resume. The [Admin HTTP RPC plugin](/plugins/admin-http-rpc) remains +available for hosts that cannot speak WebSocket at all. If every control path +is lost, the two-minute lease expiry reopens admission automatically. The RPC contract is: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 0323ed32660d..f52522db80d9 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -520,7 +520,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `last-heartbeat` returns the latest persisted heartbeat event. - `set-heartbeats` toggles heartbeat processing on the gateway. - `gateway.restart.preflight` is a deprecated, read-only compatibility preview of restart-specific active work. It does not close admission, create a suspension lease, or provide the atomic full-work fence of `gateway.suspend.prepare`; new restart flows should call `gateway.restart.request`. - - `gateway.suspend.prepare` creates a short cooperative-suspension lease only when tracked Gateway work is idle. `gateway.suspend.status` checks that lease, and `gateway.suspend.resume` releases it after thaw or an aborted host operation. + - `gateway.suspend.prepare` creates a short cooperative-suspension lease only when tracked Gateway work is idle. While prepared, authenticated WebSocket connects remain available, but every method except `gateway.suspend.*` is fenced. `gateway.suspend.status` checks the lease, and `gateway.suspend.resume` releases it after thaw or an aborted host operation. diff --git a/packages/gateway-client/src/client.handshake.test.ts b/packages/gateway-client/src/client.handshake.test.ts index e5eb3f60a2cb..23f93891f7e1 100644 --- a/packages/gateway-client/src/client.handshake.test.ts +++ b/packages/gateway-client/src/client.handshake.test.ts @@ -1,4 +1,5 @@ // Gateway Client tests cover websocket opening-handshake timeout behavior. +import http from "node:http"; import net from "node:net"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; @@ -16,6 +17,9 @@ describe("GatewayClient websocket opening handshakeTimeout", () => { for (const socket of sockets.splice(0)) { socket.destroy(); } + for (const server of servers) { + (server as net.Server & { closeAllConnections?: () => void }).closeAllConnections?.(); + } await Promise.all( servers.splice(0).map( (server) => @@ -26,18 +30,22 @@ describe("GatewayClient websocket opening handshakeTimeout", () => { ); }); + async function listen(server: net.Server): Promise { + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + return (server.address() as AddressInfo).port; + } + it("fails when a peer accepts TCP but never completes the websocket upgrade", async () => { // Accept TCP but never complete the websocket upgrade so missing // handshakeTimeout would leave start() waiting forever for open. const server = net.createServer((socket) => { sockets.push(socket); }); - servers.push(server); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const { port } = server.address() as AddressInfo; + const port = await listen(server); const handshakeTimeoutMs = 250; const startedAt = Date.now(); const outcome = await new Promise<{ @@ -89,4 +97,93 @@ describe("GatewayClient websocket opening handshakeTimeout", () => { }`, ); }); + + it("surfaces a rejected websocket upgrade body through the connection error", async () => { + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(503, { "Content-Type": "text/plain" }); + res.end("Gateway websocket admission closed"); + }); + const port = await listen(server); + const errors: Error[] = []; + let resolveRetry = () => {}; + const retried = new Promise((resolve) => { + resolveRetry = resolve; + }); + const closed = new Promise<{ code: number; connectError?: Error }>((resolve) => { + const client = new GatewayClient({ + url: `ws://127.0.0.1:${port}`, + onConnectError: (error) => { + errors.push(error); + if (errors.length === 2) { + resolveRetry(); + } + }, + onClose: (code, _reason, info) => resolve({ code, connectError: info?.connectError }), + }); + clients.push(client); + client.start(); + }); + + await expect(closed).resolves.toMatchObject({ + code: 1006, + connectError: { + name: "GatewayClientRequestError", + message: + "gateway rejected websocket upgrade (HTTP 503): Gateway websocket admission closed", + gatewayCode: "UNAVAILABLE", + retryable: true, + }, + }); + await retried; + expect(requestCount).toBe(2); + expect(errors).toHaveLength(2); + expect(errors.map((error) => error.message)).toEqual([ + "gateway rejected websocket upgrade (HTTP 503): Gateway websocket admission closed", + "gateway rejected websocket upgrade (HTTP 503): Gateway websocket admission closed", + ]); + }); + + it("caps a rejected websocket upgrade body before the peer ends it", async () => { + const omittedTail = "omitted-tail-marker"; + const server = http.createServer((_req, res) => { + res.writeHead(503, { "Content-Type": "text/plain" }); + res.write(`${"x".repeat(3_000)}${omittedTail}`); + }); + const port = await listen(server); + const error = await new Promise((resolve) => { + const client = new GatewayClient({ + url: `ws://127.0.0.1:${port}`, + onConnectError: resolve, + }); + clients.push(client); + client.start(); + }); + + expect(error.message).toHaveLength( + "gateway rejected websocket upgrade (HTTP 503): ".length + 2 * 1024, + ); + expect(error.message).not.toContain(omittedTail); + }); + + it("times out while reading a stalled websocket upgrade response body", async () => { + const server = http.createServer((_req, res) => { + res.writeHead(503, { "Content-Type": "text/plain" }); + res.write("still suspending"); + }); + const port = await listen(server); + const startedAt = Date.now(); + const error = await new Promise((resolve) => { + const client = new GatewayClient({ + url: `ws://127.0.0.1:${port}`, + onConnectError: resolve, + }); + clients.push(client); + client.start(); + }); + + expect(error.message).toBe("gateway rejected websocket upgrade (HTTP 503): still suspending"); + expect(Date.now() - startedAt).toBeLessThan(1_500); + }); }); diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index d1799a4d763c..7b376c995730 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import type { ClientRequest, IncomingMessage } from "node:http"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -229,6 +230,50 @@ type FingerprintCheckingClientOptions = Omit { + return await new Promise((resolve) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + response.off("data", onData); + response.off("end", finish); + response.off("error", finish); + response.off("aborted", finish); + resolve(Buffer.concat(chunks, totalBytes).toString("utf8").replace(/\s+/gu, " ").trim()); + }; + const stop = () => { + finish(); + response.destroy(); + }; + const onData = (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = MAX_UPGRADE_ERROR_BODY_BYTES - totalBytes; + if (remaining > 0) { + const prefix = buffer.subarray(0, remaining); + chunks.push(prefix); + totalBytes += prefix.byteLength; + } + if (buffer.byteLength >= remaining) { + stop(); + } + }; + const timer = setTimeout(stop, UPGRADE_ERROR_BODY_TIMEOUT_MS); + timer.unref?.(); + response.on("data", onData); + response.once("end", finish); + response.once("error", finish); + response.once("aborted", finish); + }); +} export type GatewayReconnectPausedInfo = { code: number; @@ -241,6 +286,7 @@ export type GatewayClientCloseInfo = { socketOpened: boolean; transportValidated: boolean; transientPreHelloCleanClose: boolean; + connectError?: Error; }; export { GatewayClientRequestError } from "./request-error.js"; @@ -608,6 +654,7 @@ export class GatewayClient { } this.ws = ws; this.transportValidated = false; + let upgradeError: GatewayClientRequestError | undefined; ws.on("open", () => { handlers.open(); if (usesTls && this.opts.tlsFingerprint) { @@ -629,7 +676,28 @@ export class GatewayClient { this.resolvePendingStop(ws); handlers.close(code, reasonText); }); + ws.on("unexpected-response", (request: ClientRequest, response: IncomingMessage) => { + void readUpgradeErrorBody(response).then((body) => { + const statusCode = response.statusCode; + const message = `gateway rejected websocket upgrade (HTTP ${statusCode ?? "unknown"})${body ? `: ${body}` : ""}`; + upgradeError = new GatewayClientRequestError({ + code: "UNAVAILABLE", + message, + retryable: true, + details: { + reason: "websocket-upgrade-rejected", + ...(statusCode === undefined ? {} : { httpStatus: statusCode }), + }, + }); + handlers.error(upgradeError); + request.destroy(); + ws.close(); + }); + }); ws.on("error", (err) => { + if (upgradeError) { + return; + } this.logDebug(`gateway client error: ${formatGatewayClientErrorForLog(err)}`); handlers.error(err instanceof Error ? err : new Error(String(err))); }); @@ -1153,6 +1221,7 @@ export class GatewayClient { transportValidated: this.transportValidated, transientPreHelloCleanClose: !context.helloReceived && context.code === 1000 && context.reason === "", + ...(context.connectFailure?.error ? { connectError: context.connectFailure.error } : {}), }; } diff --git a/packages/gateway-client/src/protocol-client-contract.ts b/packages/gateway-client/src/protocol-client-contract.ts new file mode 100644 index 000000000000..52535b300137 --- /dev/null +++ b/packages/gateway-client/src/protocol-client-contract.ts @@ -0,0 +1,114 @@ +// Wire-client contract types shared by GatewayProtocolClient and its adapters. +import type { ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import type { GatewayProtocolRequestTiming } from "./pending-request.js"; +import type { GatewayProtocolRequestError } from "./protocol-request.js"; + +export type GatewayProtocolSocket = { + isOpen: () => boolean; + send: (data: string) => void; + close: (code?: number, reason?: string) => void; +}; +export type GatewayProtocolSocketHandlers = { + open: () => void; + message: (data: string) => void; + close: (code: number, reason: string) => void; + error: (error: Error) => void; +}; +type GatewayProtocolConnectContext = { + generation: number; + nonce: string | null; + challengeTs: number | null | undefined; + plan: TPlan; +}; +export type GatewayProtocolCloseContext = { + code: number; + reason: string; + generation: number; + socketOpened: boolean; + helloReceived: boolean; + connectRequestSent: boolean; + connectFailure?: { error: Error; reconnectDelayMs?: number }; +}; +type GatewayProtocolConnectDecision = { + closeCode: number; + closeReason: string; + reconnectDelayMs?: number; + stop?: boolean; + error?: Error; +}; +type GatewayProtocolCloseDecision = { + retry: boolean; + notify: boolean; + reconnectDelayMs?: number; + pendingError?: Error; +}; +export type GatewayProtocolTiming = { + phase: + | "socket-open" + | "challenge" + | "fallback" + | "device-identity-ready" + | "connect-plan-ready" + | "request-sent" + | "hello" + | "failed"; + generation: number; + durationMs: number; + phaseDurationMs: number; + hasChallenge: boolean; + usedFallback: boolean; + plan?: TPlan; + detail?: unknown; +}; +export type GatewayProtocolClientOptions = { + createSocket: (handlers: GatewayProtocolSocketHandlers) => GatewayProtocolSocket; + createRequestId: () => string; + createRequestError?: (error: Partial) => GatewayProtocolRequestError; + createRequestTimeoutError?: (method: string, timeoutMs: number, requestSent: boolean) => Error; + createRequestAbortError?: (method: string) => Error; + buildConnectPlan: (params: { + nonce: string | null; + challengeTs: number | null | undefined; + generation: number; + }) => TPlan | Promise; + buildConnectParams: (plan: TPlan) => unknown; + onConnectPlanError?: (error: Error) => GatewayProtocolConnectDecision; + onConnectHello?: (hello: HelloOk, context: GatewayProtocolConnectContext) => void; + onHello?: (hello: HelloOk) => void; + onConnectFailure?: ( + error: GatewayProtocolRequestError, + context: GatewayProtocolConnectContext, + ) => GatewayProtocolConnectDecision; + resolveClose: (context: GatewayProtocolCloseContext) => GatewayProtocolCloseDecision; + onClose?: (context: GatewayProtocolCloseContext, decision: GatewayProtocolCloseDecision) => void; + notifyStoppedClose?: boolean; + onConnectError?: (error: Error) => void; + onSocketFactoryError?: (error: Error) => void; + onParseError?: (error: unknown) => void; + onEvent?: (event: EventFrame) => void; + onGap?: (info: { expected: number; received: number }) => void; + onActivity?: () => void; + onTiming?: (timing: GatewayProtocolTiming) => void; + onRequestTiming?: (timing: GatewayProtocolRequestTiming) => void; + onCallbackError?: (label: string, error: unknown) => void; + handshake: + | { mode: "fallback"; timeoutMs: number } + | { + mode: "require-challenge"; + timeoutMs: number; + timeoutMessage?: (elapsedMs: number) => string; + }; + reconnect: { initialMs: number; multiplier: number; maxMs: number }; + requestTimeoutMs?: number; + nowMs?: () => number; + shouldRetrySocketFactoryError?: (error: Error) => boolean; + rethrowSocketFactoryError?: (error: Error) => boolean; +}; +export type ConnectTimingState = { + generation: number; + startedAtMs: number; + lastAtMs: number; + hasChallenge: boolean; + usedFallback: boolean; +}; +export type CloseSnapshot = Omit; diff --git a/packages/gateway-client/src/protocol-client.ts b/packages/gateway-client/src/protocol-client.ts index 8ce4ca530fe8..9e32a8516d49 100644 --- a/packages/gateway-client/src/protocol-client.ts +++ b/packages/gateway-client/src/protocol-client.ts @@ -1,4 +1,4 @@ -import type { ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; import { isGatewayEventFrame, isGatewayResponseFrame, @@ -20,115 +20,21 @@ export { type GatewayProtocolRequestTiming, }; -export type GatewayProtocolSocket = { - isOpen: () => boolean; - send: (data: string) => void; - close: (code?: number, reason?: string) => void; -}; -export type GatewayProtocolSocketHandlers = { - open: () => void; - message: (data: string) => void; - close: (code: number, reason: string) => void; - error: (error: Error) => void; -}; -type GatewayProtocolConnectContext = { - generation: number; - nonce: string | null; - challengeTs: number | null | undefined; - plan: TPlan; -}; -export type GatewayProtocolCloseContext = { - code: number; - reason: string; - generation: number; - socketOpened: boolean; - helloReceived: boolean; - connectRequestSent: boolean; - connectFailure?: { error: Error; reconnectDelayMs?: number }; -}; -type GatewayProtocolConnectDecision = { - closeCode: number; - closeReason: string; - reconnectDelayMs?: number; - stop?: boolean; - error?: Error; -}; -type GatewayProtocolCloseDecision = { - retry: boolean; - notify: boolean; - reconnectDelayMs?: number; - pendingError?: Error; -}; -export type GatewayProtocolTiming = { - phase: - | "socket-open" - | "challenge" - | "fallback" - | "device-identity-ready" - | "connect-plan-ready" - | "request-sent" - | "hello" - | "failed"; - generation: number; - durationMs: number; - phaseDurationMs: number; - hasChallenge: boolean; - usedFallback: boolean; - plan?: TPlan; - detail?: unknown; -}; -type GatewayProtocolClientOptions = { - createSocket: (handlers: GatewayProtocolSocketHandlers) => GatewayProtocolSocket; - createRequestId: () => string; - createRequestError?: (error: Partial) => GatewayProtocolRequestError; - createRequestTimeoutError?: (method: string, timeoutMs: number, requestSent: boolean) => Error; - createRequestAbortError?: (method: string) => Error; - buildConnectPlan: (params: { - nonce: string | null; - challengeTs: number | null | undefined; - generation: number; - }) => TPlan | Promise; - buildConnectParams: (plan: TPlan) => unknown; - onConnectPlanError?: (error: Error) => GatewayProtocolConnectDecision; - onConnectHello?: (hello: HelloOk, context: GatewayProtocolConnectContext) => void; - onHello?: (hello: HelloOk) => void; - onConnectFailure?: ( - error: GatewayProtocolRequestError, - context: GatewayProtocolConnectContext, - ) => GatewayProtocolConnectDecision; - resolveClose: (context: GatewayProtocolCloseContext) => GatewayProtocolCloseDecision; - onClose?: (context: GatewayProtocolCloseContext, decision: GatewayProtocolCloseDecision) => void; - notifyStoppedClose?: boolean; - onConnectError?: (error: Error) => void; - onSocketFactoryError?: (error: Error) => void; - onParseError?: (error: unknown) => void; - onEvent?: (event: EventFrame) => void; - onGap?: (info: { expected: number; received: number }) => void; - onActivity?: () => void; - onTiming?: (timing: GatewayProtocolTiming) => void; - onRequestTiming?: (timing: GatewayProtocolRequestTiming) => void; - onCallbackError?: (label: string, error: unknown) => void; - handshake: - | { mode: "fallback"; timeoutMs: number } - | { - mode: "require-challenge"; - timeoutMs: number; - timeoutMessage?: (elapsedMs: number) => string; - }; - reconnect: { initialMs: number; multiplier: number; maxMs: number }; - requestTimeoutMs?: number; - nowMs?: () => number; - shouldRetrySocketFactoryError?: (error: Error) => boolean; - rethrowSocketFactoryError?: (error: Error) => boolean; -}; -type ConnectTimingState = { - generation: number; - startedAtMs: number; - lastAtMs: number; - hasChallenge: boolean; - usedFallback: boolean; -}; -type CloseSnapshot = Omit; +import type { + CloseSnapshot, + ConnectTimingState, + GatewayProtocolClientOptions, + GatewayProtocolCloseContext, + GatewayProtocolSocket, + GatewayProtocolTiming, +} from "./protocol-client-contract.js"; + +export type { + GatewayProtocolCloseContext, + GatewayProtocolSocket, + GatewayProtocolSocketHandlers, + GatewayProtocolTiming, +} from "./protocol-client-contract.js"; /** * Browser-safe gateway wire client. Environment adapters own transport and auth @@ -571,6 +477,7 @@ export class GatewayProtocolClient { if (!this.isActive(socket, generation) || this.connectSent) { return; } + this.connectFailure = { error }; this.opts.onConnectError?.(error); } diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 81f0fdf8d817..c26e127a29a9 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -4,9 +4,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerGatewayCli } from "./register.js"; const mocks = vi.hoisted(() => ({ - callGatewayCli: vi.fn(async (_method: string, _opts: unknown, _params?: unknown) => ({ - ok: true, - })), + callGatewayCli: vi.fn(async (method: string, _opts: unknown, _params?: unknown) => { + if (method === "gateway.suspend.prepare") { + return { + status: "ready", + suspensionId: "suspension-1", + expiresAtMs: 1_800_000_000_000, + activeCount: 0, + blockers: [], + }; + } + if (method === "gateway.suspend.resume") { + return { ok: true, status: "running", resumed: true }; + } + return { ok: true }; + }), emitReachableGatewayAuthDiagnostic: vi.fn(async (_params: unknown) => false), formatHealthChannelLines: vi.fn(() => []), gatewayStatusCommand: vi.fn(async (_opts: unknown, _runtime: unknown) => {}), @@ -216,6 +228,32 @@ describe("gateway register option collisions", () => { expectLocalGatewayCall("health", 19085); }, }, + { + name: "projects gateway suspend --port and request id", + argv: ["gateway", "suspend", "--request-id", "host-operation", "--port", "19086", "--json"], + assert: () => { + expectLocalGatewayCall("gateway.suspend.prepare", 19086, { + requestId: "host-operation", + }); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ status: "ready", requestId: "host-operation" }), + ); + }, + }, + { + name: "inherits parent --port for gateway resume", + argv: ["gateway", "--port", "19087", "resume", "suspension-1", "--json"], + assert: () => { + expectLocalGatewayCall("gateway.suspend.resume", 19087, { + suspensionId: "suspension-1", + }); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ + ok: true, + status: "running", + resumed: true, + }); + }, + }, { name: "forwards --token to gateway probe when parent and child option names collide", argv: ["gateway", "probe", "--token", "tok_probe", "--json"], diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index b10ea56eb717..4643b0a9e85c 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -27,6 +27,7 @@ import type { GatewayDiscoverOpts } from "./discover.js"; import { isGatewayMachineOutput } from "./output-mode.js"; import { addGatewayRestartHandoffCommands } from "./register-restart-handoff.js"; import { addGatewayRunCommand } from "./run-command.js"; +import { runGatewayResume, runGatewaySuspend } from "./suspend-cli.js"; type GatewayRpcOpts = Parameters[1]; @@ -595,6 +596,54 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie }), ); + gatewayCallOpts( + gateway + .command("suspend") + .description("Prepare the Gateway for cooperative host suspension") + .option("--request-id ", "Stable suspension request id") + .option("--wait ", "Wait up to this many seconds for active work to drain") + .option("--port ", "Local Gateway port") + .action(async (opts, command) => { + await runGatewayCommand( + async () => { + const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command); + await runGatewaySuspend( + { + rpcOpts, + requestId: opts.requestId, + waitSeconds: opts.wait, + json: Boolean(rpcOpts.json), + }, + { callGateway: callGatewayCli, runtime: defaultRuntime }, + ); + }, + "Gateway suspend failed", + { json: Boolean(opts.json) }, + ); + }), + ); + + gatewayCallOpts( + gateway + .command("resume") + .description("Release a cooperative Gateway suspension") + .argument("", "Suspension id returned by gateway suspend") + .option("--port ", "Local Gateway port") + .action(async (suspensionId, opts, command) => { + await runGatewayCommand( + async () => { + const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command); + await runGatewayResume( + { rpcOpts, suspensionId: String(suspensionId), json: Boolean(rpcOpts.json) }, + { callGateway: callGatewayCli, runtime: defaultRuntime }, + ); + }, + "Gateway resume failed", + { json: Boolean(opts.json) }, + ); + }), + ); + gatewayCallOpts( gateway .command("usage-cost") diff --git a/src/cli/gateway-cli/suspend-cli.test.ts b/src/cli/gateway-cli/suspend-cli.test.ts new file mode 100644 index 000000000000..a5193f7fa706 --- /dev/null +++ b/src/cli/gateway-cli/suspend-cli.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OutputRuntimeEnv } from "../../runtime.js"; +import { runGatewayResume, runGatewaySuspend } from "./suspend-cli.js"; + +function createRuntime(): OutputRuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + writeStdout: vi.fn(), + writeJson: vi.fn(), + exit: vi.fn(), + }; +} + +const readyResult = { + status: "ready" as const, + suspensionId: "suspension-1", + expiresAtMs: Date.parse("2026-08-11T12:00:00.000Z"), + activeCount: 0, + blockers: [], +}; + +const busyResult = { + status: "busy" as const, + reason: "active-work" as const, + retryAfterMs: 200, + activeCount: 1, + blockers: [{ kind: "root-request" as const, count: 1, message: "1 active request" }], +}; + +describe("gateway suspend CLI", () => { + beforeEach(() => vi.clearAllMocks()); + + it("prints a ready lease with the default CLI request id", async () => { + const callGateway = vi.fn(async () => readyResult); + const runtime = createRuntime(); + + await runGatewaySuspend({ rpcOpts: {} }, { callGateway, runtime }); + + expect(callGateway).toHaveBeenCalledWith( + "gateway.suspend.prepare", + {}, + { requestId: expect.stringMatching(/^cli-[0-9a-f]{8}$/u) }, + ); + expect(callGateway).toHaveBeenCalledOnce(); + expect(runtime.log).toHaveBeenCalledWith("Gateway suspension prepared."); + expect(runtime.log).toHaveBeenCalledWith("Suspension ID: suspension-1"); + expect(runtime.log).toHaveBeenCalledWith( + `Expires: 2026-08-11T12:00:00.000Z (${readyResult.expiresAtMs} ms)`, + ); + expect(runtime.log).toHaveBeenCalledWith("Resume with: openclaw gateway resume suspension-1"); + }); + + it("reports blockers without polling when --wait is omitted", async () => { + const callGateway = vi.fn(async () => busyResult); + + await expect( + runGatewaySuspend( + { rpcOpts: {}, requestId: "host-operation" }, + { callGateway, runtime: createRuntime() }, + ), + ).rejects.toThrow( + "Gateway suspension is busy (active-work; 1 active).\nBlockers:\n- 1 active request\nRetry later or use --wait .", + ); + expect(callGateway).toHaveBeenCalledOnce(); + }); + + it("polls with one stable request id until the Gateway is ready", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce(busyResult) + .mockResolvedValueOnce(readyResult); + let now = 1_000; + const sleep = vi.fn(async (delayMs: number) => { + now += delayMs; + }); + + await runGatewaySuspend( + { rpcOpts: {}, requestId: "host-operation", waitSeconds: "2" }, + { callGateway, runtime: createRuntime(), nowMs: () => now, sleep }, + ); + + expect(sleep).toHaveBeenCalledExactlyOnceWith(200); + expect(callGateway).toHaveBeenCalledTimes(2); + expect(callGateway.mock.calls.map((call) => call[2])).toEqual([ + { requestId: "host-operation" }, + { requestId: "host-operation" }, + ]); + }); + + it("emits the latest busy result and exits nonzero in JSON mode", async () => { + const runtime = createRuntime(); + + await runGatewaySuspend( + { rpcOpts: { json: true }, requestId: "host-operation", json: true }, + { callGateway: vi.fn(async () => busyResult), runtime }, + ); + + expect(runtime.writeJson).toHaveBeenCalledWith({ + ...busyResult, + requestId: "host-operation", + }); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("never issues another prepare after a sleep overshoots the deadline", async () => { + let now = 1_000; + const callGateway = vi.fn(async () => busyResult); + + await expect( + runGatewaySuspend( + { rpcOpts: {}, requestId: "host-operation", waitSeconds: "0.2" }, + { + callGateway, + runtime: createRuntime(), + nowMs: () => now, + sleep: async () => { + // A lagging clock can wake far past the advertised --wait window. + now += 10_000; + }, + }, + ), + ).rejects.toThrow("Timed out waiting for the Gateway to become idle."); + expect(callGateway).toHaveBeenCalledOnce(); + }); + + it("reports the latest blockers when the wait deadline expires", async () => { + let now = 1_000; + + await expect( + runGatewaySuspend( + { rpcOpts: {}, requestId: "host-operation", waitSeconds: "0.1" }, + { + callGateway: vi.fn(async () => busyResult), + runtime: createRuntime(), + nowMs: () => now, + sleep: async (delayMs) => { + now += delayMs; + }, + }, + ), + ).rejects.toThrow( + "Gateway suspension is busy (active-work; 1 active).\nBlockers:\n- 1 active request\nTimed out waiting for the Gateway to become idle.", + ); + }); +}); + +describe("gateway resume CLI", () => { + it.each([ + { resumed: true, message: "Gateway resumed." }, + { + resumed: false, + message: + "No matching suspension was held (lease already expired or resumed); gateway is running.", + }, + ])("prints the resumed=$resumed outcome", async ({ resumed, message }) => { + const runtime = createRuntime(); + const callGateway = vi.fn(async () => ({ ok: true, status: "running", resumed })); + + await runGatewayResume({ rpcOpts: {}, suspensionId: "suspension-1" }, { callGateway, runtime }); + + expect(callGateway).toHaveBeenCalledExactlyOnceWith( + "gateway.suspend.resume", + {}, + { suspensionId: "suspension-1" }, + ); + expect(runtime.log).toHaveBeenCalledExactlyOnceWith(message); + }); +}); diff --git a/src/cli/gateway-cli/suspend-cli.ts b/src/cli/gateway-cli/suspend-cli.ts new file mode 100644 index 000000000000..4700b2b0b362 --- /dev/null +++ b/src/cli/gateway-cli/suspend-cli.ts @@ -0,0 +1,157 @@ +import { randomBytes } from "node:crypto"; +import type { + GatewaySuspendPrepareResult, + GatewaySuspendResumeResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; +import type { OutputRuntimeEnv } from "../../runtime.js"; +import type { callGatewayFromCliWithTransport } from "../gateway-rpc.js"; + +type SuspendRpcOpts = Parameters[1]; + +type SuspendRpcCall = (method: string, opts: SuspendRpcOpts, params?: unknown) => Promise; + +type SuspendCliDeps = { + callGateway: SuspendRpcCall; + runtime: OutputRuntimeEnv; + nowMs?: () => number; + sleep?: (delayMs: number) => Promise; +}; + +const MIN_SUSPEND_POLL_DELAY_MS = 50; + +function parseWaitMs(value: string | number | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const seconds = typeof value === "number" ? value : Number(value.trim()); + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error("--wait must be a non-negative number of seconds"); + } + const milliseconds = Math.floor(seconds * 1_000); + if (!Number.isSafeInteger(milliseconds)) { + throw new Error("--wait is too large"); + } + return milliseconds; +} + +function resolveRequestId(value: string | undefined): string { + if (value === undefined) { + return `cli-${randomBytes(4).toString("hex")}`; + } + const requestId = value.trim(); + if (!requestId || requestId.length > 128) { + throw new Error("--request-id must contain 1 to 128 characters"); + } + return requestId; +} + +function formatBusyResult( + result: Extract, +): string { + const blockers = result.blockers.map((blocker) => `- ${blocker.message}`); + return [ + `Gateway suspension is busy (${result.reason}; ${result.activeCount} active).`, + ...(blockers.length > 0 ? ["Blockers:", ...blockers] : []), + ].join("\n"); +} + +function writeSuspendJson( + runtime: OutputRuntimeEnv, + result: GatewaySuspendPrepareResult, + requestId: string, +): void { + runtime.writeJson({ ...result, requestId }); +} + +export async function runGatewaySuspend( + options: { + rpcOpts: SuspendRpcOpts; + requestId?: string; + waitSeconds?: string | number; + json?: boolean; + }, + deps: SuspendCliDeps, +): Promise { + const nowMs = deps.nowMs ?? Date.now; + const sleep = + deps.sleep ?? + (async (delayMs: number) => + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + })); + const requestId = resolveRequestId(options.requestId); + const waitMs = parseWaitMs(options.waitSeconds); + const deadlineMs = waitMs === undefined ? undefined : nowMs() + waitMs; + const maxAttempts = waitMs === undefined ? 1 : Math.ceil(waitMs / MIN_SUSPEND_POLL_DELAY_MS) + 1; + let latest: GatewaySuspendPrepareResult | undefined; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + // A sleep can overshoot the deadline; never issue a prepare that could + // suspend the Gateway after the operator's advertised --wait window. + if (attempt > 0 && deadlineMs !== undefined && nowMs() >= deadlineMs) { + break; + } + latest = (await deps.callGateway("gateway.suspend.prepare", options.rpcOpts, { + requestId, + })) as GatewaySuspendPrepareResult; + if (latest.status === "ready") { + if (options.json) { + writeSuspendJson(deps.runtime, latest, requestId); + return; + } + const rich = isRich(); + deps.runtime.log(colorize(rich, theme.success, "Gateway suspension prepared.")); + deps.runtime.log(`${colorize(rich, theme.muted, "Suspension ID:")} ${latest.suspensionId}`); + deps.runtime.log( + `${colorize(rich, theme.muted, "Expires:")} ${new Date(latest.expiresAtMs).toISOString()} (${latest.expiresAtMs} ms)`, + ); + deps.runtime.log(`Resume with: openclaw gateway resume ${latest.suspensionId}`); + return; + } + + if (deadlineMs === undefined) { + if (options.json) { + writeSuspendJson(deps.runtime, latest, requestId); + deps.runtime.exit(1); + return; + } + throw new Error(`${formatBusyResult(latest)}\nRetry later or use --wait .`); + } + + const remainingMs = deadlineMs - nowMs(); + if (remainingMs <= 0) { + break; + } + const delayMs = Math.min(remainingMs, Math.max(MIN_SUSPEND_POLL_DELAY_MS, latest.retryAfterMs)); + await sleep(delayMs); + } + + if (!latest || latest.status !== "busy") { + throw new Error("Gateway suspension polling ended without a result"); + } + if (options.json) { + writeSuspendJson(deps.runtime, latest, requestId); + deps.runtime.exit(1); + return; + } + throw new Error(`${formatBusyResult(latest)}\nTimed out waiting for the Gateway to become idle.`); +} + +export async function runGatewayResume( + options: { rpcOpts: SuspendRpcOpts; suspensionId: string; json?: boolean }, + deps: Pick, +): Promise { + const result = (await deps.callGateway("gateway.suspend.resume", options.rpcOpts, { + suspensionId: options.suspensionId, + })) as GatewaySuspendResumeResult; + if (options.json) { + deps.runtime.writeJson(result); + return; + } + deps.runtime.log( + result.resumed + ? "Gateway resumed." + : "No matching suspension was held (lease already expired or resumed); gateway is running.", + ); +} diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index e8a87306eaf3..d27a71674e46 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1703,6 +1703,34 @@ describe("callGateway error details", () => { }); }); + it("surfaces a websocket upgrade rejection carried by close info", async () => { + startMode = "silent"; + setLocalLoopbackGatewayConfig(); + const upgradeError = Object.assign( + new Error( + "gateway rejected websocket upgrade (HTTP 503): Gateway websocket admission closed", + ), + { + name: "GatewayClientRequestError", + gatewayCode: "UNAVAILABLE", + details: { reason: "websocket-upgrade-rejected", httpStatus: 503 }, + retryable: true, + }, + ); + + const request = callGateway({ method: "health" }); + await waitForFast(() => expect(lastClientOptions).not.toBeNull()); + lastClientOptions?.onClose?.(1006, "", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + transientPreHelloCleanClose: false, + connectError: upgradeError, + }); + + await expect(request).rejects.toBe(upgradeError); + }); + it.each([ { name: "another structured auth rejection", diff --git a/src/gateway/call.ts b/src/gateway/call.ts index dd18b956761d..df7a9bade273 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -984,6 +984,11 @@ async function executeGatewayRequestWithScopes(params: { if (settled || ignoreClose) { return; } + if (info?.connectError) { + ignoreClose = true; + stop(info.connectError); + return; + } if ( !primaryRequestStarted && info?.transientPreHelloCleanClose === true && diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index b07ae3a0e43a..a2fee29aa28e 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -809,6 +809,7 @@ describe("GatewayClient close handling", () => { expect.objectContaining({ message: "gateway tls fingerprint mismatch" }), ); expect(onClose).toHaveBeenCalledWith(1008, "gateway tls fingerprint mismatch", { + connectError: expect.objectContaining({ message: "gateway tls fingerprint mismatch" }), phase: "pre-hello", socketOpened: true, transportValidated: false, @@ -2591,6 +2592,11 @@ describe("GatewayClient connect auth payload", () => { "gateway client reconnect paused handler error: Error: paused callback failed", ); expect(onClose).toHaveBeenCalledWith(1008, "connect failed", { + connectError: expect.objectContaining({ + details: { code: "AUTH_TOKEN_MISSING" }, + gatewayCode: "INVALID_REQUEST", + message: "unauthorized", + }), phase: "pre-hello", socketOpened: true, transportValidated: true, diff --git a/src/gateway/probe.device-auth-scope.test.ts b/src/gateway/probe.device-auth-scope.test.ts index 2d64190c7569..deda900418d0 100644 --- a/src/gateway/probe.device-auth-scope.test.ts +++ b/src/gateway/probe.device-auth-scope.test.ts @@ -7,7 +7,7 @@ import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withTempDir } from "../test-utils/temp-dir.js"; -type WebSocketEvent = "open" | "message" | "close" | "error"; +type WebSocketEvent = "open" | "message" | "close" | "error" | "unexpected-response"; const webSockets = vi.hoisted((): ProbeWebSocket[] => []); @@ -25,6 +25,7 @@ class ProbeWebSocket { message: [], close: [], error: [], + "unexpected-response": [], }; constructor(_url: string, _options?: unknown) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 3daacb523957..8d3307ecb8a2 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -18,7 +18,11 @@ import { createDiagnosticTraceContext, runWithDiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; -import { isGatewayWorkAdmissionClosed } from "../process/gateway-work-admission.js"; +import { + getGatewaySuspendAdmissionPhase, + isGatewayRestartDraining, + isGatewayWorkAdmissionClosed, +} from "../process/gateway-work-admission.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveAssistantIdentity } from "./assistant-identity.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; @@ -782,7 +786,12 @@ function handleBudgetedGatewayWebSocketUpgrade(params: { prepareSocket?: (socket: GatewayIngressWebSocket) => void; }): void { const { req, socket, head, wss, preauthConnectionBudget, preauthBudgetKey, ingressName } = params; - if (isGatewayWorkAdmissionClosed()) { + if ( + isGatewayWorkAdmissionClosed() && + (ingressName === "Worker" || + isGatewayRestartDraining() || + getGatewaySuspendAdmissionPhase() !== "prepared") + ) { writeGatewayUpgradeServiceUnavailable(socket, `${ingressName} websocket admission closed`); socket.destroy(); return; @@ -962,8 +971,7 @@ export function attachGatewayUpgradeHandler(opts: { return; } // Plugin-owned upgrade routes have already had the opportunity to claim the socket. - // Core Gateway upgrades must stop at the HTTP boundary so a client cannot hold an - // untracked pre-connect socket after suspension or restart admission closes. + // Core Gateway control connections remain reachable while suspension is prepared. try { handleBudgetedGatewayWebSocketUpgrade({ req, diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 3cce6ab91376..d18ae25f3291 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -9,7 +9,11 @@ import { resetDiagnosticEventsForTest, type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; -import { tryBeginGatewaySuspendAdmission } from "../process/gateway-work-admission.js"; +import { + markGatewayRestartDraining, + resetGatewayWorkAdmission, + tryBeginGatewaySuspendAdmission, +} from "../process/gateway-work-admission.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; @@ -43,6 +47,7 @@ const PREAUTH_HANDSHAKE_TEST_CLOSE_LIMIT_MS = 5_000; const cleanupEnv: Array<() => void> = []; afterEach(async () => { + resetGatewayWorkAdmission(); while (cleanupEnv.length > 0) { cleanupEnv.pop()?.(); } @@ -152,6 +157,39 @@ describe("gateway pre-auth hardening", () => { } }); + it("rejects worker websocket upgrades after suspension is prepared", async () => { + const httpServer = http.createServer(); + const wss = new WebSocketServer({ maxPayload: 1024, noServer: true }); + wss.on("connection", (socket) => socket.close()); + attachWorkerGatewayUpgradeHandler({ + httpServer, + wss, + preauthConnectionBudget: createPreauthConnectionBudget(1), + }); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + const address = httpServer.address(); + const port = typeof address === "object" && address ? address.port : 0; + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension?.commit()).toBe(true); + + try { + await expect(requestUpgradeRejection(port)).resolves.toEqual({ + status: 503, + body: "Worker websocket admission closed", + }); + } finally { + suspension?.release(); + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("rejects upgrades before websocket handlers attach (pre-auth budget enforced, then released)", async () => { const clients = new Set(); const resolvedAuth: ResolvedGatewayAuth = { mode: "none", allowTailscale: false }; @@ -196,18 +234,49 @@ describe("gateway pre-auth hardening", () => { } }); - it("rejects core websocket upgrades while suspension admission is closed", async () => { + it("accepts core websocket upgrades after suspension is prepared", async () => { const harness = await createGatewaySuiteHarness(); const suspension = tryBeginGatewaySuspendAdmission(() => {}); expect(suspension?.commit()).toBe(true); + try { + const ws = await harness.openWs(); + await expect(readConnectChallengeNonce(ws)).resolves.toEqual(expect.any(String)); + ws.close(); + await new Promise((resolve) => { + ws.once("close", () => resolve()); + }); + } finally { + suspension?.release(); + await harness.close(); + } + }); + + it("rejects core websocket upgrades while suspension is preparing", async () => { + const harness = await createGatewaySuiteHarness(); + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + + try { + await expect(requestUpgradeRejection(harness.port)).resolves.toEqual({ + status: 503, + body: "Gateway websocket admission closed", + }); + } finally { + suspension?.rollback(); + await harness.close(); + } + }); + + it("rejects core websocket upgrades during restart drain", async () => { + const harness = await createGatewaySuiteHarness(); + markGatewayRestartDraining(); + try { await expect(requestUpgradeRejection(harness.port)).resolves.toEqual({ status: 503, body: "Gateway websocket admission closed", }); } finally { - suspension?.release(); await harness.close(); } }); diff --git a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts index 8868483ad56c..89a211edf947 100644 --- a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts @@ -5,6 +5,7 @@ import type { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js"; import { getActiveGatewayRootWorkCount, + markGatewayRestartDraining, resetGatewayWorkAdmission, tryBeginGatewaySuspendAdmission, } from "../../../process/gateway-work-admission.js"; @@ -149,6 +150,27 @@ function attachHarness(params: { deferSocketSend?: boolean } = {}) { }, }), ), + sendNodeConnect: () => + onMessage?.( + JSON.stringify({ + type: "req", + id: "node-connect-1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "node", + scopes: [], + caps: [], + }, + }), + ), sendWorkerConnect: () => onMessage?.( JSON.stringify({ @@ -173,54 +195,106 @@ beforeEach(() => { afterEach(resetGatewayWorkAdmission); describe("WebSocket connect suspension admission", () => { - it.each(["preparing", "prepared"] as const)( - "rejects a validated connect while suspension is %s before session mutations", - async (phase) => { - const suspension = tryBeginGatewaySuspendAdmission(() => {}); - expect(suspension).not.toBeNull(); - if (phase === "prepared") { - expect(suspension?.commit()).toBe(true); - } - const harness = attachHarness(); + it("rejects a validated connect while suspension is preparing before session mutations", async () => { + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension).not.toBeNull(); + const harness = attachHarness(); - harness.sendConnect(); + harness.sendConnect(); - await vi.waitFor(() => { - expect(harness.socketSend).toHaveBeenCalledOnce(); - }); - const response = JSON.parse(harness.socketSend.mock.calls[0]?.[0] ?? "{}") as { - error?: { - code?: string; - retryable?: boolean; - retryAfterMs?: number; - details?: Record; - }; + await vi.waitFor(() => { + expect(harness.socketSend).toHaveBeenCalledOnce(); + }); + const response = JSON.parse(harness.socketSend.mock.calls[0]?.[0] ?? "{}") as { + error?: { + code?: string; + retryable?: boolean; + retryAfterMs?: number; + details?: Record; }; - expect(response.error).toMatchObject({ - code: "UNAVAILABLE", - retryable: true, - retryAfterMs: 1_000, - details: { - method: "connect", - reason: "gateway-suspending", - phase, - }, - }); - expect(harness.client).toBeNull(); - expect(harness.setClient).not.toHaveBeenCalled(); - expect(upsertPresenceMock).not.toHaveBeenCalled(); - expect(incrementPresenceVersionMock).not.toHaveBeenCalled(); - await vi.waitFor(() => { - expect(harness.close).toHaveBeenCalledWith(1013, "gateway suspension in progress"); - }); + }; + expect(response.error).toMatchObject({ + code: "UNAVAILABLE", + retryable: true, + retryAfterMs: 1_000, + details: { + method: "connect", + reason: "gateway-suspending", + phase: "preparing", + }, + }); + expect(harness.client).toBeNull(); + expect(harness.setClient).not.toHaveBeenCalled(); + expect(upsertPresenceMock).not.toHaveBeenCalled(); + expect(incrementPresenceVersionMock).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(harness.close).toHaveBeenCalledWith(1013, "gateway suspension in progress"); + }); + suspension?.rollback(); + }); - if (phase === "prepared") { - suspension?.release(); - } else { - suspension?.rollback(); - } - }, - ); + it("accepts a validated connect while suspension is prepared", async () => { + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension?.commit()).toBe(true); + const harness = attachHarness(); + + harness.sendConnect(); + + await vi.waitFor(() => { + expect(harness.setClient).toHaveBeenCalledOnce(); + }); + expect(harness.client).not.toBeNull(); + expect(harness.close).not.toHaveBeenCalled(); + suspension?.release(); + }); + + it("rejects a node connect while suspension is prepared", async () => { + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension?.commit()).toBe(true); + const harness = attachHarness(); + + harness.sendNodeConnect(); + + await vi.waitFor(() => { + expect(harness.socketSend).toHaveBeenCalledOnce(); + }); + const response = JSON.parse(harness.socketSend.mock.calls[0]?.[0] ?? "{}") as { + error?: { details?: Record }; + }; + expect(response.error?.details).toMatchObject({ + method: "connect", + reason: "gateway-suspending", + phase: "prepared", + }); + expect(harness.setClient).not.toHaveBeenCalled(); + expect(upsertPresenceMock).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(harness.close).toHaveBeenCalledWith(1013, "gateway suspension in progress"); + }); + suspension?.release(); + }); + + it("rejects a validated connect during restart drain", async () => { + markGatewayRestartDraining(); + const harness = attachHarness(); + + harness.sendConnect(); + + await vi.waitFor(() => { + expect(harness.socketSend).toHaveBeenCalledOnce(); + }); + const response = JSON.parse(harness.socketSend.mock.calls[0]?.[0] ?? "{}") as { + error?: { details?: Record }; + }; + expect(response.error?.details).toMatchObject({ + method: "connect", + reason: "gateway-restarting", + }); + expect(harness.setClient).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(harness.close).toHaveBeenCalledWith(1013, "gateway restart in progress"); + }); + }); it("keeps an accepted handshake visible as root work until hello is sent", async () => { const harness = attachHarness({ deferSocketSend: true }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 560c034b559d..6aa2d785801b 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -402,21 +402,38 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } }; - const rejectConnectForClosedAdmission = async (data: RawData): Promise => { + const parsePreauthConnectFrame = (data: RawData) => { if (isClosed() || rawDataByteLength(data) > MAX_PREAUTH_PAYLOAD_BYTES) { - return false; + return null; } let parsed: unknown; try { parsed = JSON.parse(rawDataToString(data)); } catch { - return false; + return null; } if ( !validateRequestFrame(parsed) || parsed.method !== "connect" || !validateConnectParams(parsed.params) ) { + return null; + } + return parsed; + }; + + const isPreparedControlConnect = (data: RawData): boolean => { + const parsed = parsePreauthConnectFrame(data); + if (!parsed) { + return false; + } + const connectParams = parsed.params as { role?: unknown }; + return connectParams.role !== "node" && !claimsWorkerConnectionIdentity(parsed.params); + }; + + const rejectConnectForClosedAdmission = async (data: RawData): Promise => { + const parsed = parsePreauthConnectFrame(data); + if (!parsed) { return false; } @@ -457,6 +474,18 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } const admission = tryBeginGatewayRootWorkAdmission(); if (!admission) { + if ( + !isGatewayRestartDraining() && + getGatewaySuspendAdmissionPhase() === "prepared" && + isPreparedControlConnect(data) + ) { + // Refuse-only suspension fences work, not control-plane visibility. Only + // operator connects are admitted while prepared, and they can only reach + // suspend-control methods after handshake; node and worker connects would + // attach presence/registry state, so they stay refused. + await handleMessage(data); + return; + } if (await rejectConnectForClosedAdmission(data)) { return; } From 526ae6d94405d89df70d503f59b0997488f2b612 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:12:56 -0700 Subject: [PATCH 025/165] fix(macos): honor While Using location permission (#122435) --- .../OpenClaw/NodeMode/MacNodeRuntime.swift | 11 ++----- .../MacNodeRuntimeTests.swift | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 16d4a38d7d2f..646d4e9e45ed 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -500,14 +500,9 @@ extension MacNodeRuntime { (Self.locationPreciseEnabled() ? .precise : .balanced) let services = await mainActorServices() let status = await services.locationAuthorizationStatus() - let hasPermission = switch mode { - case .always: - status == .authorizedAlways - case .whileUsing: - status == .authorizedAlways - case .off: - false - } + let hasPermission = PermissionManager.isLocationAuthorized( + status: status, + requireAlways: mode == .always) if !hasPermission { return BridgeInvokeResponse( id: req.id, diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift index abfb06a1af18..f8b849787b2e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -130,6 +130,7 @@ struct MacNodeRuntimeTests { var actError: Error? var performCallCount = 0 var releaseCallCount = 0 + var locationStatus: CLAuthorizationStatus var receivedLifecycleGenerations: [UInt64] = [] var receivedReleaseGenerations: [UInt64] = [] private let snapshotInspection: SnapshotInspection? @@ -147,6 +148,7 @@ struct MacNodeRuntimeTests { snapshotError: Error? = nil, snapshotInspection: SnapshotInspection? = nil, actError: Error? = nil, + locationAuthorizationStatus: CLAuthorizationStatus = .authorizedAlways, performEnteredGate: AsyncTestGate? = nil, allowPerformGate: AsyncTestGate? = nil) { @@ -154,6 +156,7 @@ struct MacNodeRuntimeTests { self.snapshotError = snapshotError self.snapshotInspection = snapshotInspection self.actError = actError + self.locationStatus = locationAuthorizationStatus self.performEnteredGate = performEnteredGate self.allowPerformGate = allowPerformGate } @@ -192,7 +195,7 @@ struct MacNodeRuntimeTests { } func locationAuthorizationStatus() -> CLAuthorizationStatus { - .authorizedAlways + self.locationStatus } func locationAccuracyAuthorization() -> CLAccuracyAuthorization { @@ -453,6 +456,30 @@ struct MacNodeRuntimeTests { } } + @Test func `handle location invoke applies authorization required by mode`() async throws { + let authorizedWhenInUse = try #require(CLAuthorizationStatus(rawValue: 4)) + let cases: [(mode: OpenClawLocationMode, status: CLAuthorizationStatus, accepted: Bool)] = [ + (.whileUsing, authorizedWhenInUse, true), + (.always, authorizedWhenInUse, false), + (.whileUsing, .authorizedAlways, true), + (.always, .authorizedAlways, true), + ] + + for testCase in cases { + await TestIsolation.withUserDefaultsValues([locationModeKey: testCase.mode.rawValue]) { + let services = await MainActor.run { + MainActorServicesProbe(locationAuthorizationStatus: testCase.status) + } + let runtime = MacNodeRuntime(makeMainActorServices: { services }) + + let response = await self.invoke( + runtime, "req-location", OpenClawLocationCommand.get.rawValue) + + #expect(response.ok == testCase.accepted) + } + } + } + @Test func `handle invoke screen record uses injected services`() async throws { let services = await MainActor.run { MainActorServicesProbe() } let runtime = MacNodeRuntime(makeMainActorServices: { services }) From 0295b7ab54f2767019b4d0a8188313920e81ed3d Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 12 Aug 2026 02:14:47 -0300 Subject: [PATCH 026/165] fix(ui): gate model shortcuts by search focus (#122316) * improve(ui): unify model picker shortcut hints * fix(ui): gate model shortcuts by search focus * fix(ui): focus model trigger on open --- .../chat-composer-accessory-focus.e2e.test.ts | 48 +++++++-------- .../chat-flow.models-reasoning.e2e.test.ts | 3 +- ...-session-page.workspace-memory.e2e.test.ts | 59 +++++++++++++++++++ .../chat/components/chat-model-picker.ts | 23 +++++--- ui/src/styles/chat/layout.css | 21 +++---- ui/src/styles/layout.css | 3 +- 6 files changed, 114 insertions(+), 43 deletions(-) diff --git a/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts index eae397457712..884466d19e90 100644 --- a/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts +++ b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts @@ -9,7 +9,7 @@ const suite = createControlUiE2eSuite({ }); suite.define(() => { - it("keeps focus in place when pointer-opening passive composer popovers", async () => { + it("routes focus by composer accessory purpose", async () => { await suite.withPage({ viewport: { width: 1440, height: 900 } }, async ({ page }) => { const gateway = await installMockGateway(page, { models: [{ id: "gpt-5.6", name: "GPT-5.6", provider: "openai" }], @@ -114,28 +114,30 @@ suite.define(() => { await trigger.press("Enter"); } - for (const popover of [ - { - focus: ".chat-controls__model-search", - trigger: ".chat-controls__model-picker > summary", - }, - { - focus: ".agent-chat__attach-menu-option", - trigger: ".agent-chat__input-btn--attach", - }, - ]) { - await outside.focus(); - await composer.locator(popover.trigger).click(); - await expect - .poll(() => - page - .locator(popover.focus) - .first() - .evaluate((element) => document.activeElement === element), - ) - .toBe(true); - await page.keyboard.press("Escape"); - } + const modelTrigger = composer.locator(".chat-controls__model-picker > summary"); + await outside.focus(); + await modelTrigger.click(); + expect(await modelTrigger.evaluate((element) => document.activeElement === element)).toBe( + true, + ); + expect( + await page + .locator(".chat-controls__model-search") + .evaluate((element) => document.activeElement === element), + ).toBe(false); + await page.keyboard.press("Escape"); + + await outside.focus(); + await composer.locator(".agent-chat__input-btn--attach").click(); + await expect + .poll(() => + page + .locator(".agent-chat__attach-menu-option") + .first() + .evaluate((element) => document.activeElement === element), + ) + .toBe(true); + await page.keyboard.press("Escape"); }); }); }); diff --git a/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts index 119e0d17d61c..b3db40d0e36a 100644 --- a/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts +++ b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts @@ -530,7 +530,8 @@ suite.define(() => { const search = main.locator('[data-chat-model-search="true"]'); await expect .poll(() => search.evaluate((element) => element === document.activeElement)) - .toBe(true); + .toBe(false); + await search.focus(); await search.fill("anthropic"); const anthropicModel = main.locator('[data-chat-model-option="anthropic/claude-fable-5"]'); await expect.poll(() => anthropicModel.isVisible()).toBe(true); diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 6334e135ff35..20a59f391b28 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -235,6 +235,65 @@ suite.define(() => { }); }); + it("separates model shortcuts from numeric search input by focus", async () => { + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + await installMockGateway(page, { models: MODELS }); + await page.goto(`${suite.server.baseUrl}new`); + + const modelSelect = page.locator('[data-chat-model-select="true"]'); + const picker = page.locator(".chat-controls__model-picker"); + const search = page.locator('[data-chat-model-search="true"]'); + const firstModel = page.locator('[data-chat-model-option="openai/gpt-5.5"]'); + const secondModel = page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]'); + + await modelSelect.click(); + await expect.poll(() => picker.getAttribute("open")).toBe(""); + await expect + .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) + .toBe(true); + const secondShortcut = secondModel.locator('[data-chat-model-shortcut-number="2"]'); + await expect.poll(() => secondShortcut.count()).toBe(1); + const menuBoxBeforeFocus = await page.locator(".chat-controls__model-menu").boundingBox(); + const actionBoxBeforeFocus = await secondModel + .locator(".chat-controls__model-option-action") + .boundingBox(); + expect(menuBoxBeforeFocus).not.toBeNull(); + expect(actionBoxBeforeFocus).not.toBeNull(); + await expect + .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("1"); + + await search.focus(); + await expect + .poll(() => search.evaluate((element) => element === document.activeElement)) + .toBe(true); + await expect + .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("0"); + expect(await page.locator(".chat-controls__model-menu").boundingBox()).toEqual( + menuBoxBeforeFocus, + ); + expect( + await secondModel.locator(".chat-controls__model-option-action").boundingBox(), + ).toEqual(actionBoxBeforeFocus); + await search.press("1"); + await expect.poll(() => search.inputValue()).toBe("1"); + await expect.poll(() => picker.getAttribute("open")).toBe(""); + + await search.fill("anthropic"); + await expect.poll(() => firstModel.isVisible()).toBe(false); + await expect.poll(() => secondModel.isVisible()).toBe(true); + await modelSelect.focus(); + const filteredShortcut = secondModel.locator('[data-chat-model-shortcut-number="1"]'); + await expect + .poll(() => filteredShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("1"); + await page.keyboard.press("1"); + await expect.poll(() => picker.getAttribute("open")).toBe(null); + await expect.poll(() => modelSelect.textContent()).toContain("Claude Sonnet 4.6"); + }); + }); + it("keeps the effort label, slider stop, and create payload aligned after a model switch", async () => { await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { const levels = (ids: string[]) => ids.map((id) => ({ id, label: id })); diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index 640cc5eb7bd4..a893f59ea6c2 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -91,8 +91,8 @@ function pickerMenu(target: EventTarget | null): HTMLElement | null { : null; } -function visibleModelRows(menu: HTMLElement): HTMLButtonElement[] { - return [...menu.querySelectorAll("[data-chat-model-option]")] +function visibleModelRows(root: HTMLElement): HTMLButtonElement[] { + return [...root.querySelectorAll("[data-chat-model-option]")] .filter((row) => !row.hidden) .toSorted( (left, right) => @@ -234,11 +234,6 @@ function handleModelSearchKeydown(event: KeyboardEvent): void { if (rows.length === 0) { return; } - if (/^[1-9]$/u.test(event.key) && input.value === "") { - event.preventDefault(); - rows[Number(event.key) - 1]?.click(); - return; - } if (event.key === "Enter") { const highlighted = rows.find((row) => row.hasAttribute("data-chat-model-highlighted")); if (highlighted) { @@ -258,6 +253,16 @@ function handleModelSearchKeydown(event: KeyboardEvent): void { rows[nextIndex]?.scrollIntoView?.({ block: "nearest" }); } +function handleModelPickerKeydown(event: KeyboardEvent): void { + const details = event.currentTarget as HTMLDetailsElement; + if (!details.open || event.target instanceof HTMLInputElement || !/^[1-9]$/u.test(event.key)) { + return; + } + const row = visibleModelRows(details)[Number(event.key) - 1]; + event.preventDefault(); + row?.click(); +} + function renderCatalogState(state: ChatModelCatalogState | undefined, hasOptions: boolean) { if (!state || (state.status === "ready" && hasOptions)) { return nothing; @@ -382,6 +387,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { return html`
{ const details = event.currentTarget as HTMLDetailsElement; if (!details.open) { @@ -392,7 +398,6 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { const input = details.querySelector("[data-chat-model-search]"); if (input) { updateModelSearch(input); - input.focus({ preventScroll: true }); } }); }} @@ -411,7 +416,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { @click=${(event: MouseEvent) => { if (params.disabled) { event.preventDefault(); + return; } + (event.currentTarget as HTMLElement).focus({ preventScroll: true }); }} > ${modelToolsUnavailable diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 0f59e3c6d447..c5a970cd7c9c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -4587,16 +4587,17 @@ button.chat-reply-preview--message:disabled { margin-left: auto; } -.chat-controls__model-option-action kbd { - min-width: 18px; - padding: 1px 4px; - border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - border-radius: 4px; - color: var(--muted); - font-family: inherit; - font-size: 10px; - line-height: 1.35; - text-align: center; +@media (prefers-reduced-motion: no-preference) { + .chat-controls__model-option-action kbd { + transition: opacity var(--duration-fast) var(--ease-out); + } +} + +.chat-controls__model-search-wrap:focus-within + ~ .chat-controls__model-options + .chat-controls__model-option-action + kbd { + opacity: 0; } .chat-controls__model-option-action kbd::before { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 2b22bf145cec..a93668fe5403 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2789,7 +2789,8 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { /* Keycap hint, not content: the app's quiet mono shortcut idiom (see .chat-question-panel__option kbd), fixed-width so letters and digits share one rail beside the labels. */ -.session-menu__shortcut { +.session-menu__shortcut, +.chat-controls__model-option-action kbd { flex: 0 0 auto; min-width: 12px; color: var(--muted); From dd57cfb6c150b8201680b8e8930de0403d71856c Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 12 Aug 2026 02:18:05 -0300 Subject: [PATCH 027/165] fix(ui): show catalog labels in hidden section settings (#122320) * test(ui): reproduce hidden catalog id labels * fix(ui): label hidden session catalogs * test(ui): capture hidden catalog label evidence * test(ui): harden hidden catalog label proof --- ui/src/e2e/sidebar-customization.e2e.test.ts | 79 +++++++++++++++++++ ui/src/pages/config/config-page.ts | 46 ++++++++++- .../config/view-appearance-preferences.ts | 2 +- ui/src/pages/config/view-types.ts | 1 + ui/src/pages/config/view.browser.test.ts | 21 +++-- 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index 3c2a2d2e6a68..62e2845bb9c4 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -20,6 +20,7 @@ const suite = createControlUiE2eSuite({ }); const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const hiddenSessionCatalogsStorageKey = "openclaw:sidebar:sessions:hidden-catalogs"; const uiProofArtifactDir = path.join( process.cwd(), ".artifacts", @@ -93,6 +94,19 @@ async function holdUiProof(page: Page, durationMs = 600) { } } +async function setThemeMode(page: Page, mode: "dark" | "light") { + await page.emulateMedia({ colorScheme: mode }); + await page.evaluate((nextMode) => { + const root = document.documentElement; + root.dataset.themeMode = nextMode; + root.dataset.themeResolved = nextMode; + root.classList.toggle("wa-light", nextMode === "light"); + root.classList.toggle("wa-dark", nextMode === "dark"); + root.style.colorScheme = nextMode; + }, mode); + await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe(mode); +} + async function openSidebarTestPage() { const context = await suite.browser.newContext({ locale: "en-US", @@ -107,6 +121,71 @@ async function openSidebarTestPage() { } suite.define(() => { + it("uses catalog labels in the hidden-section recovery rows", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + await page.addInitScript(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), { + key: hiddenSessionCatalogsStorageKey, + value: ["claude", "offline-catalog"], + }); + const gateway = await installMockGateway(page, { + featureMethods: ["sessions.catalog.list"], + methodResponses: { + "sessions.catalog.list": { + catalogs: [ + { + id: "claude", + label: "Claude Code", + capabilities: { continueSession: true, archive: false }, + hosts: [], + }, + ], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}settings/appearance`); + await waitForControlUiSettingsTakeover(page); + await gateway.waitForRequest("sessions.catalog.list"); + const sidebarSettings = page.locator("#settings-appearance-sidebar"); + await sidebarSettings.getByRole("heading", { name: "Hidden session sections" }).waitFor(); + const recovery = sidebarSettings.locator(".settings-group", { hasText: "offline-catalog" }); + const row = recovery.locator(".settings-row", { hasText: "Claude Code" }); + await expect.poll(() => recovery.textContent()).toContain("Claude Code"); + await expect.poll(() => recovery.textContent()).toContain("offline-catalog"); + expect(await recovery.getByText("claude", { exact: true }).count()).toBe(0); + + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await recovery.scrollIntoViewIfNeeded(); + for (const theme of ["light", "dark"] as const) { + await setThemeMode(page, theme); + await page.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, `after-${theme}-context.png`), + }); + await recovery.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, `after-${theme}-rows.png`), + }); + } + } + + await row.getByRole("button", { name: "Show" }).click(); + await expect.poll(() => row.count()).toBe(0); + expect( + await page.evaluate((key) => localStorage.getItem(key), hiddenSessionCatalogsStorageKey), + ).toBe('["offline-catalog"]'); + } finally { + await context.close(); + } + }); + it("pins routes, restores defaults, and persists navigation state across reloads", async () => { if (captureUiProofEnabled) { await mkdir(uiProofArtifactDir, { recursive: true }); diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index dc4e8eb2f3da..e6c0cf7438bc 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -4,7 +4,10 @@ import { initialState, Task, TaskStatus } from "@lit/task"; import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; -import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js"; +import type { + SessionsCatalogListResult, + SystemInfoResult, +} from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelCatalogEntry } from "../../api/types.ts"; import { titleForRoute } from "../../app-navigation.ts"; @@ -114,6 +117,7 @@ const MOVED_SECTION_ROUTES: Record = new Map(); function defaultConfigSelection(pageId: ConfigPageId): ConfigSelection { switch (pageId) { @@ -338,6 +342,42 @@ export class ConfigPage extends OpenClawLightDomElement { } }, }); + private readonly hiddenSessionCatalogLabelsTask = new Task(this, { + args: () => { + const gateway = this.context?.gateway.snapshot; + const hiddenCatalogIds = [...this.hiddenSessionCatalogIds].toSorted(); + const client = + this.pageId === "appearance" && + hiddenCatalogIds.length > 0 && + canCallGatewayMethod(gateway, "sessions.catalog.list", "operator.read") + ? gateway?.client + : null; + return [ + client, + this.context?.agentSelection.state.selectedId ?? null, + hiddenCatalogIds.join("\0"), + ] as const; + }, + task: async ([client, agentId], { signal }) => { + if (!client) { + return EMPTY_SESSION_CATALOG_LABELS; + } + try { + const result = await client.request( + "sessions.catalog.list", + { + ...(agentId ? { agentId } : {}), + limitPerHost: 1, + }, + { signal }, + ); + return new Map(result.catalogs.map((catalog) => [catalog.id, catalog.label])); + } catch { + // Recovery must remain available when catalog discovery is unsupported or offline. + return EMPTY_SESSION_CATALOG_LABELS; + } + }, + }); private pendingRouteTargetId: string | null = null; private readonly subscriptions = new SubscriptionsController(this) .watch( @@ -1170,6 +1210,10 @@ export class ConfigPage extends OpenClawLightDomElement { this.settings.sidebarLiveActivity ?? UI_APPEARANCE_DEFAULTS.sidebarLiveActivity, setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled), hiddenSessionCatalogIds: this.hiddenSessionCatalogIds, + hiddenSessionCatalogLabels: + this.hiddenSessionCatalogLabelsTask.status === TaskStatus.COMPLETE + ? (this.hiddenSessionCatalogLabelsTask.value ?? EMPTY_SESSION_CATALOG_LABELS) + : EMPTY_SESSION_CATALOG_LABELS, setSessionCatalogHidden: setStoredSessionCatalogHidden, chatMessageMaxWidth: this.settings.chatMessageMaxWidth, setChatMessageMaxWidth: (value) => this.setSetting("chatMessageMaxWidth", value), diff --git a/ui/src/pages/config/view-appearance-preferences.ts b/ui/src/pages/config/view-appearance-preferences.ts index 7100e66e4842..ab931478f78e 100644 --- a/ui/src/pages/config/view-appearance-preferences.ts +++ b/ui/src/pages/config/view-appearance-preferences.ts @@ -486,7 +486,7 @@ export function renderSidebarPreferencesSection(props: ConfigProps) {
${hiddenCatalogIds.map((catalogId) => renderSettingsRow({ - title: catalogId, + title: props.hiddenSessionCatalogLabels.get(catalogId) ?? catalogId, description: t("quickSettings.personal.browserOnly"), control: html` + ` + : nothing; const desktopPanelAction = desktopPanelAvailable ? html` - - ` - : nothing; - const browserButton = sessionWorkspace.onToggleBrowser - ? html` - - - - ` - : nothing; - const custodianButton = sessionWorkspace.onToggleCustodian - ? html` - - - - ` - : nothing; - const diffButton = sessionWorkspace.onOpenDiff - ? html` - - - - ` - : nothing; const files = sessionWorkspace.list?.files ?? []; const modifiedFiles = files.filter((file) => file.kind === "modified"); const readFiles = files.filter((file) => file.kind === "read"); @@ -1306,7 +1244,6 @@ export function renderSessionWorkspaceRail( ${t("chat.workspaceFiles.files")}
- ${diffButton} ${terminalButton} ${browserButton} ${custodianButton} ${sessionWorkspace.narrowLayout ? nothing : html` diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 6370835c8ce2..92ad14b082e9 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -454,7 +454,6 @@ openclaw-chat-sidebar-region, gap: 6px; } -.chat-workspace-rail__terminal, .chat-workspace-rail__refresh, .chat-workspace-rail__dock, .chat-workspace-rail__collapse-toggle { @@ -476,24 +475,6 @@ openclaw-chat-sidebar-region, cursor: grabbing; } -.chat-workspace-rail__terminal { - display: inline-flex; - align-items: center; - justify-content: center; - /* Chromeless at rest like the sibling ghost buttons; chrome on hover only. */ - border: 1px solid transparent; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); -} - -.chat-workspace-rail__terminal:hover, -.chat-workspace-rail__terminal:focus-visible { - color: var(--text); - border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, transparent); -} - /* Overrides the shared .nav-collapse-toggle chrome (border, elevated background, inset highlight): rail header buttons only show chrome on hover. */ @@ -510,7 +491,6 @@ openclaw-chat-sidebar-region, transform: none; } -.chat-workspace-rail__terminal svg, .chat-workspace-rail__refresh svg, .chat-workspace-rail__dock svg, .chat-workspace-rail__collapse-toggle svg, From 83bca6844025c545a93cdf42c569f64fc43665d3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 12:44:46 +0530 Subject: [PATCH 054/165] fix: remove new-chat startup stalls (#122471) * fix(gateway): defer dashboard title generation * perf(agents): reuse prepared plugin metadata for model catalogs * perf(agents): keep live model inventory off turn startup * perf: keep isolated completions on static catalogs * fix: preserve fast readable worktree names * refactor: remove unnecessary title startup plumbing --- src/agents/agent-model-discovery.test.ts | 4 +- .../embedded-agent-runner/run-orchestrator.ts | 6 +- .../usage-reporting.test-support.ts | 2 +- src/agents/isolated-completion.test.ts | 3 + src/agents/isolated-completion.ts | 35 +++-- src/gateway/dashboard-session-title.test.ts | 112 +++---------- src/gateway/dashboard-session-title.ts | 4 +- .../chat-send-agent-dispatch.ts | 22 ++- .../server-methods/chat-send-handler.ts | 14 -- src/gateway/server-methods/sessions-create.ts | 54 +------ src/gateway/server.sessions.create.test.ts | 147 ++++++++++++++---- 11 files changed, 194 insertions(+), 209 deletions(-) diff --git a/src/agents/agent-model-discovery.test.ts b/src/agents/agent-model-discovery.test.ts index 7afdce937dae..797c32aad279 100644 --- a/src/agents/agent-model-discovery.test.ts +++ b/src/agents/agent-model-discovery.test.ts @@ -20,9 +20,7 @@ beforeEach(() => { clearCurrentPluginMetadataSnapshot(); }); -afterEach(() => { - vi.unstubAllEnvs(); -}); +afterEach(() => vi.unstubAllEnvs()); function writeModelsJson(agentDir: string, modelId: string): void { fs.writeFileSync( diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 9df2d0e6d305..98519a1e6ddb 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -268,9 +268,9 @@ async function runEmbeddedAgentInternal( ? acquireReadOnlyPreparedModelRuntime(preparedInput) : acquireAgentRunPreparedModelRuntime(preparedInput, { retainIdleRunOwner, - // A one-shot turn needs only configured turn-admission facts. Full live model - // inventory remains available through the snapshot's lazy control-plane loader. - ...(params.oneShotCliRun ? { catalogMode: "static" } : {}), + // Turns need only configured admission facts. Full live model inventory remains + // available through the snapshot's lazy control-plane loader. + catalogMode: "static", }), ); startupStages.mark("prepared-runtime"); diff --git a/src/agents/embedded-agent-runner/usage-reporting.test-support.ts b/src/agents/embedded-agent-runner/usage-reporting.test-support.ts index bc52b51e5472..5056a737e730 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test-support.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test-support.ts @@ -87,7 +87,7 @@ describe("runEmbeddedAgent usage reporting", () => { expect.objectContaining({ provider: "openai", modelId: "gpt-5.5" }), ]), }), - expect.anything(), + expect.objectContaining({ catalogMode: "static" }), ); }); diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index ffe5a42813b6..f1b385481710 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -196,6 +196,9 @@ describe("runIsolatedCompletion", () => { text: "native result", owner: { kind: "harness", id: "codex" }, }); + expect(mocks.acquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith(expect.any(Object), { + catalogMode: "static", + }); expect(mocks.prepareSimpleCompletionModel).not.toHaveBeenCalled(); expect(runIsolatedCompletionV2).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 0fbf2f3be461..1fff7c9aac06 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -436,22 +436,25 @@ export async function runIsolatedCompletion( config, includeSetupRegistry: true, }) ?? request.provider; - const lease = await acquireAgentRunPreparedModelRuntime({ - config, - agentId, - agentDir, - workspaceDir, - runtimePluginSelections: [ - { - provider, - modelId: request.model, - ...(request.agentHarnessRuntimeOverride - ? { runtime: request.agentHarnessRuntimeOverride } - : {}), - agentId, - }, - ], - }); + const lease = await acquireAgentRunPreparedModelRuntime( + { + config, + agentId, + agentDir, + workspaceDir, + runtimePluginSelections: [ + { + provider, + modelId: request.model, + ...(request.agentHarnessRuntimeOverride + ? { runtime: request.agentHarnessRuntimeOverride } + : {}), + agentId, + }, + ], + }, + { catalogMode: "static" }, + ); const pluginRegistry = lease.snapshot.pluginRegistry; try { const run = async (): Promise => { diff --git a/src/gateway/dashboard-session-title.test.ts b/src/gateway/dashboard-session-title.test.ts index 270e2accfd4a..6be4c1f32314 100644 --- a/src/gateway/dashboard-session-title.test.ts +++ b/src/gateway/dashboard-session-title.test.ts @@ -17,7 +17,7 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ChatAttachment } from "./chat-attachments.js"; import { - generateDashboardSessionTitle, + buildDashboardSessionTitleSource, maybeGenerateDashboardSessionTitle, } from "./dashboard-session-title.js"; @@ -334,51 +334,26 @@ describe("maybeGenerateDashboardSessionTitle", () => { }); }); -describe("generateDashboardSessionTitle", () => { - beforeEach(() => { - generateConversationLabelWithFallback.mockReset(); - resolveUtilityModelRefForAgent.mockReset(); - generateConversationLabelWithFallback.mockResolvedValue("Worktree Naming Improvements"); - resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna"); - }); - - it("generates the reusable short dashboard title", async () => { - await expect( - generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage: "Please improve the default names for managed worktrees", - }), - ).resolves.toBe("Worktree Naming Improvements"); - }); - +describe("buildDashboardSessionTitleSource", () => { it("combines an ordinary command with large pasted text within the title-source cap", async () => { const pastedText = `Release details ${"x".repeat(2_000)}`; - - await generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage: "Review this rollout [[reply_to_current]]", + const source = buildDashboardSessionTitleSource({ + message: "Review this rollout [[reply_to_current]]", attachments: [textAttachment("Deployment context"), textAttachment(pastedText)], }); - - expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( - `Review this rollout\nDeployment context\n${pastedText}`.slice(0, 1_000), - ); + expect(source).toBe(`Review this rollout\nDeployment context\n${pastedText}`.slice(0, 1_000)); }); it.each([ ["attachment-only", "", "Pasted migration checklist"], ["slash command with attachment", "/status", "Pasted incident report"], ])("titles an %s turn from its text attachment", async (_name, userMessage, text) => { - await generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage, - attachments: [textAttachment(text)], - }); - - expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe(text); + expect( + buildDashboardSessionTitleSource({ + message: userMessage, + attachments: [textAttachment(text)], + }), + ).toBe(text); }); it.each([ @@ -390,72 +365,29 @@ describe("generateDashboardSessionTitle", () => { ["non-text", { mimeType: "image/png", content: Buffer.from("not text").toString("base64") }], ] satisfies Array<[string, ChatAttachment]>)( "ignores %s attachments", - async (_name, attachment) => { - await expect( - generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage: "", - attachments: [attachment], - }), - ).resolves.toBeNull(); - expect(generateConversationLabelWithFallback).not.toHaveBeenCalled(); - }, + async (_name, attachment) => + expect(buildDashboardSessionTitleSource({ message: "", attachments: [attachment] })).toBe(""), ); it("ignores a long text attachment with malformed trailing base64", async () => { const valid = Buffer.from("a".repeat(4_000)).toString("base64"); const malformed = `${valid.slice(0, -4)}AAA%`; - await expect( - generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage: "", + expect( + buildDashboardSessionTitleSource({ + message: "", attachments: [{ mimeType: "text/plain", content: malformed }], }), - ).resolves.toBeNull(); - expect(generateConversationLabelWithFallback).not.toHaveBeenCalled(); + ).toBe(""); }); it("keeps attachment-derived title input on a UTF-16 boundary", async () => { - await generateDashboardSessionTitle({ - cfg, - agentId: "main", - userMessage: "", - attachments: [textAttachment(`${"a".repeat(999)}🚀tail`)], - }); - - expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe( - "a".repeat(999), - ); - }); - - it("uses a requested session model as the primary fallback", async () => { - await generateDashboardSessionTitle({ - cfg, - agentId: "main", - entry: { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-5", - authProfileOverride: "work", - }, - userMessage: "Please improve the default names for managed worktrees", - }); - - expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( - expect.objectContaining({ - regularModelRef: "anthropic/claude-opus-4-5@work", - preferredProfile: "work", + expect( + buildDashboardSessionTitleSource({ + message: "", + attachments: [textAttachment(`${"a".repeat(999)}🚀tail`)], }), - ); - }); - - it.each(["", " ", "/status"])("skips non-title prompt %j", async (userMessage) => { - await expect( - generateDashboardSessionTitle({ cfg, agentId: "main", userMessage }), - ).resolves.toBeNull(); - expect(generateConversationLabelWithFallback).not.toHaveBeenCalled(); + ).toBe("a".repeat(999)); }); }); diff --git a/src/gateway/dashboard-session-title.ts b/src/gateway/dashboard-session-title.ts index 4311d2fe0a48..04721edb8ec1 100644 --- a/src/gateway/dashboard-session-title.ts +++ b/src/gateway/dashboard-session-title.ts @@ -1,5 +1,4 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -// Dashboard session titles use the shared utility-model completion path. import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; @@ -155,8 +154,7 @@ function normalizeDashboardSessionTitle(raw: string): string | null { return normalized ? truncateUtf16Safe(normalized, DASHBOARD_SESSION_TITLE_MAX_CHARS) : null; } -/** Generates the same short title used by dashboard session rows without persisting it. */ -export async function generateDashboardSessionTitle(params: { +async function generateDashboardSessionTitle(params: { cfg: OpenClawConfig; agentId: string; entry?: DashboardSessionTitleModelEntry; diff --git a/src/gateway/server-methods/chat-send-agent-dispatch.ts b/src/gateway/server-methods/chat-send-agent-dispatch.ts index a29d90d7ca5b..d43a750dcff0 100644 --- a/src/gateway/server-methods/chat-send-agent-dispatch.ts +++ b/src/gateway/server-methods/chat-send-agent-dispatch.ts @@ -18,7 +18,10 @@ import type { ChatRunTiming } from "../server-chat-state.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; import type { AdmittedChatSend } from "./chat-send-admission.js"; import type { prepareChatSendAttachments } from "./chat-send-attachments.js"; -import { resolveWebchatPromptCacheKey } from "./chat-send-background.js"; +import { + resolveWebchatPromptCacheKey, + scheduleChatDashboardSessionTitle, +} from "./chat-send-background.js"; import { createChatSendDispatchErrorLifecycle } from "./chat-send-dispatch-errors.js"; import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js"; import { finalizeAcceptedChatSendMessageInjection } from "./chat-send-message-injection.js"; @@ -503,5 +506,20 @@ export function startChatDispatch(params: StartChatDispatchParams): void { } }) .catch(dispatchErrorLifecycle.handleError) - .finally(dispatchErrorLifecycle.finalize); + .finally(() => { + dispatchErrorLifecycle.finalize(); + // Cosmetic title work starts only after the accepted turn finishes. Starting it + // before dispatch can make a cold utility runtime starve the user's real turn. + scheduleChatDashboardSessionTitle({ + admittedSessionId, + agentId, + cfg, + context, + entry, + request, + sessionKey, + sessionLoadOptions: session.sessionLoadOptions, + storePath: session.storePath, + }); + }); } diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index cfc02b237f06..06de56863696 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -7,7 +7,6 @@ import type { ChatRunTiming } from "../server-chat-state.js"; import { terminalizeRestartSafeChatAdmission } from "./chat-restart-recovery.js"; import { startChatDispatch } from "./chat-send-agent-dispatch.js"; import { prepareChatSendAttachments } from "./chat-send-attachments.js"; -import { scheduleChatDashboardSessionTitle } from "./chat-send-background.js"; import { handleChatSendSetupError } from "./chat-send-dispatch-errors.js"; import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js"; import { @@ -42,7 +41,6 @@ export async function handleChatSend( normalizedRequest.value; const { clientRunId, - sessionLoadOptions, sessionLoadMs, cfg, storePath, @@ -50,7 +48,6 @@ export async function handleChatSend( sessionKey, sessionRoutingChanged, selectedAgent, - agentId, } = preparedSession.value; const { activeRunAbort, @@ -250,17 +247,6 @@ export async function handleChatSend( ); respond(true, ackPayload, undefined, { runId: clientRunId }); const chatSendAckedAtMs = chatSendTiming?.ackedAtMs ?? performance.now(); - scheduleChatDashboardSessionTitle({ - admittedSessionId, - agentId, - cfg, - context, - entry, - request: normalizedRequest.value, - sessionKey, - sessionLoadOptions, - storePath, - }); startChatDispatch({ admissionStartedAt, admission: admitted.value, diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index e5167ce1196b..3faf2a445601 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -12,14 +12,12 @@ import { validateSessionsCreateParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { slugifyWorktreeTitle } from "../../agents/worktrees/name.js"; import { managedWorktrees, WorktreeRepositoryError } from "../../agents/worktrees/service.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { @@ -29,7 +27,7 @@ import { } from "../../projects/project-registry.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; -import { generateDashboardSessionTitle } from "../dashboard-session-title.js"; +import { buildDashboardSessionTitleSource } from "../dashboard-session-title.js"; import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; import { buildDashboardSessionKey, createGatewaySession } from "../session-create-service.js"; import type { PrepareGatewaySessionLifecycle } from "../session-lifecycle-preparation.js"; @@ -40,7 +38,6 @@ import { loadGatewaySessionEntryReadOnly, resolveGatewaySessionStoreTarget, } from "../session-utils.js"; -import { resolveSessionPatchModelSelection } from "../sessions-patch.js"; import { createAgentRuntimeAuthorityGuard } from "./agent-runtime-authority.js"; import { chatHandlers } from "./chat.js"; import { resolveRegisteredCatalogCreateTarget } from "./session-catalog.js"; @@ -231,7 +228,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { const sessionExecCwd = requestedExecNode ? requestedCwd : undefined; let sessionCwd = requestedExecNode ? undefined : (projectRoot ?? requestedCwd); let prepareLifecycle: PrepareGatewaySessionLifecycle | undefined; - let generatedDisplayName: string | undefined; if (sessionCwd && !requestedExecNode && (requestedProjectId || p.worktree !== true)) { const targetAgentId = normalizeAgentId( sessionAgentId ?? @@ -338,47 +334,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { return; } - if ( - !requestedWorktreeName && - !normalizeOptionalString(p.label) && - (initialMessage || initialAttachments) - ) { - try { - const requestedTitleModel = - catalogTarget?.target.model ?? normalizeOptionalString(p.model); - let titleModelEntry: - | Pick - | undefined; - if (requestedTitleModel) { - const defaultModel = resolveDefaultModelForAgent({ cfg, agentId: target.agentId }); - const selection = resolveSessionPatchModelSelection({ - cfg, - catalog: await context.loadGatewayModelCatalog({ agentId: target.agentId }), - raw: requestedTitleModel, - defaultProvider: defaultModel.provider, - defaultModel: defaultModel.model, - }); - if (selection.ok) { - titleModelEntry = { - providerOverride: selection.provider, - modelOverride: selection.model, - ...(selection.profile ? { authProfileOverride: selection.profile } : {}), - }; - } - } - generatedDisplayName = - (await generateDashboardSessionTitle({ - cfg, - agentId: target.agentId, - entry: titleModelEntry, - userMessage: initialMessage ?? "", - attachments: initialAttachments, - })) ?? undefined; - } catch (error) { - sessionLog.warn(`worktree title generation failed: ${formatErrorMessage(error)}`); - } - } - const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; prepareLifecycle = async (lifecycleTarget) => { try { @@ -430,7 +385,11 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ownerId: lifecycleTarget.key, name: requestedWorktreeName, suggestedName: slugifyWorktreeTitle( - normalizeOptionalString(p.label) ?? generatedDisplayName ?? "", + normalizeOptionalString(p.label) ?? + buildDashboardSessionTitleSource({ + message: initialMessage ?? "", + attachments: initialAttachments, + }), ), baseRef: requestedWorktreeBaseRef, // Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them @@ -525,7 +484,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { key: sessionKey, agentId: sessionAgentId, label: p.label, - generatedDisplayName, ...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }), thinkingLevel: p.thinkingLevel, projectId: requestedProjectId, diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 15dffa6a28fa..399ed2d1d44c 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -62,8 +62,10 @@ import { type EnsureSessionDiffBaseline = (typeof import("../sessions/session-diff-baseline.js"))["ensureSessionDiffBaseline"]; -type GenerateDashboardSessionTitle = - (typeof import("./dashboard-session-title.js"))["generateDashboardSessionTitle"]; +type GenerateConversationLabelWithFallback = + (typeof import("../auto-reply/reply/conversation-label-generator.js"))["generateConversationLabelWithFallback"]; +type ScheduleChatDashboardSessionTitle = + (typeof import("./server-methods/chat-send-background.js"))["scheduleChatDashboardSessionTitle"]; type ReadSessionMessageCountAsync = (typeof import("./session-transcript-readers.js"))["readSessionMessageCountAsync"]; @@ -72,9 +74,14 @@ const sessionDiffBaselineMocks = vi.hoisted(() => ({ useReal: false, })); -const dashboardTitleMocks = vi.hoisted(() => ({ - actual: undefined as GenerateDashboardSessionTitle | undefined, - generate: vi.fn(), +const dashboardTitleGenerationMocks = vi.hoisted(() => ({ + actual: undefined as GenerateConversationLabelWithFallback | undefined, + generate: vi.fn(), +})); + +const dashboardTitleScheduleMocks = vi.hoisted(() => ({ + actual: undefined as ScheduleChatDashboardSessionTitle | undefined, + schedule: vi.fn(), })); const sessionTranscriptReaderMocks = vi.hoisted(() => ({ @@ -92,11 +99,24 @@ vi.mock("../sessions/session-diff-baseline.js", async (importOriginal) => { return { ...actual, ensureSessionDiffBaseline: sessionDiffBaselineMocks.ensure }; }); -vi.mock("./dashboard-session-title.js", async (importOriginal) => { - const actual = await importOriginal(); - dashboardTitleMocks.actual = actual.generateDashboardSessionTitle; - dashboardTitleMocks.generate.mockImplementation(actual.generateDashboardSessionTitle); - return { ...actual, generateDashboardSessionTitle: dashboardTitleMocks.generate }; +vi.mock("../auto-reply/reply/conversation-label-generator.js", async (importOriginal) => { + const actual = + await importOriginal(); + dashboardTitleGenerationMocks.actual = actual.generateConversationLabelWithFallback; + dashboardTitleGenerationMocks.generate.mockImplementation( + actual.generateConversationLabelWithFallback, + ); + return { + ...actual, + generateConversationLabelWithFallback: dashboardTitleGenerationMocks.generate, + }; +}); + +vi.mock("./server-methods/chat-send-background.js", async (importOriginal) => { + const actual = await importOriginal(); + dashboardTitleScheduleMocks.actual = actual.scheduleChatDashboardSessionTitle; + dashboardTitleScheduleMocks.schedule.mockImplementation(actual.scheduleChatDashboardSessionTitle); + return { ...actual, scheduleChatDashboardSessionTitle: dashboardTitleScheduleMocks.schedule }; }); vi.mock("./session-transcript-readers.js", async (importOriginal) => { @@ -115,11 +135,16 @@ beforeEach(() => { sessionDiffBaselineMocks.ensure.mockClear(); // Baseline capture has dedicated owner coverage and one authenticated integration below. sessionDiffBaselineMocks.useReal = false; - dashboardTitleMocks.generate.mockReset(); - if (!dashboardTitleMocks.actual) { + dashboardTitleGenerationMocks.generate.mockReset(); + if (!dashboardTitleGenerationMocks.actual) { throw new Error("actual dashboard title generator was not loaded"); } - dashboardTitleMocks.generate.mockImplementation(dashboardTitleMocks.actual); + dashboardTitleGenerationMocks.generate.mockImplementation(dashboardTitleGenerationMocks.actual); + dashboardTitleScheduleMocks.schedule.mockReset(); + if (!dashboardTitleScheduleMocks.actual) { + throw new Error("actual dashboard title scheduler was not loaded"); + } + dashboardTitleScheduleMocks.schedule.mockImplementation(dashboardTitleScheduleMocks.actual); sessionTranscriptReaderMocks.readCount.mockReset(); if (!sessionTranscriptReaderMocks.actual) { throw new Error("actual session transcript reader was not loaded"); @@ -684,6 +709,53 @@ test("createGatewaySession persists a generated title only for a new session", a expect(reused).toMatchObject({ ok: true, entry: { displayName: "Readable Worktree Names" } }); }); +test("chat.send generates a dashboard title only after the user turn finishes", async () => { + await createSessionStoreDir(); + const { ws } = await openClient(); + let finishDispatch: (() => void) | undefined; + const dispatchFinished = new Promise((resolve) => { + finishDispatch = resolve; + }); + dispatchInboundMessageMock.mockImplementationOnce(async () => { + await dispatchFinished; + return { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }; + }); + try { + const created = await rpcReq<{ key: string }>(ws, "sessions.create", { + agentId: "main", + key: "agent:main:dashboard:title-order", + }); + expect(created.ok, JSON.stringify(created.error)).toBe(true); + const sessionKey = requireNonEmptyString(created.payload?.key, "created session key"); + + const sent = await rpcReq(ws, "chat.send", { + sessionKey, + message: "Help me plan the release", + idempotencyKey: "post-dispatch-dashboard-title", + }); + expect(sent.ok, JSON.stringify(sent.error)).toBe(true); + await waitForFast(() => expect(dispatchInboundMessageMock).toHaveBeenCalled()); + expect(dashboardTitleScheduleMocks.schedule).not.toHaveBeenCalled(); + + finishDispatch?.(); + await waitForFast(() => expect(dashboardTitleScheduleMocks.schedule).toHaveBeenCalled(), { + timeout: 5_000, + }); + expect(dashboardTitleScheduleMocks.schedule).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ rawMessage: "Help me plan the release" }), + sessionKey, + }), + ); + } finally { + finishDispatch?.(); + ws.close(); + } +}); + test("incognito operator RPCs treat identityless connections as owner-equivalent", async () => { const { dir } = await createSessionStoreDir(); const admin = await openClient({ @@ -1243,7 +1315,7 @@ test("sessions.create preserves a committed worktree when initial-turn setup fai } }); -test("sessions.create derives its managed-worktree title from message and pasted text", async () => { +test("sessions.create names its managed worktree without waiting for the model title", async () => { const openClawState = await createOpenClawTestState({ layout: "state-only", prefix: "openclaw-session-worktree-title-", @@ -1261,28 +1333,45 @@ test("sessions.create derives its managed-worktree title from message and pasted mimeType: "text/plain", content: Buffer.from(pastedText).toString("base64"), }; - dashboardTitleMocks.generate.mockResolvedValueOnce("Attachment Repair"); + let resolveTitle: ((title: string) => void) | undefined; + dashboardTitleGenerationMocks.generate.mockReturnValueOnce( + new Promise((resolve) => { + resolveTitle = resolve; + }), + ); + dispatchInboundMessageMock.mockResolvedValueOnce({ + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }); + const createResult = rpcReq<{ + key: string; + worktree: { id: string; branch: string }; + }>(ws, "sessions.create", { + agentId: "main", + worktree: true, + message, + attachments: [attachment], + }); + let createSettled = false; + void createResult.then(() => { + createSettled = true; + }); try { - const created = await rpcReq<{ - worktree: { id: string; branch: string }; - }>(ws, "sessions.create", { - agentId: "main", - worktree: true, - message, - attachments: [attachment], + await waitForFast(() => expect(createSettled).toBe(true), { + timeout: 1_000, }); + const created = await createResult; expect(created.ok, JSON.stringify(created.error)).toBe(true); worktreeId = created.payload?.worktree.id; - expect(created.payload?.worktree.branch).toBe("openclaw/attachment-repair"); - expect(dashboardTitleMocks.generate).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: "main", - userMessage: message, - attachments: [attachment], - }), + expect(created.payload?.worktree.branch).toBe( + "openclaw/review-this-rollout-pasted-deployment-plan-xxxxxxxxxxxxxxxxxxxxx", ); + resolveTitle?.("Attachment Repair"); } finally { + resolveTitle?.("Attachment Repair"); + const created = await createResult; + worktreeId ??= created.payload?.worktree.id; if (worktreeId) { await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true }); } From fc0147b529fb982ea8b50e3b7d657ca7aeafc508 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:19:22 -0700 Subject: [PATCH 055/165] test(plugins): remove duplicate setup cases (#122515) --- extensions/codex/harness.test.ts | 7 ----- extensions/whatsapp/src/setup-surface.test.ts | 31 ------------------- 2 files changed, 38 deletions(-) diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 14a67bf257fb..dcf46e643cfe 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -179,13 +179,6 @@ describe("Codex agent harness supports()", () => { }); }); - it("supports the canonical openai routing id (documented Codex path)", () => { - expect(harness.supports({ provider: "openai", requestedRuntime: "codex" })).toEqual({ - supported: true, - priority: 100, - }); - }); - it("supports an official route declared compatible with Codex", () => { expect( harness.supports({ diff --git a/extensions/whatsapp/src/setup-surface.test.ts b/extensions/whatsapp/src/setup-surface.test.ts index 44a136d66d81..c414f5ac09bd 100644 --- a/extensions/whatsapp/src/setup-surface.test.ts +++ b/extensions/whatsapp/src/setup-surface.test.ts @@ -9,19 +9,16 @@ import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/set import { beforeEach, describe, expect, it, vi } from "vitest"; import { whatsappSetupWizard } from "./setup-surface.js"; import { - createWhatsAppAllowlistModeInput, createWhatsAppLinkingHarness, createWhatsAppOwnerAllowlistHarness, createWhatsAppPersonalPhoneHarness, createWhatsAppRootAllowFromConfig, createWhatsAppWorkAccountConfig, expectNoWhatsAppLoginFollowup, - expectWhatsAppAllowlistModeSetup, expectWhatsAppLoginFollowup, expectWhatsAppOpenPolicySetup, expectWhatsAppOwnerAllowlistSetup, expectWhatsAppPersonalPhoneSetup, - expectWhatsAppSeparatePhoneDisabledSetup, expectWhatsAppWorkAccountAccessNote, expectWhatsAppWorkAccountOpenAccess, } from "./setup-test-helpers.js"; @@ -138,20 +135,6 @@ function expectFinalizeResult(result: Awaited { beforeEach(() => { hoisted.detectWhatsAppLinked.mockReset(); @@ -186,14 +169,6 @@ describe("whatsapp setup wizard", () => { expectWhatsAppOwnerAllowlistSetup(result.cfg, harness); }); - it("supports disabled DM policy for separate-phone setup", async () => { - const { harness, result } = await runSeparatePhoneFlow({ - selectValues: ["separate", "disabled"], - }); - - expectWhatsAppSeparatePhoneDisabledSetup(result.cfg, harness); - }); - it("writes named-account DM policy and allowFrom instead of the channel root", async () => { hoisted.pathExists.mockResolvedValue(true); const harness = createSeparatePhoneHarness({ @@ -310,12 +285,6 @@ describe("whatsapp setup wizard", () => { expectWhatsAppWorkAccountAccessNote(harness); }); - it("normalizes allowFrom entries when list mode is selected", async () => { - const { result } = await runSeparatePhoneFlow(createWhatsAppAllowlistModeInput()); - - expectWhatsAppAllowlistModeSetup(result.cfg); - }); - it("enables allowlist self-chat mode for personal-phone setup", async () => { hoisted.pathExists.mockResolvedValue(true); const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter); From 0de17482ae45866d73c30af4060849a03d6c0000 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:21:23 -0700 Subject: [PATCH 056/165] feat(gateway,ui): quiet Where picker, placement chip, and projects read model (#120804) * feat(gateway): projects.list groups known checkouts by repo identity Implements docs/plan/runners.md milestone 4 derived projects read model. * feat(ui): regroup the Where picker by gateway, devices, and cloud * feat(ui): placement chip shows where a session runs with reclaim Implements docs/plan/runners.md milestone 4 placement display and reclaim. --- .../openclaw/app/gateway/GatewayProtocol.kt | 31 + .../gateway/GatewayProtocolGeneratedTest.kt | 8 + .../OpenClawProtocol/GatewayModels.swift | 64 +- .../GatewayProtocolGeneratedModelsTests.swift | 10 + .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/plan/runners.md | 57 +- packages/gateway-protocol/src/index.ts | 1 + .../src/schema/projects.test.ts | 33 + .../gateway-protocol/src/schema/projects.ts | 48 +- .../protocol-schema-fragment-agent-control.ts | 2 + scripts/protocol-gen-kotlin.ts | 1 + src/agents/worktrees/service.test.ts | 15 + src/agents/worktrees/service.ts | 21 + .../server-methods.authorization.test.ts | 28 + .../server-methods/projects-observed.test.ts | 308 +++++++ src/gateway/server-methods/projects.test.ts | 83 +- src/gateway/server-methods/projects.ts | 692 ++++++++++----- ...w-session-page.operator-scopes.e2e.test.ts | 20 +- ...w-session-page.projects-places.e2e.test.ts | 816 ++++++++++++++++++ ...sion-page.workspace-validation.e2e.test.ts | 26 + ui/src/i18n/locales/en.ts | 2 + ui/src/pages/chat/chat-pane-base.ts | 1 + ui/src/pages/chat/chat-pane-context.ts | 25 + ui/src/pages/chat/chat-pane-header.ts | 8 + ui/src/pages/chat/chat-pane-placement.test.ts | 217 +++++ ui/src/pages/chat/chat-pane-placement.ts | 102 +++ .../pages/chat/chat-pane-task-suggestions.ts | 4 +- ui/src/pages/chat/chat-pane.test-support.ts | 2 + .../chat/components/chat-pane-header.test.ts | 52 +- .../pages/chat/components/chat-pane-header.ts | 17 +- .../chat/components/chat-pane-placement.ts | 42 + .../new-session/cloud-profile-discovery.ts | 10 +- ui/src/pages/new-session/cloud-target.ts | 13 +- ui/src/pages/new-session/discovery.test.ts | 30 +- ui/src/pages/new-session/discovery.ts | 37 +- .../pages/new-session/draft-gateway-state.ts | 31 +- ui/src/pages/new-session/new-session-page.ts | 1 + .../new-session/place-picker-sections.ts | 25 + ui/src/pages/new-session/place-picker.test.ts | 117 +++ ui/src/pages/new-session/place-picker.ts | 70 +- .../pages/new-session/recent-places.test.ts | 9 +- ui/src/styles/chat/split-view.css | 38 +- 58 files changed, 2841 insertions(+), 310 deletions(-) create mode 100644 src/gateway/server-methods/projects-observed.test.ts create mode 100644 ui/src/e2e/new-session-page.projects-places.e2e.test.ts create mode 100644 ui/src/pages/chat/chat-pane-placement.test.ts create mode 100644 ui/src/pages/chat/chat-pane-placement.ts create mode 100644 ui/src/pages/chat/components/chat-pane-placement.ts create mode 100644 ui/src/pages/new-session/place-picker-sections.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index e7e0b5e59eb5..0e838d5cf753 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -166,6 +166,13 @@ data class WorkerDesktopLaunchResult( val status: String = "ready", ) +@Serializable +data class ProjectsListResult( + val projects: List, + val recents: List? = null, + val observedProjects: List? = null, +) + @Serializable data class GatewayEventFrameStateVersion( val presence: Long, @@ -178,6 +185,30 @@ data class GatewayNodeInvokeResultParamsError( val message: String? = null, ) +@Serializable +data class ProjectsListResultProjectsItem( + val id: String, + val displayName: String, + val repoRoot: String? = null, + val originUrl: String? = null, + val source: String, + val agentId: String? = null, +) + +@Serializable +data class ProjectsListResultObservedProjectsItem( + val name: String, + val originUrl: String? = null, + val checkouts: List, + val lastUsedAt: Double, +) + +@Serializable +data class ProjectsListResultObservedProjectsItemCheckoutsItem( + val runnerId: String, + val path: String, +) + enum class GatewayMethod( val rawValue: String, ) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt index 84ebe30e2bd4..d30f4bd80ff8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt @@ -46,6 +46,14 @@ class GatewayProtocolGeneratedTest { assertEquals(5_000L, decoded.timeoutMs) } + @Test + fun projectsListResultDecodesALegacyProjectsOnlyPayload() { + val decoded = json.decodeFromString(ProjectsListResult.serializer(), """{"projects":[]}""") + + assertTrue(decoded.projects.isEmpty()) + assertNull(decoded.observedProjects) + } + @Test fun generatedGatewayCatalogsAreCompleteAndUnique() { val methods = GatewayMethod.entries.map { it.rawValue } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a452c6140789..4054619e80ad 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2183,6 +2183,50 @@ public struct WorkerDesktopLaunchResult: Codable, Sendable { } } +public struct ProjectCheckout: Codable, Sendable { + public let runnerid: String + public let path: String + + public init( + runnerid: String, + path: String) + { + self.runnerid = runnerid + self.path = path + } + + private enum CodingKeys: String, CodingKey { + case runnerid = "runnerId" + case path + } +} + +public struct ProjectSummary: Codable, Sendable { + public let name: String + public let originurl: String? + public let checkouts: [ProjectCheckout] + public let lastusedat: Double + + public init( + name: String, + originurl: String? = nil, + checkouts: [ProjectCheckout], + lastusedat: Double) + { + self.name = name + self.originurl = originurl + self.checkouts = checkouts + self.lastusedat = lastusedat + } + + private enum CodingKeys: String, CodingKey { + case name + case originurl = "originUrl" + case checkouts + case lastusedat = "lastUsedAt" + } +} + public struct SystemInfoParams: Codable, Sendable {} public struct SystemInfoResult: Codable, Sendable { @@ -3165,23 +3209,39 @@ public struct ProjectRecentProject: Codable, Sendable { } } -public struct ProjectsListParams: Codable, Sendable {} +public struct ProjectsListParams: Codable, Sendable { + public let includeobserved: Bool? + + public init( + includeobserved: Bool? = nil) + { + self.includeobserved = includeobserved + } + + private enum CodingKeys: String, CodingKey { + case includeobserved = "includeObserved" + } +} public struct ProjectsListResult: Codable, Sendable { public let projects: [ProjectsAddResult] public let recents: [ProjectRecent]? + public let observedprojects: [ProjectSummary]? public init( projects: [ProjectsAddResult], - recents: [ProjectRecent]? = nil) + recents: [ProjectRecent]? = nil, + observedprojects: [ProjectSummary]? = nil) { self.projects = projects self.recents = recents + self.observedprojects = observedprojects } private enum CodingKeys: String, CodingKey { case projects case recents + case observedprojects = "observedProjects" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift index 0b662155e8b6..dc05ff0035ec 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift @@ -68,4 +68,14 @@ struct GatewayProtocolGeneratedModelsTests { #expect(additive.scopes == ["operator.read"]) #expect(additive.locale == "en-US") } + + @Test + func `projects list result decodes a legacy projects-only payload`() throws { + let result = try JSONDecoder().decode( + ProjectsListResult.self, + from: Data(#"{"projects":[]}"#.utf8)) + + #expect(result.projects.isEmpty) + #expect(result.observedprojects == nil) + } } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 706088d0dc2d..71e7a14df4c3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"92f86077bf8bd7e0ade16592677b6e824e8ace048e40849aa5fd5cd9fe17f280","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"8c6ff70bbf195705355c6599c50819065f5f350f0faaddc4a868b5e86da65e28","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 911561a94fd2..24728a1bca39 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"82523c6b1d5b8bc1b0933455ef0d8f798fb938e2b0e2676a434522926fd1d1ed","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"91311c46d7fa124d5b79e0a8a6640f2ef8436c5000c1dafa3391f8c1303054b7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 75b83b372c06..a546efe66f2f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"b771ba5e57fca93e4b49512f7cde5cd1c1b0fe4cf247985aeae5bfd44cb54923","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"c3b22d889dc4c65a0ab8c4f5743e49df7f18a399a77b271a56e2cd60a2671fea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 94540322a878..4bddaffac84c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"8bc9cfdf0f7299079e3d39ce377b1eb8cf2b4b99ff39d3f8ceee1f7ac72d0dbd","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"7cc678c87c2d6063305951bc9bcd75f78f4fe2a9408a263bc1cb294c66075b50","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 96aadff0c4d3..505d71483049 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"69ed324324c86ad863f2804fd60dfc61f6b79040ee34fa52d33d1277857db4b1","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f30a3ff93dab57ce1a6e73c10b027d2bb748968cd87cda27228654fc41811a03","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 51b4f05da2d5..251f4ea554dc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"a98fa525383537f792dfc06ffb40d1a89cf7c5d1d381e253178f5a2ebad79276","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"b6ef4ba8c3840141bff65a7fee3a5ea8efbe3da79696f5d59b41f8ccf1e32914","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 2f5c29760554..4da75b1b6b96 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"8fdceb1f341434d335cbbb84052651df83b29436d1016c694461b072a75121c1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"43afd1b1996e17f373e75c3922cbea6a0223782b0b68f6d05f7c29bbd83b8ebb","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index ee55b7e02682..ceac45434780 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"121f16d39c0b147393ca289221d4b020856a661dfc989dc978b5a499bfce5222","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"4d6021d0ae4ad15e1ce39d8700756ec415d604606ed538240c8f8aa9bf0acc37","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 5a43a582629b..a581faedc4ea 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"00e179ad017e2921d85f9d2ffcf5f9e1bb415984d19e6fdc62a1832dbee659b0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"17eaa6392ffdf071bbe104a7c5720892042a906109be42478dec03b8bf5291a0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 1751f4ed359a..f232b4541473 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"e31574637f3e276338e3e05e4774f7ad79981042c8021e49fb53df7ffe901917","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"176c5db037a1e5b685c0550aeee46186b3273e8da4bbb8a720a074596598e8cc","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 2bdb8846687f..7c0976da00ab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"bfde664c63043f9719c8c12ef1e144dc387c05348b0f8b26bb2156438518dc21","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"7bbbe7b8509abe241992eb632b1b484df2c270534f485ecf0f80e48b5671566a","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d8be367b6c13..94e70da01e84 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"2608b548ecca0c2981d80bff3596982d74572558c0fe7eb846a971880b1563af","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"00ae2e13016a7ba0ebd4baef8b34a2b3811e0221799f6351d2189f6e0ea694ba","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index f655ce4ffdbd..474c9c965de1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"8220986c0c848a5fe05c909f5c0626a6ebe0800fe5517a196718d4f21d30ebb6","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"e20acaab6a8d3d56b57a487168439da89adfe250f0474a4c53350424415ac498","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 4d3025465523..0dd7bab53d6f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"8350a80731ab2e020c859ea1994acd004fbaa9ca9c0d92fb09acd8703bc79ed3","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"31e0fb8e73f98c8f69f8808aec8d8dc18fc2a074a21b9fc82414729fa1acceee","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index f7fcdd9e6351..1661414f0136 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"f61cc543e5d007e5da4951ccc61e12fc107fed478dfc8285b37389a77d2a6224","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"4918ded36f8fc0701bd94e4be965577c1c035977e6c5d32db1c17862775d6318","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index ffa394876c59..bc034e25fcc5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"f27ec37aa7c772f12aeda7b8aa969b34a7a7bb7877e6d95b26a8b3e9174f4899","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"cd199186dee1148dc53d05710cb9785fdcff475bedbc92f5c18b5ddca675dbe7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 7b8b290d000f..92ab4d7a41d3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"6579285290ce10e5bf4ae930bbb2d28de52672fc4c7dd0f55f0dcdc1d711d3c1","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"265be26af18bed1c86ddc4636df78ac2f064848dcfc9e9da8e05842c69750ba4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 4d3765f0ed74..88fab30f0dfc 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -21,7 +21,7 @@ advances a milestone. | 1c | Cleanup: node-pairing → device-pairing merge | not started | — | | 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 | | 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — | -| 4 | Picker: liveness, enrichment, Connect-a-machine | not started | — | +| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 | | 5 | Public worker ingress path | not started | — | | 6 | Node worker provider (device runners) | not started | — | | 7 | Bundle push consent + runner updates | not started | — | @@ -272,24 +272,46 @@ it cannot rot into approval fatigue or silent surprise: dispatch to stale nodes with a doctor-style hint instead of failing silently. +### Projects read model (milestone 4 foundation) + +OpenClaw already computes project identity twice without naming it: the +worktree service derives `originUrl` + a 16-char repo fingerprint +(`src/agents/worktrees/service.ts:199-205`), and the sessions catalog groups +Codex/Claude rows by project folder, folding `.claude/worktrees/` into +its origin repo. This component promotes that to a first-class observed read +model alongside the registered projects already returned by `projects.list`, +following the same computed pattern as `environments.list`: + +- **`projects.list.observedProjects` read model** (computed for + write-capable callers, no new store): group known checkouts by repo fingerprint → `{ name, originUrl, checkouts: +[{runnerId, path}], lastUsedAt }`. Sources: session rows + (`execCwd`/`execNode`) and the managed-worktree registry. The observed + paths and sanitized origins are returned only to `operator.write` callers; + read-only callers keep the registered project catalog and project-only + recents. Device-advertised checkouts remain milestone 6 work. + ### UI (milestone 4) Revision 1's design rule stands: normal state is silent; only exceptions speak. Additions: -- The Where picker subscribes to `presence` and pairing events (the devices - page already does), so a freshly connected machine appears live while the - popover is open — the visible payoff of the one-paste flow. -- Sections "This gateway / Your devices / Cloud"; session-capable connected - devices only (capability-gated); busy state from slot occupancy; presence - vocabulary distinguishes _never connected_ from _was connected, lost_ - (the first-connect failure is the top onboarding support case). -- `EnvironmentSummary` enriched additively: platform, session-host - capability, trust class, runner version. -- Placement chip on the session header: current placement + state; reclaim - ("Bring home") for remote placements; stop-and-continue moves once - milestone 8 ships. `runner-offline` shows as a banner with the recorded - reason and the two recovery verbs. +- **Use the existing environment type discriminant** for picker grouping: + local gateway, connected execution-capable nodes, worker environments, and + the separate cloud profiles list. `sessionHost` is deferred to milestone 6, + where device runners introduce the capability fact that needs it. +- **Where picker regrouped** (`ui/src/pages/new-session/place-picker.ts`): + sections "This gateway" / "Devices" / "Cloud". Device rows intersect the + environment catalog with connected, execution-capable nodes; cloud + profiles remain their separate list. Folder and destination stay + orthogonal. +- **Placement chip** on the session header: shows quiet current placement; + active cloud placements reclaim through `sessions.reclaim` with "Bring + home". Stop-and-continue moves arrive with milestone 8. +- **Remaining milestone work**: live presence and pairing subscriptions, the + admin-gated "Connect a machine…" foot, busy and never-connected states, + and additive `EnvironmentSummary` platform, session-host, trust, and runner + version facts. `runner-offline` then shows a banner with the recorded reason + and its recovery verbs. ### Cloud convergence (milestone 10) @@ -370,9 +392,10 @@ Independently mergeable PR series; 3–5 can interleave after 1c. shortcode mint + curl wrapper on the public site. Exit: a fresh machine pairs against a remote gateway with one pasted command and one admin click, no manual approval steps. -4. **Picker**: live presence subscription; "Connect a machine…" foot - (admin-gated) showing the copyable one-liner; regrouped sections; additive - `EnvironmentSummary` enrichment; never-connected vs lost states. +4. **Picker** (in progress): regrouped sections, quiet placement + reclaim, + and the observed projects read model land first; live presence subscription, + the admin-gated "Connect a machine…" foot, additive `EnvironmentSummary` + enrichment, and never-connected vs lost states complete the milestone. 5. **Public worker ingress**: path-tagged worker upgrade on the main TLS endpoint; opaque admission failure; shared preauth budgets. Exit: a worker process on any internet host with a valid dispatch credential completes diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index a89b94a37e7e..a4ffa3eeba41 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -37,6 +37,7 @@ export { } from "./schema/sessions-row.js"; export * from "./schema/session-classification.js"; export * from "./schema/sessions-suggestions.js"; +export * from "./schema/projects.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; export * from "./validator-registry.js"; diff --git a/packages/gateway-protocol/src/schema/projects.test.ts b/packages/gateway-protocol/src/schema/projects.test.ts index a67ffe651df9..26024f679724 100644 --- a/packages/gateway-protocol/src/schema/projects.test.ts +++ b/packages/gateway-protocol/src/schema/projects.test.ts @@ -1,8 +1,11 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, ProjectRecordSchema, ProjectsAddResultSchema, + ProjectSummarySchema, ProjectsListResultSchema, ProjectsSearchRemoteResultSchema, validateProjectsAddParams, @@ -16,6 +19,9 @@ import { describe("project protocol schemas", () => { it("validates project method inputs as closed objects", () => { expect(validateProjectsListParams({})).toBe(true); + expect(validateProjectsListParams({ includeObserved: true })).toBe(true); + expect(validateProjectsListParams({ includeObserved: false })).toBe(true); + expect(validateProjectsListParams({ includeObserved: "yes" })).toBe(false); expect(validateProjectsListParams({ extra: true })).toBe(false); expect(validateProjectsRegisterParams({ path: "/repo", name: "OpenClaw" })).toBe(true); expect(validateProjectsRegisterParams({ path: "" })).toBe(false); @@ -79,9 +85,36 @@ describe("project protocol schemas", () => { { kind: "project", projectId: "openclaw", displayName: "OpenClaw" }, { kind: "folder", folder: "/repo/scratch", displayName: "scratch" }, ], + observedProjects: [], }), ).toBe(true); expect(Value.Check(ProjectsListResultSchema, { projects: [] })).toBe(true); + expect(Value.Check(ProjectsListResultSchema, { observedProjects: [] })).toBe(false); + }); + + it("bounds observed projects and their checkout lists", () => { + const project = { + name: "openclaw", + originUrl: "https://github.com/openclaw/openclaw.git", + checkouts: [{ runnerId: "gateway", path: "/repo/openclaw" }], + lastUsedAt: 1, + }; + expect(Value.Check(ProjectSummarySchema, project)).toBe(true); + expect( + Value.Check(ProjectSummarySchema, { + ...project, + checkouts: Array.from( + { length: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT + 1 }, + (_, index) => ({ runnerId: "gateway", path: `/repo/openclaw-${index}` }), + ), + }), + ).toBe(false); + expect( + Value.Check(ProjectsListResultSchema, { + projects: [], + observedProjects: Array.from({ length: PROJECTS_LIST_DEFAULT_LIMIT + 1 }, () => project), + }), + ).toBe(false); }); it("accepts projectId as an additive sessions.create parameter", () => { diff --git a/packages/gateway-protocol/src/schema/projects.ts b/packages/gateway-protocol/src/schema/projects.ts index b9052ad35add..30ab05ee141a 100644 --- a/packages/gateway-protocol/src/schema/projects.ts +++ b/packages/gateway-protocol/src/schema/projects.ts @@ -7,6 +7,10 @@ const StoredProjectIdSchema = Type.String({ pattern: "^[a-z0-9][a-z0-9-]{0,63}$", }); +export const PROJECTS_LIST_DEFAULT_LIMIT = 50; +export const PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT = 50; +export const PROJECTS_LIST_MAX_IDENTITY_PROBES = 32; + export const ProjectRecordSchema = closedObject({ id: NonEmptyString, displayName: NonEmptyString, @@ -44,10 +48,50 @@ export const ProjectRecentSchema = Type.Union([ ProjectRecentFolderSchema, ]); -export const ProjectsListParamsSchema = closedObject({}); +/** One gateway-visible checkout for an observed repository project. */ +export const ProjectCheckoutSchema = closedObject({ + runnerId: Type.String({ + minLength: 1, + description: "Runner hosting this operator.write-scoped checkout.", + }), + path: Type.String({ + minLength: 1, + description: "Physical checkout path returned only to operator.write-capable callers.", + }), +}); + +/** Repository identity derived from visible checkout and session state. */ +export const ProjectSummarySchema = closedObject({ + name: NonEmptyString, + originUrl: Type.Optional( + Type.String({ + minLength: 1, + description: "Sanitized repository origin returned to operator.write-capable callers.", + }), + ), + checkouts: Type.Array(ProjectCheckoutSchema, { + minItems: 1, + maxItems: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + }), + lastUsedAt: Type.Number({ minimum: 0 }), +}); + +export const ProjectsListParamsSchema = closedObject({ + includeObserved: Type.Optional( + Type.Boolean({ + description: "Compute write-scoped observed checkout groups in addition to projects.", + }), + ), +}); export const ProjectsListResultSchema = closedObject({ projects: Type.Array(ProjectRecordSchema), recents: Type.Optional(Type.Array(ProjectRecentSchema, { maxItems: 8 })), + observedProjects: Type.Optional( + Type.Array(ProjectSummarySchema, { + maxItems: PROJECTS_LIST_DEFAULT_LIMIT, + description: "Observed checkout details returned only to operator.write-capable callers.", + }), + ), }); export const ProjectsRegisterParamsSchema = closedObject({ @@ -86,6 +130,8 @@ export const ProjectsRemoveResultSchema = closedObject({ removed: Type.Boolean() export type ProjectRecord = Static; export type ProjectRecent = Static; +export type ProjectCheckout = Static; +export type ProjectSummary = Static; export type ProjectsListParams = Static; export type ProjectsListResult = Static; export type ProjectsRegisterParams = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts index 29e4fe56a3b9..2e61aac04f96 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts @@ -24,6 +24,8 @@ export const AgentControlProtocolSchemas = { WorkerDesktopObserveResult: environments.WorkerDesktopObserveResultSchema, WorkerDesktopLaunchParams: environments.WorkerDesktopLaunchParamsSchema, WorkerDesktopLaunchResult: environments.WorkerDesktopLaunchResultSchema, + ProjectCheckout: projects.ProjectCheckoutSchema, + ProjectSummary: projects.ProjectSummarySchema, SystemInfoParams: systemInfo.SystemInfoParamsSchema, SystemInfoResult: systemInfo.SystemInfoResultSchema, AgentEvent: agent.AgentEventSchema, diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index 31731a81d7a6..4291d46d3439 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -60,6 +60,7 @@ const schemaNames = new Map([ ["WorkerDesktopObserveResult", "WorkerDesktopObserveResult"], ["WorkerDesktopLaunchParams", "WorkerDesktopLaunchParams"], ["WorkerDesktopLaunchResult", "WorkerDesktopLaunchResult"], + ["ProjectsListResult", "ProjectsListResult"], ]); const androidEnums: EnumSpec[] = [ diff --git a/src/agents/worktrees/service.test.ts b/src/agents/worktrees/service.test.ts index 0c5dc96515f0..f8d88e252a26 100644 --- a/src/agents/worktrees/service.test.ts +++ b/src/agents/worktrees/service.test.ts @@ -159,6 +159,21 @@ describe("ManagedWorktreeService", () => { expect(repeated).toEqual(created); }); + it("reads registry records without retiring a temporarily unavailable worktree", async () => { + const created = await service.create({ + repoRoot: repo, + name: "read-only-list", + baseRef: "HEAD", + }); + await fs.rm(created.path, { recursive: true, force: true }); + + expect(service.listRegistryRecords()).toEqual([expect.objectContaining({ id: created.id })]); + expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined(); + + expect(await service.list()).toEqual([]); + expect(getRegistryWorktree(env, created.id)?.removedAt).toBe(now); + }); + it("does not remove a worktree owned by another caller", async () => { const created = await service.create({ repoRoot: repo, diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index c932a4b1cc5f..b7e87068c50c 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -772,6 +772,11 @@ export class ManagedWorktreeService { return records.filter((record) => record.removedAt === undefined || record.snapshotRef); } + /** Returns persisted worktree facts without probing paths or mutating lifecycle state. */ + listRegistryRecords(): ManagedWorktreeRecord[] { + return listRegistryWorktrees(this.env); + } + findLiveByOwner( ownerKind: ManagedWorktreeOwnerKind, ownerId: string, @@ -796,6 +801,22 @@ export class ManagedWorktreeService { }; } + /** Resolves the repository facts shared by managed worktrees and project discovery. */ + async resolveRepositoryIdentity(repoRoot: string): Promise<{ + checkoutRoot: string; + repoRoot: string; + originUrl: string; + fingerprint: string; + }> { + const resolved = await resolveRepository(repoRoot); + return { + checkoutRoot: resolved.sourceRoot, + repoRoot: resolved.repoRoot, + originUrl: resolved.originUrl, + fingerprint: resolved.fingerprint, + }; + } + /** * Lists selectable base refs for a repository without touching the network. * Base-ref pickers must stay snappy; resolveWorktreeBase() still fetches on create diff --git a/src/gateway/server-methods.authorization.test.ts b/src/gateway/server-methods.authorization.test.ts index 4e8efb6f32be..eb20f7c0fa3f 100644 --- a/src/gateway/server-methods.authorization.test.ts +++ b/src/gateway/server-methods.authorization.test.ts @@ -106,6 +106,34 @@ describe("gateway method authorization", () => { }); }); + it("allows read-only projects.list to reach its redacting handler", async () => { + const handler = vi.fn(({ respond }) => respond(true, { projects: [] })); + const respond = vi.fn(); + + await handleGatewayRequest({ + req: { type: "req", id: "req-projects-read", method: "projects.list", params: {} }, + respond, + client: { + connId: "conn-projects-read", + connect: { + role: "operator", + scopes: ["operator.read"], + client: { id: "test", version: "1", platform: "test", mode: "test" }, + minProtocol: 1, + maxProtocol: 1, + }, + } as Parameters[0]["client"], + isWebchatConnect: () => false, + context: { logGateway: { warn: vi.fn() } } as unknown as Parameters< + typeof handleGatewayRequest + >[0]["context"], + extraHandlers: { "projects.list": handler }, + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(respond).toHaveBeenCalledWith(true, { projects: [] }); + }); + it("rejects every node RPC when its connection no longer owns the pairing generation", async () => { const handler = vi.fn(({ respond }) => respond(true, { ok: true })); const respond = vi.fn(); diff --git a/src/gateway/server-methods/projects-observed.test.ts b/src/gateway/server-methods/projects-observed.test.ts new file mode 100644 index 000000000000..06010a342921 --- /dev/null +++ b/src/gateway/server-methods/projects-observed.test.ts @@ -0,0 +1,308 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { createProjectsHandlers } from "./projects.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +type ProjectWorktreeService = Parameters[0]; + +const seededSessions = vi.hoisted(() => ({ + store: {} as Record, +})); + +vi.mock("../session-utils.js", () => ({ + loadCombinedSessionStoreForGatewayCore: () => ({ store: seededSessions.store }), +})); + +vi.mock("../../projects/project-registry.js", () => ({ + listProjectRegistry: () => [], + ProjectCheckoutError: class ProjectCheckoutError extends Error {}, + registerProjectRegistry: vi.fn(), + removeProjectRegistry: vi.fn(), +})); + +function authenticatedClient(user: string, scopes = ["operator.write"]): GatewayClient { + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + }, + role: "operator", + scopes, + }, + authenticatedUserId: user, + authenticatedUserProfile: { + profileId: user, + displayName: user, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + +function assertObservedProjectsPayload( + payload: unknown, +): asserts payload is { observedProjects: unknown[] } { + if (!isRecord(payload) || !Array.isArray(payload.observedProjects)) { + throw new TypeError("projects.list response is missing observedProjects"); + } +} + +async function listObservedProjects(params: { + service: { + listRegistryRecords: () => unknown[]; + resolveRepositoryIdentity: (checkoutPath: string) => Promise<{ + checkoutRoot: string; + repoRoot: string; + originUrl: string; + fingerprint: string; + }>; + }; + client?: GatewayClient; +}) { + const handlers = createProjectsHandlers(params.service as never); + const responses: Parameters[] = []; + await handlers["projects.list"]?.({ + params: { includeObserved: true }, + respond: (...response: Parameters) => responses.push(response), + context: { + getRuntimeConfig: () => ({ agents: { list: [{ id: "main", default: true }] } }), + } as GatewayRequestContext, + client: params.client ?? authenticatedClient("operator@example.com"), + } as never); + expect(responses).toHaveLength(1); + const response = responses[0]; + if (!response) { + throw new Error("projects.list did not respond"); + } + expect(response[0]).toBe(true); + assertObservedProjectsPayload(response[1]); + return response[1].observedProjects; +} + +beforeEach(() => { + seededSessions.store = {}; +}); + +describe("projects.list observed projects", () => { + it.each([["operator.write"], ["operator.admin"]])( + "returns detailed observed projects to %s callers", + async (scope) => { + seededSessions.store = { + "agent:main:old": { + sessionId: "old", + updatedAt: 100, + execCwd: "/links/alpha-old", + }, + "agent:main:new": { + sessionId: "new", + updatedAt: 300, + execCwd: "/links/alpha-new", + }, + "agent:main:device": { + sessionId: "device", + updatedAt: 400, + execCwd: "/device/alpha", + execNode: "paired-mac", + }, + }; + const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath.replace("/links/", "/physical/"), + repoRoot: "/physical/alpha", + originUrl: "https://github.com/openclaw/alpha.git", + fingerprint: "alpha-fingerprint", + })); + + await expect( + listObservedProjects({ + service: { listRegistryRecords: () => [], resolveRepositoryIdentity }, + client: authenticatedClient(`${scope}@example.com`, [scope]), + }), + ).resolves.toEqual([ + { + name: "alpha-new", + originUrl: "https://github.com/openclaw/alpha.git", + checkouts: [ + { runnerId: "gateway", path: "/physical/alpha-new" }, + { runnerId: "gateway", path: "/physical/alpha-old" }, + ], + lastUsedAt: 300, + }, + ]); + expect(resolveRepositoryIdentity).not.toHaveBeenCalledWith("/device/alpha"); + }, + ); + + it("admits managed worktrees only when their owning session is visible", async () => { + seededSessions.store = { + "agent:main:visible": { + sessionId: "visible", + updatedAt: 200, + visibility: "shared", + createdActor: { type: "human", id: "owner@example.com" }, + }, + "agent:main:private": { + sessionId: "private", + updatedAt: 300, + visibility: "draft", + createdActor: { type: "human", id: "owner@example.com" }, + }, + }; + const worktree = (name: string, ownerId: string, lastActiveAt: number) => ({ + id: name, + name, + repoFingerprint: name, + repoRoot: `/repos/${name}`, + path: `/worktrees/${name}`, + branch: `openclaw/${name}`, + baseRef: "main", + ownerKind: "session", + ownerId, + createdAt: 100, + lastActiveAt, + }); + const worktrees = [ + worktree("visible", "agent:main:visible", 500), + worktree("private", "agent:main:private", 490), + worktree("orphan", "agent:main:missing", 480), + { + ...worktree("manual", "ignored", 470), + ownerKind: "manual", + ownerId: undefined, + }, + ]; + const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: `https://example.test${checkoutPath}.git`, + fingerprint: checkoutPath, + })); + const service = { listRegistryRecords: () => worktrees, resolveRepositoryIdentity }; + + const viewer = (await listObservedProjects({ + service, + client: authenticatedClient("viewer@example.com"), + })) as Array<{ name: string }>; + expect(viewer.map((project) => project.name)).toEqual(["visible"]); + + const admin = (await listObservedProjects({ + service, + client: authenticatedClient("admin@example.com", ["operator.admin"]), + })) as Array<{ name: string }>; + expect(admin.map((project) => project.name)).toEqual([ + "visible", + "private", + "orphan", + "manual", + ]); + }); + + it("redacts URL and SCP-style userinfo and omits unknown remote forms", async () => { + seededSessions.store = Object.fromEntries( + ["url", "token-scp", "git-scp", "unknown"].map((name, index) => [ + `agent:main:${name}`, + { sessionId: name, updatedAt: 400 - index, execCwd: `/repos/${name}` }, + ]), + ); + const origins: Record = { + "/repos/url": ["https://user", ":placeholder", "@host/repo.git?visible=value#branch"].join( + "", + ), + "/repos/token-scp": ["placeholder", "@host:org/private.git"].join(""), + "/repos/git-scp": "git@host:org/public.git", + "/repos/unknown": "opaque credential-shaped remote", + }; + + const projects = (await listObservedProjects({ + service: { + listRegistryRecords: () => [], + resolveRepositoryIdentity: async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: origins[checkoutPath] ?? "", + fingerprint: checkoutPath, + }), + }, + })) as Array<{ name: string; originUrl?: string }>; + + expect(projects.map(({ name, originUrl }) => ({ name, originUrl }))).toEqual([ + { name: "url", originUrl: "https://host/repo.git" }, + { name: "token-scp", originUrl: "host:org/private.git" }, + { name: "git-scp", originUrl: "host:org/public.git" }, + { name: "unknown", originUrl: undefined }, + ]); + }); + + it("caps checkout arrays in deterministic newest-first order", async () => { + const worktrees = Array.from( + { length: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT + 3 }, + (_, index) => ({ + id: `worktree-${index}`, + name: `worktree-${index}`, + repoFingerprint: "alpha-fingerprint", + repoRoot: "/repos/alpha", + path: `/worktrees/${String(index).padStart(2, "0")}`, + branch: `openclaw/worktree-${index}`, + baseRef: "main", + ownerKind: "manual", + createdAt: 1, + lastActiveAt: index, + }), + ); + + const projects = (await listObservedProjects({ + service: { + listRegistryRecords: () => worktrees, + resolveRepositoryIdentity: async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: "https://github.com/openclaw/alpha.git", + fingerprint: "alpha-fingerprint", + }), + }, + client: authenticatedClient("admin@example.com", ["operator.admin"]), + })) as Array<{ checkouts: Array<{ path: string }> }>; + + expect(projects[0]?.checkouts).toHaveLength(PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT); + expect(projects[0]?.checkouts[0]?.path).toBe("/worktrees/52"); + expect(projects[0]?.checkouts.at(-1)?.path).toBe("/worktrees/03"); + }); + + it("retains only the newest bounded candidates before identity resolution", async () => { + const rawCandidateLimit = Math.max( + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, + 50, + ); + seededSessions.store = Object.fromEntries( + Array.from({ length: rawCandidateLimit + 5 }, (_, index) => [ + `agent:main:session-${index}`, + { sessionId: `session-${index}`, updatedAt: index, execCwd: `/repos/${index}` }, + ]), + ); + const resolveRepositoryIdentity = vi.fn( + async (_checkoutPath) => { + throw new Error("checkout unavailable"); + }, + ); + + await expect( + listObservedProjects({ + service: { listRegistryRecords: () => [], resolveRepositoryIdentity }, + }), + ).resolves.toEqual([]); + expect(resolveRepositoryIdentity).toHaveBeenCalledTimes(PROJECTS_LIST_MAX_IDENTITY_PROBES); + expect(resolveRepositoryIdentity.mock.calls.length).toBeLessThanOrEqual(rawCandidateLimit); + expect(resolveRepositoryIdentity.mock.calls[0]?.[0]).toBe(`/repos/${rawCandidateLimit + 4}`); + expect(resolveRepositoryIdentity).not.toHaveBeenCalledWith("/repos/0"); + }); +}); diff --git a/src/gateway/server-methods/projects.test.ts b/src/gateway/server-methods/projects.test.ts index acf394265d94..36106274b445 100644 --- a/src/gateway/server-methods/projects.test.ts +++ b/src/gateway/server-methods/projects.test.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { expect, test } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { insertRegistryWorktree } from "../../agents/worktrees/registry.js"; import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.js"; import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; @@ -14,9 +14,25 @@ import { } from "../../projects/project-registry.js"; import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; -import { projectsHandlers } from "./projects.js"; +import { createProjectsHandlers } from "./projects.js"; const execFileAsync = promisify(execFile); +const listRegistryRecords = vi.fn(() => []); +const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: "", + fingerprint: checkoutPath, +})); +const projectsHandlers = createProjectsHandlers({ + listRegistryRecords, + resolveRepositoryIdentity, +} as never); + +beforeEach(() => { + listRegistryRecords.mockClear(); + resolveRepositoryIdentity.mockClear(); +}); async function initializeRepository( root: string, @@ -117,8 +133,18 @@ test("projects.list exposes checkout details only at write scope", async () => { expect(project).not.toHaveProperty("repoRoot"); expect(project).not.toHaveProperty("originUrl"); } + expect(readResult.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).not.toHaveBeenCalled(); + expect(resolveRepositoryIdentity).not.toHaveBeenCalled(); + + const readOptIn = await invokeProjectMethod("projects.list", { includeObserved: true }, cfg, [ + "operator.read", + ]); + expect(readOptIn?.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).not.toHaveBeenCalled(); for (const scope of ["operator.write", "operator.admin"]) { + const callsBeforeDefaultList = listRegistryRecords.mock.calls.length; const writeResult = await invokeProjectMethod("projects.list", {}, cfg, [scope]); expect(writeResult).toMatchObject({ ok: true, @@ -133,7 +159,60 @@ test("projects.list exposes checkout details only at write scope", async () => { ], }, }); + expect(writeResult?.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).toHaveBeenCalledTimes(callsBeforeDefaultList); + + const observedResult = await invokeProjectMethod( + "projects.list", + { includeObserved: true }, + cfg, + [scope], + ); + expect(observedResult).toMatchObject({ + ok: true, + payload: { observedProjects: [] }, + }); } + expect(listRegistryRecords).toHaveBeenCalledTimes(2); + } finally { + await state.cleanup(); + } +}); + +test("project responses redact credentials and URL suffixes from registered origins", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const repo = await initializeRepository(state.root); + await execFileAsync("git", [ + "-C", + repo, + "remote", + "set-url", + "origin", + ["https://user", ":placeholder", "@host/private.git?visible=value#branch"].join(""), + ]); + + const registered = await invokeProjectMethod( + "projects.register", + { path: repo, name: "Private" }, + {}, + ["operator.admin"], + ); + expect(registered).toMatchObject({ + ok: true, + payload: { originUrl: "https://host/private.git" }, + }); + + const listed = await invokeProjectMethod("projects.list", {}, {}, ["operator.write"]); + expect(listed).toMatchObject({ + ok: true, + payload: { + projects: expect.arrayContaining([ + expect.objectContaining({ id: "workspace:main" }), + expect.objectContaining({ id: "private", originUrl: "https://host/private.git" }), + ]), + }, + }); } finally { await state.cleanup(); } diff --git a/src/gateway/server-methods/projects.ts b/src/gateway/server-methods/projects.ts index 7e3b51961143..a4a1c4027c9d 100644 --- a/src/gateway/server-methods/projects.ts +++ b/src/gateway/server-methods/projects.ts @@ -4,14 +4,20 @@ import { ErrorCodes, GatewayErrorDetailCodes, errorShape, + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, + type ProjectRecord, type ProjectRecent, validateProjectsAddParams, + type ProjectSummary, validateProjectsListParams, validateProjectsRegisterParams, validateProjectsRemoveParams, validateProjectsSearchRemoteParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listRegistryWorktrees } from "../../agents/worktrees/registry.js"; +import { managedWorktrees, type ManagedWorktreeService } from "../../agents/worktrees/service.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { ProjectCloneError } from "../../projects/project-clone-runtime.js"; @@ -31,17 +37,118 @@ import { listProfiles, resolveUserProfileId } from "../../state/user-profiles.js import { githubApiToken } from "../control-ui-github-api.js"; import { WRITE_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; import { searchRemoteProjects } from "../project-github-search.js"; +import { createSessionListEntryFilter } from "../session-sharing.js"; import { loadCombinedSessionStoreForGatewayCore } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; type ProjectRegistryEntry = ReturnType[number]; +type ProjectWorktreeService = Pick< + ManagedWorktreeService, + "listRegistryRecords" | "resolveRepositoryIdentity" +>; + +type ProjectCandidate = { + checkoutPath: string; + fingerprint: string; + lastUsedAt: number; + originUrl?: string; +}; + +type RawProjectCandidate = + | { kind: "session"; checkoutPath: string; lastUsedAt: number } + | { + kind: "worktree"; + checkoutPath: string; + fingerprint: string; + lastUsedAt: number; + repoRoot: string; + }; + +type ProjectGroup = { + checkouts: Map; + lastUsedAt: number; + name: string; + nameUsedAt: number; + originUrl?: string; +}; + +// This buffer must cover the largest possible response/checkouts while remaining independent of +// session history. Identity resolution has its own lower subprocess ceiling within this bound. +const PROJECTS_LIST_MAX_RAW_CANDIDATES = Math.max( + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, +); function folderDisplayName(folder: string): string { const trimmed = folder.replace(/[\\/]+$/u, ""); return path.posix.basename(trimmed) || path.win32.basename(trimmed) || folder; } +function checkoutName(checkoutPath: string): string { + const trimmed = checkoutPath.replace(/[\\/]+$/u, ""); + return trimmed.split(/[\\/]/u).at(-1) || trimmed; +} + +function compareRawProjectCandidates(left: RawProjectCandidate, right: RawProjectCandidate) { + return ( + right.lastUsedAt - left.lastUsedAt || + left.checkoutPath.localeCompare(right.checkoutPath) || + left.kind.localeCompare(right.kind) + ); +} + +function retainNewestRawProjectCandidate( + candidates: RawProjectCandidate[], + candidate: RawProjectCandidate, +) { + const insertionIndex = candidates.findIndex( + (existing) => compareRawProjectCandidates(candidate, existing) < 0, + ); + if (insertionIndex < 0) { + if (candidates.length < PROJECTS_LIST_MAX_RAW_CANDIDATES) { + candidates.push(candidate); + } + return; + } + candidates.splice(insertionIndex, 0, candidate); + if (candidates.length > PROJECTS_LIST_MAX_RAW_CANDIDATES) { + candidates.pop(); + } +} + +function sanitizePublicOriginUrl(originUrl: string): string | undefined { + const trimmed = originUrl.trim(); + const suffixIndex = trimmed.search(/[?#]/u); + const withoutSuffix = suffixIndex < 0 ? trimmed : trimmed.slice(0, suffixIndex); + const scp = /^[^@\s/:]+@(\[[^\]]+\]|[^:\s]+):(.+)$/u.exec(withoutSuffix); + if (scp) { + return `${scp[1]}:${scp[2]}`; + } + let parsed: URL; + try { + parsed = new URL(withoutSuffix); + } catch { + return undefined; + } + if (!parsed.username && !parsed.password) { + return withoutSuffix; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); +} + +function sanitizeProjectRecord(project: ProjectRecord): ProjectRecord { + const { originUrl, ...record } = project; + const sanitizedOriginUrl = originUrl ? sanitizePublicOriginUrl(originUrl) : undefined; + return { + ...record, + ...(sanitizedOriginUrl ? { originUrl: sanitizedOriginUrl } : {}), + }; +} + function resolvePathProject( projects: readonly ProjectRegistryEntry[], folder: string, @@ -117,212 +224,395 @@ function listProjectRecents( return recents; } -export const projectsHandlers: GatewayRequestHandlers = { - "projects.list": ({ params, respond, context, client }) => { - if (!assertValidParams(params, validateProjectsListParams, "projects.list", respond)) { - return; - } - const projects = listProjectRegistry(context.getRuntimeConfig()); - const profileId = client?.authenticatedUserProfile?.profileId; - const canonicalProfileId = profileId - ? (resolveUserProfileId(profileId) ?? profileId) - : undefined; - const recentProfileIds = canonicalProfileId - ? new Set([ - canonicalProfileId, - ...listProfiles() - .filter((profile) => profile.mergedInto === canonicalProfileId) - .map((profile) => profile.id), - ]) - : undefined; - const recents = recentProfileIds - ? listProjectRecents(context.getRuntimeConfig(), recentProfileIds, projects) - : undefined; - const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; - if (authorizeOperatorScopesForRequiredScope(WRITE_SCOPE, scopes).allowed) { - respond(true, { projects, ...(recents ? { recents } : {}) }, undefined); - return; - } - // Project identity is read-safe; host paths and origins are placement - // details reserved for clients that can create sessions. - respond( - true, - { - projects: projects.map((project) => - project.agentId - ? { - id: project.id, - displayName: project.displayName, - source: project.source, - agentId: project.agentId, - } - : { - id: project.id, - displayName: project.displayName, - source: project.source, - }, - ), - ...(recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}), - }, - undefined, - ); - }, - "projects.register": async ({ params, respond }) => { - if (!assertValidParams(params, validateProjectsRegisterParams, "projects.register", respond)) { - return; - } - try { - respond( - true, - await registerProjectRegistry({ path: params.path, name: params.name }), - undefined, - ); - } catch (error) { - respond( - false, - undefined, - errorShape( - error instanceof ProjectCheckoutError - ? ErrorCodes.INVALID_REQUEST - : ErrorCodes.UNAVAILABLE, - formatErrorMessage(error), - ), - ); - } - }, - "projects.add": async ({ params, respond, context, signal }) => { - if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) { - return; - } - try { - respond( - true, - await materializeProjectClone( - { cfg: context.getRuntimeConfig(), gitUrl: params.gitUrl, name: params.name }, - { signal, token: githubApiToken() }, - ), - undefined, - ); - } catch (error) { - if (error instanceof ProjectCloneError) { - respond( - false, - undefined, - errorShape( - error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - error.message, - { - details: { - code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, - cause: error.failure, - }, - retryable: error.failure === "network" || error.failure === "clone_failed", - }, - ), - ); - return; - } - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - } - }, - "projects.searchRemote": async ({ params, respond }) => { - if ( - !assertValidParams( - params, - validateProjectsSearchRemoteParams, - "projects.searchRemote", - respond, - ) - ) { - return; - } - try { - respond(true, await searchRemoteProjects(params.query), undefined); - } catch { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "GitHub project search is unavailable. Retry shortly.", { - retryable: true, - }), - ); - } - }, - "projects.remove": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) { - return; - } - const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id); - if (!project || project.source === "workspace") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), - ); - return; - } - if (params.deleteCheckout) { - if (project.source !== "cloned") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "Only projects cloned by the Gateway can delete their checkout.", - ), - ); - return; - } - const normalizedRoot = path.resolve(project.repoRoot); - const worktreeReference = listRegistryWorktrees(process.env).find( - (worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot, - ); - const sessionReference = Object.entries( - loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { projection: "list" }) - .store, - ).find(([, entry]) => { - if (entry.archivedAt) { - return false; - } - const sessionRoot = entry.worktree?.repoRoot; - if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) { - return true; - } - const cwd = entry.spawnedCwd; - return Boolean( - cwd && - (path.resolve(cwd) === normalizedRoot || isPathInside(normalizedRoot, path.resolve(cwd))), - ); +function projectCandidatesToSummaries(candidates: readonly ProjectCandidate[]): ProjectSummary[] { + const groups = new Map(); + for (const candidate of candidates) { + const group: ProjectGroup = groups.get(candidate.fingerprint) ?? { + checkouts: new Map(), + lastUsedAt: candidate.lastUsedAt, + name: checkoutName(candidate.checkoutPath), + nameUsedAt: candidate.lastUsedAt, + }; + const checkout = group.checkouts.get(candidate.checkoutPath); + if (!checkout || candidate.lastUsedAt > checkout.lastUsedAt) { + group.checkouts.set(candidate.checkoutPath, { + path: candidate.checkoutPath, + lastUsedAt: candidate.lastUsedAt, }); - if (worktreeReference || sessionReference) { - const reference = worktreeReference - ? `managed worktree ${worktreeReference.name}` - : `session ${sessionReference?.[0]}`; - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`, + } + group.lastUsedAt = Math.max(group.lastUsedAt, candidate.lastUsedAt); + if (candidate.lastUsedAt > group.nameUsedAt) { + group.name = checkoutName(candidate.checkoutPath); + group.nameUsedAt = candidate.lastUsedAt; + } + if (!group.originUrl && candidate.originUrl) { + group.originUrl = candidate.originUrl; + } + groups.set(candidate.fingerprint, group); + } + return [...groups.values()] + .toSorted( + (left, right) => right.lastUsedAt - left.lastUsedAt || left.name.localeCompare(right.name), + ) + .slice(0, PROJECTS_LIST_DEFAULT_LIMIT) + .map((group) => { + const summary: ProjectSummary = { + name: group.name, + checkouts: [...group.checkouts.values()] + .toSorted( + (left, right) => + right.lastUsedAt - left.lastUsedAt || left.path.localeCompare(right.path), + ) + .slice(0, PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT) + .map((checkout) => ({ runnerId: "gateway", path: checkout.path })), + lastUsedAt: group.lastUsedAt, + }; + if (group.originUrl) { + const originUrl = sanitizePublicOriginUrl(group.originUrl); + if (originUrl) { + summary.originUrl = originUrl; + } + } + return summary; + }); +} + +async function listObservedProjects( + service: ProjectWorktreeService, + context: Parameters[0]["context"], + client: Parameters[0]["client"], +): Promise { + const { store } = loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { + projection: "list", + }); + const rawCandidates: RawProjectCandidate[] = []; + const visibilityFilter = createSessionListEntryFilter({ client }); + const canSeeAll = !visibilityFilter; + for (const [sessionKey, entry] of Object.entries(store)) { + if (visibilityFilter && !visibilityFilter(sessionKey, entry)) { + continue; + } + const checkoutPath = entry.execCwd?.trim(); + if (checkoutPath && !entry.execNode?.trim()) { + retainNewestRawProjectCandidate(rawCandidates, { + kind: "session", + checkoutPath, + lastUsedAt: entry.updatedAt, + }); + } + } + for (const worktree of service.listRegistryRecords()) { + if (worktree.removedAt !== undefined) { + continue; + } + if (!canSeeAll) { + // Session-owned worktrees use their canonical session key as ownerId, so the same + // visibility policy that admitted the session also owns its managed checkout. + const ownerId = worktree.ownerKind === "session" ? worktree.ownerId?.trim() : undefined; + const ownerEntry = ownerId ? store[ownerId] : undefined; + if (!ownerId || !ownerEntry || !visibilityFilter?.(ownerId, ownerEntry)) { + continue; + } + } + retainNewestRawProjectCandidate(rawCandidates, { + kind: "worktree", + checkoutPath: worktree.path, + fingerprint: worktree.repoFingerprint, + lastUsedAt: worktree.lastActiveAt, + repoRoot: worktree.repoRoot, + }); + } + + const candidates: ProjectCandidate[] = []; + type RepositoryIdentity = Awaited< + ReturnType + >; + const identities = new Map>(); + let identityProbeCount = 0; + const resolveIdentity = (checkoutPath: string) => { + const existing = identities.get(checkoutPath); + if (existing) { + return existing; + } + if (identityProbeCount >= PROJECTS_LIST_MAX_IDENTITY_PROBES) { + return undefined; + } + identityProbeCount += 1; + const identity = Promise.resolve().then(() => service.resolveRepositoryIdentity(checkoutPath)); + identities.set(checkoutPath, identity); + return identity; + }; + + // The buffer is already newest-first, so probes always go to the retained top-K candidates. + for (const raw of rawCandidates) { + if (raw.kind === "worktree") { + let originUrl: string | undefined; + const pendingIdentity = resolveIdentity(raw.repoRoot); + try { + const identity = pendingIdentity ? await pendingIdentity : undefined; + originUrl = identity?.originUrl || undefined; + } catch { + // The registry fingerprint and checkout path remain authoritative if the source checkout + // disappears after the managed worktree record was written. + } + candidates.push({ + checkoutPath: raw.checkoutPath, + fingerprint: raw.fingerprint, + lastUsedAt: raw.lastUsedAt, + ...(originUrl ? { originUrl } : {}), + }); + continue; + } + const pendingIdentity = resolveIdentity(raw.checkoutPath); + if (!pendingIdentity) { + continue; + } + try { + const identity = await pendingIdentity; + candidates.push({ + checkoutPath: identity.checkoutRoot, + fingerprint: identity.fingerprint, + lastUsedAt: raw.lastUsedAt, + ...(identity.originUrl ? { originUrl: identity.originUrl } : {}), + }); + } catch { + // Plain folders remain available through the existing folder picker. + } + } + + // M5: merge operator-enabled device checkout advertisements at this seam. + return projectCandidatesToSummaries(candidates); +} + +export function createProjectsHandlers(service: ProjectWorktreeService): GatewayRequestHandlers { + return { + "projects.list": async ({ params, respond, context, client }) => { + if (!assertValidParams(params, validateProjectsListParams, "projects.list", respond)) { + return; + } + const registryProjects = listProjectRegistry(context.getRuntimeConfig()); + const projects = registryProjects.map(sanitizeProjectRecord); + const profileId = client?.authenticatedUserProfile?.profileId; + const canonicalProfileId = profileId + ? (resolveUserProfileId(profileId) ?? profileId) + : undefined; + const recentProfileIds = canonicalProfileId + ? new Set([ + canonicalProfileId, + ...listProfiles() + .filter((profile) => profile.mergedInto === canonicalProfileId) + .map((profile) => profile.id), + ]) + : undefined; + const recents = recentProfileIds + ? listProjectRecents(context.getRuntimeConfig(), recentProfileIds, registryProjects) + : undefined; + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + const canWrite = authorizeOperatorScopesForRequiredScope(WRITE_SCOPE, scopes).allowed; + if (params.includeObserved && canWrite) { + try { + const observedProjects = await listObservedProjects(service, context, client); + respond(true, { projects, ...(recents ? { recents } : {}), observedProjects }, undefined); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + return; + } + if (canWrite) { + respond(true, { projects, ...(recents ? { recents } : {}) }, undefined); + return; + } + // Project identity is read-safe; host paths, origins, folders, and observed checkouts are + // placement details reserved for clients that can create sessions. + respond( + true, + { + projects: projects.map((project) => + project.agentId + ? { + id: project.id, + displayName: project.displayName, + source: project.source, + agentId: project.agentId, + } + : { + id: project.id, + displayName: project.displayName, + source: project.source, + }, ), - ); + ...(recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}), + }, + undefined, + ); + }, + "projects.register": async ({ params, respond }) => { + if ( + !assertValidParams(params, validateProjectsRegisterParams, "projects.register", respond) + ) { return; } try { - await deleteClonedProjectCheckout(project); + respond( + true, + sanitizeProjectRecord( + await registerProjectRegistry({ path: params.path, name: params.name }), + ), + undefined, + ); } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + respond( + false, + undefined, + errorShape( + error instanceof ProjectCheckoutError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE, + formatErrorMessage(error), + ), + ); + } + }, + "projects.add": async ({ params, respond, context, signal }) => { + if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) { return; } - } - if (!removeProjectRegistry(params.id)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), - ); - return; - } - respond(true, { removed: true }, undefined); - }, -}; + try { + respond( + true, + await materializeProjectClone( + { cfg: context.getRuntimeConfig(), gitUrl: params.gitUrl, name: params.name }, + { signal, token: githubApiToken() }, + ), + undefined, + ); + } catch (error) { + if (error instanceof ProjectCloneError) { + respond( + false, + undefined, + errorShape( + error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, + error.message, + { + details: { + code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, + cause: error.failure, + }, + retryable: error.failure === "network" || error.failure === "clone_failed", + }, + ), + ); + return; + } + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, + "projects.searchRemote": async ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateProjectsSearchRemoteParams, + "projects.searchRemote", + respond, + ) + ) { + return; + } + try { + respond(true, await searchRemoteProjects(params.query), undefined); + } catch { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "GitHub project search is unavailable. Retry shortly.", + { retryable: true }, + ), + ); + } + }, + "projects.remove": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) { + return; + } + const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id); + if (!project || project.source === "workspace") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), + ); + return; + } + if (params.deleteCheckout) { + if (project.source !== "cloned") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "Only projects cloned by the Gateway can delete their checkout.", + ), + ); + return; + } + const normalizedRoot = path.resolve(project.repoRoot); + const worktreeReference = listRegistryWorktrees(process.env).find( + (worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot, + ); + const sessionReference = Object.entries( + loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { + projection: "list", + }).store, + ).find(([, entry]) => { + if (entry.archivedAt) { + return false; + } + const sessionRoot = entry.worktree?.repoRoot; + if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) { + return true; + } + const cwd = entry.spawnedCwd; + return Boolean( + cwd && + (path.resolve(cwd) === normalizedRoot || + isPathInside(normalizedRoot, path.resolve(cwd))), + ); + }); + if (worktreeReference || sessionReference) { + const reference = worktreeReference + ? `managed worktree ${worktreeReference.name}` + : `session ${sessionReference?.[0]}`; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`, + ), + ); + return; + } + try { + await deleteClonedProjectCheckout(project); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + return; + } + } + if (!removeProjectRegistry(params.id)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), + ); + return; + } + respond(true, { removed: true }, undefined); + }, + }; +} + +export const projectsHandlers = createProjectsHandlers(managedWorktrees); diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index 19ad02291a58..f78406a9c9e6 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -10,7 +10,13 @@ const suite = createNewSessionPageE2eSuite(); async function openDraft( operatorScopes: string[], - featureMethods = ["chat.metadata", "chat.startup", "sessions.create", "sessions.dispatch"], + featureMethods = [ + "chat.metadata", + "chat.startup", + "projects.list", + "sessions.create", + "sessions.dispatch", + ], ) { const context = await suite.browser.newContext({ locale: "en-US", @@ -22,6 +28,7 @@ async function openDraft( featureMethods, operatorScopes, methodResponses: { + "projects.list": { projects: [] }, "sessions.create": { key: "agent:main:operator-scope-proof", runStarted: true }, }, }); @@ -32,7 +39,10 @@ async function openDraft( suite.define(() => { it("keeps read-scoped operators out of new-session entry and submission paths", async () => { - const { context, gateway, page } = await openDraft(["operator.read"]); + const { context, gateway, page } = await openDraft( + ["operator.read"], + ["chat.metadata", "chat.startup", "projects.list", "sessions.create", "sessions.dispatch"], + ); try { const sidebarCreate = page.locator(".sidebar-brand__new-thread"); const submit = page.getByRole("button", { name: "Start session" }); @@ -45,6 +55,9 @@ suite.define(() => { "This action requires operator.admin access.", ); await submit.click({ force: true }); + const projectRequests = await gateway.getRequests("projects.list"); + expect(projectRequests).toHaveLength(1); + expect(projectRequests[0]?.params).toEqual({}); expect(await gateway.getRequests("sessions.create")).toHaveLength(0); } finally { await context.close(); @@ -54,6 +67,9 @@ suite.define(() => { it("allows write-scoped normal creation while keeping incognito admin-only", async () => { const { context, gateway, page } = await openDraft(["operator.read", "operator.write"]); try { + await expect(gateway.waitForRequest("projects.list")).resolves.toMatchObject({ + params: {}, + }); const submit = page.getByRole("button", { name: "Start session" }); const incognito = page.getByRole("switch", { name: "Incognito" }); diff --git a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts new file mode 100644 index 000000000000..f98b5d2c5759 --- /dev/null +++ b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts @@ -0,0 +1,816 @@ +import { expect, it } from "vitest"; +import { + EXEC_ONLY_PICKED, + NODE_HOME, + NODE_PICKED, + NODE_UNC, + SESSION_LIST_DEFAULTS, + WORKSPACE, + captureProjectUiProof, + captureUiProof, + createNewSessionPageE2eSuite, + createdSessionListResult, + installMockGateway, + pollLocatorText, + prepareProjectUiProof, + replaceGatewayClient, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); +const gatewayEnvironment = { + id: "gateway", + type: "local", + status: "available", +}; +const deviceEnvironment = (nodeId: string) => ({ + id: `node:${nodeId}`, + type: "node", + status: "available", +}); + +suite.define(() => { + it("registers a Git checkout from Browse and selects the refreshed project", async () => { + await prepareProjectUiProof(); + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const repoRoot = "/recorded/openclaw"; + const registeredProject = { + id: "recorded-openclaw", + displayName: "openclaw", + repoRoot, + originUrl: "https://github.com/openclaw/openclaw.git", + source: "registered", + }; + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "projects.list", + "projects.register", + "sessions.create", + "worktrees.branches", + ], + methodResponses: { + "projects.list": { + sequence: [{ projects: [] }, { projects: [registeredProject] }], + }, + "projects.register": registeredProject, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { path: WORKSPACE, home: "/home/peter", entries: [] }, + }, + { + match: { path: repoRoot }, + response: { path: repoRoot, parent: "/recorded", home: "/home/peter", entries: [] }, + }, + ], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await pathInput.fill(repoRoot); + await pathInput.press("Enter"); + const register = place.getByRole("button", { name: "Register as project" }); + await register.waitFor(); + await captureProjectUiProof(page, "project-register-action.png"); + await register.click(); + + const request = await gateway.waitForRequest("projects.register"); + expect(request.params).toEqual({ path: repoRoot }); + await expect.poll(async () => (await gateway.getRequests("projects.list")).length).toBe(2); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + expect(await trigger.getAttribute("data-project-id")).toBe("recorded-openclaw"); + } finally { + await context.close(); + } + }); + + it("handles a legacy projects-only response for write-scoped operators", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + operatorScopes: ["operator.read", "operator.write"], + featureMethods: ["chat.metadata", "chat.startup", "projects.list", "sessions.create"], + methodResponses: { "projects.list": { projects: [] } }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await page.locator("#new-session-place-trigger").click(); + await place.getByText("Projects", { exact: true }).waitFor(); + await place + .getByText("Admins can register projects from Browse folders", { exact: true }) + .waitFor(); + expect(await place.getByRole("button", { name: "Register as project" }).count()).toBe(0); + } finally { + await context.close(); + } + }); + + it("hides the destination axis when the Gateway is the only place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { + environments: [gatewayEnvironment], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + expect(await place.getByText("Places", { exact: true }).count()).toBe(0); + await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("uses advertised system info for Gateway place labels", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + featureMethods: ["chat.metadata", "chat.startup", "sessions.create", "system.info"], + methodResponses: { + "system.info": { + machineName: "Peters-Mac-Studio", + hostname: "peters-mac-studio.local", + platform: "darwin", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [gatewayEnvironment, deviceEnvironment("macbook")], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("system.info"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw · Gateway · Peters-Mac-Studio", + ); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await expect + .poll(() => + page.locator("input.new-session-page__browser-path").getAttribute("placeholder"), + ) + .toBe("Gateway · Peters-Mac-Studio"); + + await gateway.setMethodResponse("node.list", { nodes: [] }); + const nodeRequests = (await gateway.getRequests("node.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(nodeRequests); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("shows live devices when the initial environment catalog is unavailable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "fallback-device", + displayName: "Fallback device", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + __mockError: { + code: "UNAVAILABLE", + message: "environment catalog unavailable", + }, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByText("Devices", { exact: true }).waitFor(); + await place.getByRole("button", { name: "Fallback device" }).waitFor(); + await captureUiProof(page, "04-catalog-unavailable-device-fallback.png"); + } finally { + await context.close(); + } + }); + + it("keeps the last environment catalog across a same-Gateway client refresh failure", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "stable-device", + displayName: "Stable device", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [deviceEnvironment("stable-device")], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Stable device" }).waitFor(); + await page.keyboard.press("Escape"); + + await gateway.setMethodResponse("environments.list", { + __mockError: { + code: "UNAVAILABLE", + message: "environment refresh unavailable", + }, + }); + const environmentRequests = (await gateway.getRequests("environments.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(environmentRequests); + + await trigger.click(); + await place.getByRole("button", { name: "Stable device" }).waitFor(); + } finally { + await context.close(); + } + }); + + it("disambiguates duplicate node names without changing the selected chip", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "11111111aaaaaaaa", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac14,12", + remoteIp: "192.168.1.11", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "22222222bbbbbbbb", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac15,14", + remoteIp: "192.168.1.12", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "33333333cccccccc", + displayName: "iPhone", + platform: "iOS 26.4", + deviceFamily: "iPhone", + modelIdentifier: "iPhone17,2", + remoteIp: "192.168.1.30", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [ + gatewayEnvironment, + { id: "node:11111111aaaaaaaa", type: "node", status: "available" }, + deviceEnvironment("22222222bbbbbbbb"), + deviceEnvironment("33333333cccccccc"), + ], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await place.getByText("Devices", { exact: true }).waitFor(); + const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); + const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); + const phone = page.locator('[data-value="node:33333333cccccccc"]'); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14"); + await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone"); + expect(await first.locator(".session-menu__icon svg").count()).toBe(1); + expect(await second.locator(".session-menu__icon svg").count()).toBe(1); + expect(await phone.locator(".session-menu__icon svg").count()).toBe(1); + expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11"); + expect(await second.getAttribute("title")).toContain("192.168.1.12"); + await captureUiProof(page, "03-legacy-device-picker.png"); + await second.click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Agent workspace · Mac Studio", + ); + expect(await trigger.textContent()).not.toContain("Mac15,14"); + expect(await trigger.textContent()).not.toContain("192.168.1.12"); + } finally { + await context.close(); + } + }); + + it("keeps and disambiguates recent locations with the same basename", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { environments: [], profiles: [] }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" }, + { key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-collision" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const first = page.locator('[data-value="recent::/a/openclaw"]'); + const second = page.locator('[data-value="recent::/b/openclaw"]'); + await first.waitFor(); + await second.waitFor(); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("a"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("b"); + const recentValues = await page + .locator('[data-value^="recent::"]') + .evaluateAll((items) => items.map((item) => item.getAttribute("data-value"))); + expect(recentValues).toEqual(["recent::/a/openclaw", "recent::/b/openclaw"]); + await second.click(); + await page.locator(".new-session-page__message").fill("continue in work checkout"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: "/b/openclaw", + message: "continue in work checkout", + }); + } finally { + await context.close(); + } + }); + + it("applies a recent folder and node as one place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "environments.list": { + environments: [gatewayEnvironment, deviceEnvironment("macbook")], + profiles: [], + }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + key: "agent:main:recent-node", + kind: "direct", + updatedAt: 2, + execCwd: NODE_PICKED, + execNode: "macbook", + }, + { + key: "agent:main:workspace", + kind: "direct", + updatedAt: 1, + execCwd: WORKSPACE, + }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-place" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Projects · MacBook", exact: true }) + .click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Projects · MacBook", + ); + + await page.locator(".new-session-page__message").fill("continue on the recent node"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: NODE_PICKED, + execNode: "macbook", + message: "continue on the recent node", + }); + } finally { + await context.close(); + } + }); + it("runs directly in a custom non-Git Gateway folder", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [gatewayEnvironment], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + cases: [ + { + match: { repoRoot: WORKSPACE }, + response: { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + { + match: { repoRoot: "/home" }, + response: { branches: [], repositoryStatus: "not_git" }, + }, + ], + }, + "sessions.create": { key: "agent:main:plain-folder" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill("/home"); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: "/home", includeRepositoryStatus: true }); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "home · Gateway · local", + ); + + await trigger.click(); + expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await place + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await place.getByText("Cloud", { exact: true }).waitFor(); + const cloud = place.getByRole("button", { name: "Cloud · aws" }); + expect(await cloud.isDisabled()).toBe(true); + expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree"); + await page.keyboard.press("Escape"); + + await page.locator(".new-session-page__message").fill("clone and inspect this project"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + cwd: "/home", + message: "clone and inspect this project", + }); + expect(create.params).not.toHaveProperty("worktree"); + expect(create.params).not.toHaveProperty("worktreeBaseRef"); + } finally { + await context.close(); + } + }); + + it("browses capable nodes and accepts manual paths for exec-only nodes", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + { + nodeId: "old-node", + displayName: "Old node", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "offline-node", + displayName: "Offline node", + connected: false, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "environments.list": { + environments: [ + gatewayEnvironment, + deviceEnvironment("macbook"), + deviceEnvironment("old-node"), + deviceEnvironment("offline-node"), + ], + profiles: [], + }, + "fs.listDir": { + cases: [ + { + match: { nodeId: "macbook", path: NODE_UNC }, + response: { + path: NODE_UNC, + parent: "\\\\server\\share", + home: "C:\\Users\\peter", + entries: [], + }, + }, + { + match: { nodeId: "macbook", path: NODE_PICKED }, + response: { + path: NODE_PICKED, + parent: NODE_HOME, + home: NODE_HOME, + entries: [], + }, + }, + { + match: { nodeId: "macbook" }, + response: { + path: NODE_HOME, + home: NODE_HOME, + entries: [{ name: "Projects", path: NODE_PICKED }], + }, + }, + ], + }, + "sessions.create": { key: "agent:main:node-draft-e2e" }, + "sessions.list": createdSessionListResult("agent:main:node-draft-e2e"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator(".new-session-page__message").waitFor(); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + const placeLabel = placeTrigger.locator(".new-session-page__trigger-label"); + const browserEntries = page.locator(".new-session-page__browser-list"); + + // Pick the node from Devices. + await placeTrigger.click(); + await placeSelect + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await placeSelect.getByText("Devices", { exact: true }).waitFor(); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + // Node sessions cannot use managed worktrees, so the menu drops the item. + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await page.keyboard.press("Escape"); + + // Manual path entry in the browser head preserves UNC paths; these + // cannot be rediscovered by starting at the node home directory. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(NODE_UNC); + await pathInput.press("Enter"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC); + // Escape returns to the picker root without applying or closing it. + await page.keyboard.press("Escape"); + await expect + .poll(() => + placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(true); + await placeSelect + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + + // Destination selection stays grouped by place; browsing is fixed to the current target. + await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); + await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + + // Using a node folder retargets the draft to that node. + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + // A node cwd belongs to the selected agent's draft and must not leak + // across an agent change, even though the execution node stays selected. + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await agentPicker.locator(".agent-select__trigger").click(); + await agentPicker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: "Research" }) + .click(); + await page.getByRole("heading", { name: "Research" }).waitFor(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Clearing the path applies the node's default directory (empty folder), + // the state the replaced clearable folder textbox could express. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(""); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Browse back to the custom folder, then retarget to the exec-only node + // with a manual absolute path for the final create assertion. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Old node" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(""); + await pathInput.fill(EXEC_ONLY_PICKED); + await pathInput.press("Enter"); + expect( + (await gateway.getRequests("fs.listDir")).filter( + (request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node", + ), + ).toHaveLength(0); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("repo · Old node"); + + await page.locator(".new-session-page__message").fill("inspect the remote checkout"); + await page.getByRole("button", { name: "Start session" }).click(); + const createRequest = await gateway.waitForRequest("sessions.create"); + expect(createRequest.params).toMatchObject({ + agentId: "research", + message: "inspect the remote checkout", + execNode: "old-node", + cwd: EXEC_ONLY_PICKED, + }); + expect(createRequest.params).not.toHaveProperty("worktree"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts index 06f1abeaa377..c70471f80942 100644 --- a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -46,6 +46,14 @@ function branchList(name = "main") { }; } +function deviceEnvironment(nodeId: string) { + return { + id: `node:${nodeId}`, + type: "node", + status: "available", + }; +} + async function withNewSessionPage( options: BrowserContextOptions, run: (page: Page) => Promise, @@ -295,12 +303,17 @@ suite.define(() => { }, ], }, + "environments.list": { + environments: [deviceEnvironment("old-device")], + profiles: [], + }, "worktrees.branches": branchList(), "sessions.create": { key: "agent:main:validated-device" }, }, }); await page.goto(`${suite.server.baseUrl}new`); await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); await page.locator("#new-session-place-trigger").click(); await placeSelect.getByRole("button", { name: "Old device" }).click(); @@ -343,12 +356,17 @@ suite.define(() => { }, ], }, + "environments.list": { + environments: [deviceEnvironment("old-device")], + profiles: [], + }, "worktrees.branches": branchList("alpha"), }, }); await page.goto(`${suite.server.baseUrl}new`); await page.getByRole("heading", { name: "Original agent" }).waitFor(); await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); await gateway.waitForRequest("worktrees.branches"); const message = page.locator(".new-session-page__message"); @@ -379,9 +397,14 @@ suite.define(() => { }, ], }); + await gateway.setMethodResponse("environments.list", { + environments: [deviceEnvironment("new-device")], + profiles: [], + }); await gateway.setMethodResponse("worktrees.branches", branchList("beta")); const socketsBefore = await gateway.getSocketCount(); const nodesBefore = (await gateway.getRequests("node.list")).length; + const environmentsBefore = (await gateway.getRequests("environments.list")).length; const branchesBefore = (await gateway.getRequests("worktrees.branches")).length; await replaceGatewayClient(page); @@ -390,6 +413,9 @@ suite.define(() => { await expect .poll(async () => (await gateway.getRequests("node.list")).length) .toBe(nodesBefore + 1); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBe(environmentsBefore + 1); await expect .poll(async () => (await gateway.getRequests("worktrees.branches")).length) .toBe(branchesBefore + 1); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a8a8dc9e9f0d..7681cc675caa 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -699,6 +699,7 @@ export const en: TranslationMap = { agent: "Agent", where: "Where", gateway: "Gateway · local", + thisGateway: "This gateway", gatewayNamed: "Gateway · {name}", cloudWorker: "Cloud · {profile}", cloudWorkerProvider: "Cloud worker provider: {provider}", @@ -720,6 +721,7 @@ export const en: TranslationMap = { cloneProject: "Clone", cloningProject: "Cloning project…", registerProject: "Register as project", + cloud: "Cloud", recentFolders: "Recent", runsOn: "Runs on {place}", browse: "Browse folders", diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 46b85af406d1..cdf6e9a51e5e 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -160,6 +160,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @litState() protected headerRenameValue = ""; @litState() protected headerPlatform: string | null = null; @litState() protected headerCopiedAction: ChatPaneHeaderAction | null = null; + @litState() protected headerPlacementReclaimingKey: string | null = null; @litState() protected presencePayload: PresencePayload | undefined; @litState() protected sessionSharingStates = new Map(); protected readonly sessionParticipationTracker = new SessionParticipationTracker(); diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index ad685eeb8c89..5c6a3a7a95d5 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -1,3 +1,4 @@ +import type { GatewaySessionRow } from "../../api/types.ts"; import { invalidateAssistantIdentityCache } from "../../app/assistant-identity.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; @@ -22,6 +23,7 @@ import { import { invalidateChatAvatarCache } from "./chat-avatar.ts"; import { applyChatAgentsList, syncSelectedSessionMessageSubscription } from "./chat-history.ts"; import { ChatPaneLifecycle } from "./chat-pane-lifecycle.ts"; +import { reclaimChatPanePlacement } from "./chat-pane-placement.ts"; import { applySelectedSessionProjection } from "./chat-pane-state.ts"; import { resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts"; import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts"; @@ -53,6 +55,29 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { super.disconnectedCallback(); } + protected async reclaimHeaderPlacement(row: GatewaySessionRow): Promise { + const onReclaimingChange = (reclaimingKey: string | null) => { + // A later reclaim may take ownership before this request settles. Only + // the request that still owns the row may clear the pane's progress key. + if (reclaimingKey !== null || this.headerPlacementReclaimingKey === row.key) { + this.headerPlacementReclaimingKey = reclaimingKey; + } + }; + await reclaimChatPanePlacement({ + client: this.connectedClient, + connectionGeneration: this.connectionGeneration, + gatewaySnapshot: this.context.gateway.snapshot, + reclaimingKey: this.headerPlacementReclaimingKey, + row, + isCurrent: (client, generation) => + this.connectedClient === client && this.connectionGeneration === generation, + onReclaimingChange, + publishError: (error) => this.publishHeaderError(error), + refreshReplacement: (agentId) => this.context.sessions.refreshReplacement(agentId), + requestUpdate: () => this.requestUpdate(), + }); + } + protected applySessionsState(stateValue: ApplicationContext["sessions"]["state"]) { const state = this.state; if (!state) { diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 41407f413b7b..ef43f5294eb1 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -25,6 +25,7 @@ import { import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { isActiveTask } from "../../lib/tasks/data.ts"; import { renderBoardViewSwitch } from "./board-session-surface.ts"; +import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-render.ts"; @@ -311,6 +312,11 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { onActivate: () => this.onSplitRight?.(this.paneId), }); } + const placement = resolveChatPanePlacement({ + gatewaySnapshot: this.context.gateway.snapshot, + reclaimingKey: this.headerPlacementReclaimingKey, + row, + }); return renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, @@ -452,6 +458,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { .onAction=${(action: HeaderMenuAction) => this.handleHeaderSessionAction(action, row)} >` : nothing, + placementReclaimDisabledReason: placement.reclaimDisabledReason, nativeGateways: this.nativeGateways, gatewaysSnapshot: this.gatewaysSnapshot, onboarding: this.onboarding, @@ -474,6 +481,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { onOpenParentSession: (sessionKey) => { this.onPaneSessionChange?.(this.paneId, sessionKey); }, + onPlacementReclaim: () => row && void this.reclaimHeaderPlacement(row), onBranchSelect: (leafEntryId) => { const access = readChatSessionActionAccess( this.context.gateway.snapshot, diff --git a/ui/src/pages/chat/chat-pane-placement.test.ts b/ui/src/pages/chat/chat-pane-placement.test.ts new file mode 100644 index 000000000000..eaa2d6c85a97 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-placement.test.ts @@ -0,0 +1,217 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { + answerConfirmDialog, + installDialogPolyfill, + waitForConfirmDialogActions, +} from "../../test-helpers/modal-dialog.ts"; +import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; +import { createTestChatPane } from "./chat-pane.test-support.ts"; + +let restoreDialogPolyfill: () => void; + +beforeEach(() => { + restoreDialogPolyfill = installDialogPolyfill(); +}); + +afterEach(() => { + document.body.replaceChildren(); + restoreDialogPolyfill(); + vi.unstubAllGlobals(); +}); + +type ActivePlacement = Extract, { state: "active" }>; + +function activePlacementSession( + key = "agent:main:cloud", +): GatewaySessionRow & { placement: ActivePlacement } { + return { + key, + kind: "direct", + updatedAt: 0, + placement: { + state: "active", + generation: 1, + createdAtMs: 1, + updatedAtMs: 1, + stateChangedAtMs: 1, + environmentId: "worker:one", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "base-manifest", + remoteWorkspaceDir: "/worker/repo", + }, + }; +} + +describe("chat pane placement", () => { + it("does not reclaim a provisioning placement with a destroyable environment", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + + const session = { + key: "agent:main:provisioning", + kind: "direct", + updatedAt: 0, + placement: { + state: "provisioning", + environmentId: "worker:one", + } as GatewaySessionRow["placement"], + } satisfies GatewaySessionRow; + const placement = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: null, + row: session, + }); + const dialogsBefore = document.body.querySelectorAll("openclaw-modal-dialog").length; + await pane.reclaimHeaderPlacement(session); + + expect(placement).toEqual({ + reclaimDisabledReason: "This Gateway does not support this session action.", + }); + expect(document.body.querySelectorAll("openclaw-modal-dialog")).toHaveLength(dialogsBefore); + expect(request).not.toHaveBeenCalled(); + }); + + it("reclaims an active placement after the operator confirms", async () => { + vi.stubGlobal( + "confirm", + vi.fn(() => { + throw new Error("native confirm must not be used"); + }), + ); + const request = vi.fn(async () => ({ ok: true })); + const refreshReplacement = vi.fn(async () => undefined); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: { refreshReplacement } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + expect(actions.textContent).toContain("Stop worker"); + answerConfirmDialog(actions, "confirm"); + await reclaim; + + expect(request).toHaveBeenCalledWith( + "sessions.reclaim", + { key: session.key, agentId: "main" }, + { timeoutMs: 10 * 60_000 }, + ); + expect(refreshReplacement).toHaveBeenCalledWith("main"); + }); + + it("does not reclaim when the operator cancels", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + answerConfirmDialog(actions, "cancel"); + await reclaim; + + expect(request).not.toHaveBeenCalled(); + }); + + it("does not reclaim after the connection changes while confirmation is open", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane, state } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + pane.connectionGeneration += 1; + answerConfirmDialog(actions, "confirm"); + await reclaim; + + expect(request).not.toHaveBeenCalled(); + expect(state.chatError).toBe(t("sessionsView.actionUnavailable")); + }); + + it("keeps reclaim progress with its session when the pane switches rows", async () => { + let resolveRequest!: (result: { ok: true }) => void; + const request = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + resolveRequest = resolve; + }), + ); + const refreshReplacement = vi.fn(async () => undefined); + const { pane, state } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: { refreshReplacement } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const sessionA = activePlacementSession("agent:main:cloud-a"); + const sessionB = { + ...sessionA, + key: "agent:main:cloud-b", + placement: { + ...sessionA.placement, + environmentId: "worker:two", + remoteWorkspaceDir: "/worker/repo-b", + }, + } satisfies GatewaySessionRow; + + const pendingReclaim = pane.reclaimHeaderPlacement(sessionA); + const actions = await waitForConfirmDialogActions(); + answerConfirmDialog(actions, "confirm"); + await vi.waitFor(() => expect(pane.headerPlacementReclaimingKey).toBe(sessionA.key)); + expect(pane.headerPlacementReclaimingKey).toBe(sessionA.key); + + state.sessionKey = sessionB.key; + expect(state.sessionKey).toBe(sessionB.key); + const placementA = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: pane.headerPlacementReclaimingKey, + row: sessionA, + }); + const placementB = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: pane.headerPlacementReclaimingKey, + row: sessionB, + }); + expect(placementA.reclaimDisabledReason).toBe(t("common.loading")); + expect(placementB.reclaimDisabledReason).toBeUndefined(); + + resolveRequest({ ok: true }); + await pendingReclaim; + + expect(pane.headerPlacementReclaimingKey).toBeNull(); + }); +}); diff --git a/ui/src/pages/chat/chat-pane-placement.ts b/ui/src/pages/chat/chat-pane-placement.ts new file mode 100644 index 000000000000..86ad5707b7fc --- /dev/null +++ b/ui/src/pages/chat/chat-pane-placement.ts @@ -0,0 +1,102 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { + requestCloudWorkerStop, + resolveCloudWorkerStopAction, +} from "../../components/cloud-worker-stop.ts"; +import { t } from "../../i18n/index.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; + +export function resolveChatPanePlacement(params: { + gatewaySnapshot: ApplicationGatewaySnapshot; + reclaimingKey: string | null; + row: GatewaySessionRow | undefined; +}): { reclaimDisabledReason: string | undefined } { + const reclaiming = params.reclaimingKey === params.row?.key; + const action = resolveCloudWorkerStopAction(params.row?.placement); + const access = readSessionMethodAccess(params.gatewaySnapshot, { + method: "sessions.reclaim", + requiredScope: "operator.admin", + }); + const reclaimDisabledReason = reclaiming + ? t("common.loading") + : params.row?.hasActiveRun === true + ? t("sessionsView.activeRun") + : action?.method !== "sessions.reclaim" + ? t("sessionsView.actionUnavailable") + : access.allowed + ? undefined + : access.reason; + return { + reclaimDisabledReason, + }; +} + +export async function reclaimChatPanePlacement(params: { + client: GatewayBrowserClient | null; + connectionGeneration: number; + gatewaySnapshot: ApplicationGatewaySnapshot; + reclaimingKey: string | null; + row: GatewaySessionRow; + isCurrent: (client: GatewayBrowserClient, generation: number) => boolean; + onReclaimingChange: (reclaimingKey: string | null) => void; + publishError: (error: unknown) => void; + refreshReplacement: (agentId?: string | null) => Promise; + requestUpdate: () => void; +}): Promise { + const client = params.client; + const connectionGeneration = params.connectionGeneration; + const action = resolveCloudWorkerStopAction(params.row.placement); + const reclaiming = params.reclaimingKey === params.row.key; + if ( + !client || + reclaiming || + params.row.hasActiveRun === true || + action?.method !== "sessions.reclaim" + ) { + return; + } + const access = readSessionMethodAccess(params.gatewaySnapshot, { + method: "sessions.reclaim", + requiredScope: "operator.admin", + }); + if (!access.allowed) { + params.publishError(access.reason); + return; + } + const { showConfirmDialog } = await import("../../components/confirm-dialog.js"); + const confirmed = await showConfirmDialog({ + message: t("sessionsView.stopCloudWorkerConfirm", { + session: params.row.label || params.row.key, + }), + confirmLabel: t("sessionsView.stopCloudWorkerConfirmAction"), + danger: true, + }); + if (!confirmed) { + return; + } + if (!params.isCurrent(client, connectionGeneration)) { + params.publishError(t("sessionsView.actionUnavailable")); + return; + } + const agentId = parseAgentSessionKey(params.row.key)?.agentId; + params.onReclaimingChange(params.row.key); + try { + await requestCloudWorkerStop(client, action, { + key: params.row.key, + ...(agentId ? { agentId } : {}), + }); + if (params.isCurrent(client, connectionGeneration)) { + await params.refreshReplacement(agentId); + } + } catch (error) { + if (params.isCurrent(client, connectionGeneration)) { + params.publishError(error); + } + } finally { + params.onReclaimingChange(null); + params.requestUpdate(); + } +} diff --git a/ui/src/pages/chat/chat-pane-task-suggestions.ts b/ui/src/pages/chat/chat-pane-task-suggestions.ts index 5bec8c4827a7..78b5b8332279 100644 --- a/ui/src/pages/chat/chat-pane-task-suggestions.ts +++ b/ui/src/pages/chat/chat-pane-task-suggestions.ts @@ -13,7 +13,7 @@ import { taskSuggestionAcceptParams, type TaskSuggestionAcceptMode, } from "../../lib/task-suggestion-acceptance.ts"; -import { discoverCloudProfiles } from "../new-session/cloud-profile-discovery.ts"; +import { discoverPlaceCatalog } from "../new-session/cloud-profile-discovery.ts"; import { ChatPaneSharing } from "./chat-pane-sharing.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; @@ -49,7 +49,7 @@ export abstract class ChatPaneTaskSuggestions extends ChatPaneSharing { return; } try { - const profiles = await discoverCloudProfiles(scope.client, true); + const { profiles } = await discoverPlaceCatalog(scope.client, true); if (!this.isConnectionScopeCurrent(scope)) { return; } diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 4023b39d22c7..1560afe11047 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -120,6 +120,8 @@ export type TestChatPane = HTMLElement & { agentWorkspace: string | undefined, workspaceGit: boolean, ) => Promise; + headerPlacementReclaimingKey: string | null; + reclaimHeaderPlacement: (row: GatewaySessionRow) => Promise; markSessionRead: (row: GatewaySessionRow | undefined) => void; renderPaneHeader: ( workspace: ReturnType, diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 828e4b7c33fb..c100c12bbf0d 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -302,6 +302,56 @@ describe("chat pane header", () => { expect(props.onBeginRename).toHaveBeenCalledOnce(); }); + it("renders a quiet cloud placement chip with the canonical stop action", () => { + const onPlacementReclaim = vi.fn(); + const { container } = mount({ + session: row({ + placement: { + state: "active", + generation: 1, + createdAtMs: 100_000, + updatedAtMs: 300_000, + stateChangedAtMs: 300_000, + environmentId: "worker:one", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "base-manifest", + remoteWorkspaceDir: "/worker/repo", + }, + }), + onPlacementReclaim, + }); + + expect(container.querySelector(".chat-pane__placement-chip")?.textContent?.trim()).toBe( + "Runs on Cloud", + ); + expect(container.querySelector(".chat-pane__placement-state")).toBeNull(); + expect(container.querySelector(".chat-pane__placement-note")).toBeNull(); + const actions = container.querySelectorAll(".chat-pane__placement-menu wa-dropdown-item"); + expect(actions).toHaveLength(1); + expect(actions[0]?.textContent?.trim()).toBe("Stop cloud worker…"); + expect(actions[0]?.classList.contains("session-menu__item--destructive")).toBe(true); + expect(actions[0]?.getAttribute("variant")).toBe("danger"); + expect(actions[0]?.querySelector(".session-menu__icon")).not.toBeNull(); + actions[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPlacementReclaim).toHaveBeenCalledOnce(); + }); + + it.each(["local", "reclaimed"] as const)("hides the placement chip for %s state", (state) => { + const { container } = mount({ + session: row({ + placement: { + state, + generation: 1, + createdAtMs: 1, + updatedAtMs: 1, + stateChangedAtMs: 1, + }, + }), + }); + expect(container.querySelector(".chat-pane__placement-chip")).toBeNull(); + }); + it("places pane presence between the identity trail and face control", () => { const { container } = mount({ presence: html``, @@ -456,7 +506,7 @@ describe("chat pane header", () => { }), canReveal: false, }); - expect(container.querySelector(".chat-pane__cloud")).not.toBeNull(); + expect(container.querySelector(".chat-pane__placement-chip")).not.toBeNull(); expect(container.querySelector('wa-dropdown-item[value="reveal"]')).toBeNull(); expect(container.querySelector('wa-dropdown-item[value="copy-path"]')).not.toBeNull(); }); diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index dd5cffadaa25..4a383d4267a1 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -27,6 +27,7 @@ import { areUiSessionKeysEquivalent, resolveUiSessionNavigationParentKey, } from "../../../lib/sessions/session-key.ts"; +import { renderChatPanePlacement } from "./chat-pane-placement.ts"; export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch"; @@ -68,6 +69,7 @@ type ChatPaneHeaderProps = { faceControl?: TemplateResult | typeof nothing; sharingControl?: TemplateResult | typeof nothing; sessionMenuAction: TemplateResult | typeof nothing; + placementReclaimDisabledReason?: string; nativeGateways?: NativeGatewaysCapability | null; gatewaysSnapshot?: NativeGatewaysSnapshot | null; onboarding?: boolean; @@ -78,6 +80,7 @@ type ChatPaneHeaderProps = { onMenuOpenChange: (open: boolean) => void; onMenuAction: (action: ChatPaneHeaderAction) => void; onOpenParentSession: (sessionKey: string) => void; + onPlacementReclaim?: () => void; onBranchSelect: (leafEntryId: string) => void; onOpenSplitView?: () => void; onSplitDown?: (paneId: string) => void; @@ -396,9 +399,6 @@ function renderGatewayPicker(props: ChatPaneHeaderProps) { } export function renderChatPaneHeader(props: ChatPaneHeaderProps) { - const placementState = props.session?.placement?.state; - const cloud = isCloudWorkerPlacementState(placementState); - const cloudLabel = cloud ? t("sessionsView.cloudWorkerPlacement", { state: placementState }) : ""; const copyPathLabel = props.copiedAction === "copy-path" ? t("chat.sessionHeader.copied") @@ -432,15 +432,6 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ` : nothing} - ${cloud - ? html`${icons.globe}` - : nothing} ${props.session?.incognito ? html` 1 ? html` diff --git a/ui/src/pages/chat/components/chat-pane-placement.ts b/ui/src/pages/chat/components/chat-pane-placement.ts new file mode 100644 index 000000000000..e239bb767035 --- /dev/null +++ b/ui/src/pages/chat/components/chat-pane-placement.ts @@ -0,0 +1,42 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { GatewaySessionRow } from "../../../api/types.ts"; +import { icons } from "../../../components/icons.ts"; +import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts"; +import { t } from "../../../i18n/index.ts"; +import { formatRelativeTimestamp } from "../../../lib/format.ts"; + +export function renderChatPanePlacement(props: { + session: GatewaySessionRow | undefined; + placementReclaimDisabledReason?: string; + onPlacementReclaim?: () => void; +}): TemplateResult | typeof nothing { + const placementState = props.session?.placement?.state; + if (!isCloudWorkerPlacementState(placementState)) { + return nothing; + } + const label = t("newSession.runsOn", { place: t("newSession.cloud") }); + const disabledReason = props.placementReclaimDisabledReason; + const age = formatRelativeTimestamp(props.session?.placement?.stateChangedAtMs, { + fallback: "", + }); + const exceptionState = + placementState === "active" ? nothing : `${placementState}${age ? ` · ${age}` : ""}`; + return html` + + + ${exceptionState === nothing + ? nothing + : html`
${exceptionState}
`} + !disabledReason && props.onPlacementReclaim?.()} + > + + ${t("sessionsView.stopCloudWorker")} + +
+ `; +} diff --git a/ui/src/pages/new-session/cloud-profile-discovery.ts b/ui/src/pages/new-session/cloud-profile-discovery.ts index db35175ee901..f128a1a8d78f 100644 --- a/ui/src/pages/new-session/cloud-profile-discovery.ts +++ b/ui/src/pages/new-session/cloud-profile-discovery.ts @@ -1,6 +1,6 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import { requestCloudProfiles } from "./cloud-target.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; +import { requestPlaceCatalog } from "./cloud-target.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; export const CLOUD_PROFILE_RETRY_DELAYS_MS = [1_000, 3_000, 10_000, 30_000, 60_000] as const; @@ -13,9 +13,9 @@ export function selectProfiles( return { profiles: unsupported ? [] : profiles, unsupported }; } -export function discoverCloudProfiles( +export function discoverPlaceCatalog( client: Pick, admin: boolean, -): Promise { - return admin ? requestCloudProfiles(client) : Promise.resolve([]); +): Promise<{ profiles: DraftCloudProfile[]; environments: DraftEnvironment[] }> { + return admin ? requestPlaceCatalog(client) : Promise.resolve({ profiles: [], environments: [] }); } diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index f00f32261541..94444fd83745 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -3,14 +3,17 @@ import type { EnvironmentsListResult } from "../../../../packages/gateway-protoc import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; -import { readDraftCloudProfiles } from "./discovery.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; +import { readDraftCloudProfiles, readDraftEnvironments } from "./discovery.ts"; -export async function requestCloudProfiles( +export async function requestPlaceCatalog( client: Pick, -): Promise { +): Promise<{ profiles: DraftCloudProfile[]; environments: DraftEnvironment[] }> { const result = await client.request("environments.list", {}); - return readDraftCloudProfiles(result?.profiles); + return { + profiles: readDraftCloudProfiles(result?.profiles), + environments: readDraftEnvironments(result?.environments), + }; } type SessionMenuItemOptions = { diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 453f775c6fcc..91a642c43a1e 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { readDraftCloudProfiles, readDraftNodes } from "./discovery.ts"; +import { readDraftCloudProfiles, readDraftEnvironments, readDraftNodes } from "./discovery.ts"; describe("readDraftNodes", () => { it("ignores non-record array entries without throwing", () => { @@ -29,21 +29,45 @@ describe("readDraftNodes", () => { ]); }); }); - describe("readDraftCloudProfiles", () => { it("keeps closed profile summaries in stable order", () => { expect( readDraftCloudProfiles([ null, 42, - { id: " zeta ", providerId: " static-ssh ", settings: { token: "hidden" } }, + { + id: " zeta ", + providerId: " static-ssh ", + settings: { token: "hidden" }, + }, { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, { id: "", providerId: "crabbox" }, { id: "missing-provider" }, ]), ).toEqual([ { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, { id: "zeta", providerId: "static-ssh" }, ]); }); }); + +describe("readDraftEnvironments", () => { + it("keeps the closed environment types while rejecting malformed entries", () => { + expect( + readDraftEnvironments([ + { id: "gateway", type: "local", label: "Gateway" }, + { id: "node:macbook", type: "node" }, + { id: "worker:aws", type: "worker" }, + { id: "future", type: "future" }, + { id: "", type: "node" }, + { id: "missing-type" }, + ]), + ).toEqual([ + { id: "gateway", type: "local" }, + { id: "node:macbook", type: "node" }, + { id: "worker:aws", type: "worker" }, + ]); + }); +}); diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index d8ff8f454286..153a74549d3b 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -32,6 +32,11 @@ export type DraftCloudProfile = { providerId: string; }; +export type DraftEnvironment = { + id: string; + type: "local" | "node" | "worker"; +}; + export type BrowserTarget = { nodeId: string; label: string }; export function readDraftNodes(value: unknown): DraftNode[] { @@ -83,14 +88,40 @@ export function readDraftNodes(value: unknown): DraftNode[] { export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] { return (Array.isArray(value) ? value : []) - .flatMap((raw) => { + .flatMap((raw) => { if (!raw || typeof raw !== "object") { return []; } - const profile = raw as { id?: unknown; providerId?: unknown }; + const profile = raw as { + id?: unknown; + providerId?: unknown; + }; const id = normalizeOptionalString(profile.id); const providerId = normalizeOptionalString(profile.providerId); - return id && providerId ? [{ id, providerId }] : []; + if (!id || !providerId) { + return []; + } + return [{ id, providerId }]; + }) + .toSorted((left, right) => left.id.localeCompare(right.id)); +} + +export function readDraftEnvironments(value: unknown): DraftEnvironment[] { + return (Array.isArray(value) ? value : []) + .flatMap((raw) => { + if (!raw || typeof raw !== "object") { + return []; + } + const environment = raw as { + id?: unknown; + type?: unknown; + }; + const id = normalizeOptionalString(environment.id); + const type = normalizeOptionalString(environment.type); + if (!id || (type !== "local" && type !== "node" && type !== "worker")) { + return []; + } + return [{ id, type }]; }) .toSorted((left, right) => left.id.localeCompare(right.id)); } diff --git a/ui/src/pages/new-session/draft-gateway-state.ts b/ui/src/pages/new-session/draft-gateway-state.ts index e4fcd67afbfc..f10b124d9e51 100644 --- a/ui/src/pages/new-session/draft-gateway-state.ts +++ b/ui/src/pages/new-session/draft-gateway-state.ts @@ -11,7 +11,7 @@ import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import * as catalog from "./catalog-target.ts"; import { CLOUD_PROFILE_RETRY_DELAYS_MS, - discoverCloudProfiles, + discoverPlaceCatalog, selectProfiles, } from "./cloud-profile-discovery.ts"; import { @@ -19,7 +19,7 @@ import { resolveSubmissionOutcomeReason, type SubmissionOutcomeReason, } from "./cloud-recovery-state.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; import { discoverGatewayName } from "./gateway-name-discovery.ts"; import type { NewSessionRouteData } from "./location.ts"; import { @@ -66,6 +66,7 @@ type DraftGatewayCallbacks = { export class DraftGatewayState { private gatewayNameValue = ""; private cloudProfilesValue: DraftCloudProfile[] = []; + private environmentsValue: DraftEnvironment[] | null = null; private cloudProfilesReadyValue = false; private catalogRetryingValue = false; private gatewaySource: ApplicationContext["gateway"] | null = null; @@ -87,7 +88,10 @@ export class DraftGatewayState { private preferenceWrite: Promise = Promise.resolve(); private readonly gatewayNameTask: Task; - private readonly cloudProfileTask: Task; + private readonly cloudProfileTask: Task< + readonly unknown[], + { profiles: DraftCloudProfile[]; environments: DraftEnvironment[] } + >; constructor( host: ReactiveControllerHost, @@ -118,14 +122,16 @@ export class DraftGatewayState { this.gatewayRecoveryScopeValue, ] as const, task: ([client, _connectionEpoch, admin]) => - client ? discoverCloudProfiles(client, admin) : initialState, - onComplete: (profiles) => { + client ? discoverPlaceCatalog(client, admin) : initialState, + onComplete: (placeCatalog) => { this.resetCloudProfileRetry(); - this.applyCloudProfiles(profiles); + this.environmentsValue = placeCatalog.environments; + this.applyCloudProfiles(placeCatalog.profiles); this.cloudProfilesReadyValue = true; this.callbacks.requestUpdate(); }, onError: () => { + // Keep the last environment catalog across a transient client refresh on this Gateway. this.cloudProfilesValue = []; this.cloudProfilesReadyValue = false; this.scheduleCloudProfileRetry(); @@ -142,6 +148,10 @@ export class DraftGatewayState { return this.cloudProfilesValue; } + get environments(): readonly DraftEnvironment[] | null { + return this.environmentsValue; + } + get cloudProfilesReady(): boolean { return this.cloudProfilesReadyValue; } @@ -183,8 +193,9 @@ export class DraftGatewayState { const connected = snapshot.phase === "connected"; const firstBind = this.gatewaySource === null; const gatewayUrlChanged = !firstBind && this.gatewayUrlValue !== gateway.connection.gatewayUrl; + const gatewaySourceChanged = !firstBind && this.gatewaySource !== gateway; const identityChanged = - !firstBind && (this.gatewaySource !== gateway || this.gatewayClientValue !== snapshot.client); + !firstBind && (gatewaySourceChanged || this.gatewayClientValue !== snapshot.client); const connectionChanged = !firstBind && this.gatewayConnectedValue !== connected; const becameConnected = connected && (identityChanged || !this.gatewayConnectedValue); const recoveryScopeBecameReady = @@ -204,9 +215,10 @@ export class DraftGatewayState { this.callbacks.onVisibilityRetired(); } if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { + const ownerChanged = gatewaySourceChanged || gatewayUrlChanged || recoveryScope.changed; const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed; this.invalidateDiscovery( - gatewayIdentityChanged, + ownerChanged, resolveSubmissionOutcomeReason({ gatewayIdentityChanged, cloudDraftOwned: Boolean(this.read().pendingCloud.sessionKey), @@ -244,6 +256,9 @@ export class DraftGatewayState { this.gatewayNameValue = ""; this.cloudProfilesValue = []; this.cloudProfilesReadyValue = false; + if (resetHostSelection) { + this.environmentsValue = null; + } this.resetCloudProfileRetry(); this.callbacks.onInvalidate(resetHostSelection, submissionOutcome); this.callbacks.requestUpdate(); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 5c73aa02e6f4..7cd4848659f8 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -370,6 +370,7 @@ class NewSessionPage extends OpenClawLightDomElement { projectCloneError: this.browser.projectCloneError, projectId: this.place.projectId, execNodes: this.place.isAdmin() ? execNodes : [], + environments: this.place.isAdmin() ? this.gateway.environments : [], gatewayName: this.gateway.gatewayName, cloudProfiles: this.place.isAdmin() ? cloudProfiles : [], cloudProfileId: this.place.cloudProfileId, diff --git a/ui/src/pages/new-session/place-picker-sections.ts b/ui/src/pages/new-session/place-picker-sections.ts new file mode 100644 index 000000000000..2059e8bac790 --- /dev/null +++ b/ui/src/pages/new-session/place-picker-sections.ts @@ -0,0 +1,25 @@ +import type { DraftCloudProfile, DraftEnvironment, DraftNode } from "./discovery.ts"; + +export function resolvePlacePickerSections(params: { + environments: readonly DraftEnvironment[] | null; + execNodes: readonly DraftNode[]; + cloudProfiles: readonly DraftCloudProfile[]; +}): { deviceNodes: DraftNode[]; cloudProfiles: DraftCloudProfile[] } { + const environmentById = params.environments + ? new Map(params.environments.map((environment) => [environment.id, environment])) + : null; + return { + deviceNodes: params.execNodes.filter((node) => { + if (!node.connected || !node.canExec) { + return false; + } + if (environmentById === null || environmentById.size === 0) { + // Missing and empty catalogs preserve the established live-node fallback. + return true; + } + const environment = environmentById.get(`node:${node.nodeId}`); + return environment?.type === "node"; + }), + cloudProfiles: [...params.cloudProfiles], + }; +} diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts index f5f88d5b1ca9..c09f2546fd12 100644 --- a/ui/src/pages/new-session/place-picker.test.ts +++ b/ui/src/pages/new-session/place-picker.test.ts @@ -1,5 +1,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { readDraftEnvironments } from "./discovery.ts"; +import { resolvePlacePickerSections } from "./place-picker-sections.ts"; import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts"; type PlaceSelectParams = Parameters[0]; @@ -24,6 +26,7 @@ function placeParams(overrides: Partial = {}): PlaceSelectPar projectCloneError: null, projectId: "", execNodes: [], + environments: null, gatewayName: "", cloudProfiles: [], cloudProfileId: "", @@ -160,3 +163,117 @@ describe("project picker", () => { expect(onCloneProject).toHaveBeenCalledWith(gitUrl); }); }); + +describe("Where picker", () => { + it("uses node presence until a non-empty authoritative environment catalog arrives", () => { + const execNodes = [ + { + nodeId: "usable", + displayName: "Usable", + connected: true, + canExec: true, + canBrowse: false, + }, + { + nodeId: "disconnected", + displayName: "Disconnected", + connected: false, + canExec: true, + canBrowse: false, + }, + { + nodeId: "no-exec", + displayName: "No exec", + connected: true, + canExec: false, + canBrowse: false, + }, + ]; + + expect( + resolvePlacePickerSections({ environments: null, execNodes, cloudProfiles: [] }).deviceNodes, + ).toEqual([execNodes[0]]); + expect( + resolvePlacePickerSections({ environments: [], execNodes, cloudProfiles: [] }).deviceNodes, + ).toEqual([execNodes[0]]); + }); + + it("groups usable places from environment types and the legacy node catalog", () => { + const container = document.createElement("div"); + const connectedExecNodes = [ + "macbook", + "worker", + "local", + "missing-environment", + "future-type", + ].map((nodeId) => ({ + nodeId, + displayName: nodeId, + connected: true, + canExec: true, + canBrowse: false, + })); + render( + renderPlaceSelect( + placeParams({ + folder: "", + execNodes: [ + ...connectedExecNodes, + { + nodeId: "offline", + displayName: "Offline Mac", + connected: false, + canExec: false, + canBrowse: false, + }, + { + nodeId: "no-exec", + displayName: "No exec", + connected: true, + canExec: false, + canBrowse: false, + }, + ], + environments: readDraftEnvironments([ + { id: "gateway", type: "local" }, + { id: "node:macbook", type: "node" }, + { id: "node:worker", type: "worker" }, + { id: "node:local", type: "local" }, + { id: "node:offline", type: "node" }, + { id: "node:no-exec", type: "node" }, + { id: "node:future-type", type: "future" }, + ]), + gatewayName: "Studio", + cloudProfiles: [ + { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, + ], + worktreeAvailable: true, + showDestinations: true, + }), + ), + container, + ); + + const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) => + element.textContent?.trim(), + ); + expect(titles).toEqual(["Folder", "Projects", "Places", "This gateway", "Devices", "Cloud"]); + expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull(); + for (const nodeId of [ + "worker", + "local", + "missing-environment", + "future-type", + "offline", + "no-exec", + ]) { + expect(container.querySelector(`[data-value="node:${nodeId}"]`)).toBeNull(); + } + expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull(); + expect(container.querySelector('[data-value="cloud:legacy"]')).not.toBeNull(); + + const gateway = container.querySelector('[data-value="gateway"]'); + expect(gateway?.lastElementChild?.classList.contains("session-menu__check")).toBe(true); + }); +}); diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 7afe7c47cc5c..488048ec65b4 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -8,9 +8,16 @@ import type { import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { renderCloudProfileMenuItems, renderSessionMenuItem } from "./cloud-target.ts"; -import type { BrowserTarget, DraftBranches, DraftCloudProfile, DraftNode } from "./discovery.ts"; +import type { + BrowserTarget, + DraftBranches, + DraftCloudProfile, + DraftEnvironment, + DraftNode, +} from "./discovery.ts"; import { folderDisplayName } from "./path.ts"; import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts"; +import { resolvePlacePickerSections } from "./place-picker-sections.ts"; function parentFolderDisplayName(path: string): string | undefined { const trimmed = path.replace(/[\\/]+$/u, ""); @@ -177,6 +184,7 @@ export function renderPlaceSelect(params: { projectCloneError: string | null; projectId: string; execNodes: DraftNode[]; + environments: readonly DraftEnvironment[] | null; gatewayName: string; cloudProfiles: readonly DraftCloudProfile[]; cloudProfileId: string; @@ -248,6 +256,7 @@ export function renderPlaceSelect(params: { const activeProfile = params.cloudProfiles.find( (profile) => profile.id === params.cloudProfileId, ); + const { deviceNodes, cloudProfiles } = resolvePlacePickerSections(params); const gatewayLabel = params.gatewayName ? t("newSession.gatewayNamed", { name: params.gatewayName }) : t("newSession.gateway"); @@ -258,10 +267,16 @@ export function renderPlaceSelect(params: { : gatewayLabel; const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel; const effectiveFolder = folder || params.workspace; - const recentItems = params.recents.map((recent) => { + const recents = params.recents.filter( + (recent) => + recent.kind !== "folder" || + !recent.execNode || + deviceNodes.some((node) => node.nodeId === recent.execNode), + ); + const recentItems = recents.map((recent) => { const node = recent.kind === "folder" && recent.execNode - ? params.execNodes.find((candidate) => candidate.nodeId === recent.execNode) + ? deviceNodes.find((candidate) => candidate.nodeId === recent.execNode) : undefined; const recentLabel = params.showDestinations && node @@ -279,7 +294,7 @@ export function renderPlaceSelect(params: { ? `${recent.folder}${recent.execNode ? ` · ${recent.execNode.slice(0, 8)}` : ""}` : recent.projectId, ]); - const nodeSuffixes = disambiguate(params.execNodes, (node) => node.displayName, [ + const nodeSuffixes = disambiguate(deviceNodes, (node) => node.displayName, [ (node) => node.modelIdentifier, (node) => node.remoteIp, (node) => node.nodeId.slice(0, 8), @@ -469,7 +484,7 @@ export function renderPlaceSelect(params: { ${t("newSession.projectsAdminHint")}
` : nothing} - ${params.recents.length > 0 + ${recents.length > 0 ? html`
${t("newSession.recentFolders")}
${recentItems.map((recent, index) => { @@ -519,6 +534,7 @@ export function renderPlaceSelect(params: { ${params.showDestinations ? html`
${t("newSession.places")}
+
${t("newSession.thisGateway")}
${renderSessionMenuItem( { value: "gateway", @@ -529,24 +545,34 @@ export function renderPlaceSelect(params: { }, params.submitting, )} - ${params.execNodes.map((node, index) => - renderSessionMenuItem( - { - value: `node:${node.nodeId}`, - label: node.displayName, - icon: isPhoneFamily(node.deviceFamily) - ? icons.monitorSmartphone - : icons.monitor, - sub: nodeSuffixes[index], - checked: params.execNode === node.nodeId, - title: nodeTooltip(node), - onSelect: () => params.onSelectExecNode(node.nodeId), - }, - params.submitting, - ), - )} + ${deviceNodes.length > 0 + ? html` +
${t("tabs.devices")}
+ ${deviceNodes.map((node, index) => + renderSessionMenuItem( + { + value: `node:${node.nodeId}`, + label: node.displayName, + icon: isPhoneFamily(node.deviceFamily) + ? icons.monitorSmartphone + : icons.monitor, + sub: nodeSuffixes[index], + checked: params.execNode === node.nodeId, + title: nodeTooltip(node), + onSelect: () => params.onSelectExecNode(node.nodeId), + }, + params.submitting, + ), + )} + ` + : nothing} + ${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile) + ? html`
+ ${t("newSession.cloud")} +
` + : nothing} ${renderCloudProfileMenuItems({ - profiles: params.cloudProfiles, + profiles: cloudProfiles, selectedId: params.cloudProfileId, submitting: params.submitting, icon: icons.server, diff --git a/ui/src/pages/new-session/recent-places.test.ts b/ui/src/pages/new-session/recent-places.test.ts index b66ddd247b6c..cdcbf0cd3e2d 100644 --- a/ui/src/pages/new-session/recent-places.test.ts +++ b/ui/src/pages/new-session/recent-places.test.ts @@ -4,16 +4,17 @@ import { isKnownWorkspacePath } from "./path.ts"; import { recentPlaces } from "./recent-places.ts"; describe("recentPlaces", () => { - it("deduplicates, caps, skips the workspace and unknown nodes, and prefers exec cwd", () => { + it("deduplicates locations, caps newest-first, and keeps matching basenames on distinct runners", () => { expect( recentPlaces( [ { execCwd: "/workspace" }, { execCwd: "/node/repo", execNode: "macbook" }, { execCwd: "/node/repo", execNode: "macbook" }, + { execCwd: "/gateway/repo" }, { execCwd: "/gone/repo", execNode: "retired" }, { - execCwd: "/preferred/repo", + execCwd: "/preferred/selected", worktree: { repoRoot: "/ignored/worktree" }, }, { worktree: { repoRoot: "/worktree/one" } }, @@ -28,9 +29,9 @@ describe("recentPlaces", () => { ), ).toEqual([ { folder: "/node/repo", execNode: "macbook" }, - { folder: "/preferred/repo", execNode: "" }, + { folder: "/gateway/repo", execNode: "" }, + { folder: "/preferred/selected", execNode: "" }, { folder: "/worktree/one", execNode: "" }, - { folder: "/cwd/two", execNode: "" }, ]); }); diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index ea702915aa6a..b5b873551ee4 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -286,7 +286,6 @@ openclaw-chat-pane { } } -.chat-pane__cloud, .chat-pane__incognito { display: inline-flex; flex: 0 0 auto; @@ -294,7 +293,6 @@ openclaw-chat-pane { color: var(--muted); } -.chat-pane__cloud svg, .chat-pane__incognito svg, .chat-pane__workspace-chip svg { width: 14px; @@ -328,6 +326,42 @@ openclaw-chat-pane { min-width: 0; } +.chat-pane__placement-menu { + flex: 0 0 auto; +} + +.chat-pane__placement-menu::part(menu) { + width: 250px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: 8px; + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + +.chat-pane__placement-chip { + padding: 2px; + border: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 11px; + cursor: var(--cursor-action); +} + +.chat-pane__placement-chip:hover, +.chat-pane__placement-chip:focus-visible { + color: var(--text); + outline: none; +} + +.chat-pane__placement-state { + padding: 6px 8px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + .chat-pane__gateway-menu { flex: 0 1 auto; min-width: 0; From 1da8fffbcb1bba73172f0898a7b420e217ae5f79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:23:34 -0700 Subject: [PATCH 057/165] improve: speed up secrets audit test shard (#122504) * test: speed up secrets audit coverage * test: complete daemon plugin fixtures --------- Co-authored-by: Amp --- src/commands/daemon-install-helpers.test.ts | 2 + src/secrets/audit.test.ts | 280 ++++++------------ src/secrets/audit.ts | 13 +- src/secrets/channel-contract-api.ts | 3 +- ...get-registry-data.current-snapshot.test.ts | 111 ++++++- src/secrets/target-registry-data.ts | 25 +- src/secrets/target-registry-query.ts | 74 +++-- src/secrets/target-registry.fast-path.test.ts | 86 +++++- src/secrets/target-registry.test.ts | 16 + 9 files changed, 385 insertions(+), 225 deletions(-) diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index ebd079ead2ea..4cb157bf25c6 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -235,6 +235,7 @@ async function buildPluginConfigExecSecretRefPlan(home: string) { id: "acme-secrets", origin: "global", rootDir: pluginRoot, + channels: [], secretProviderIntegrations: { "secret-store": { source: "exec", @@ -252,6 +253,7 @@ async function buildPluginConfigExecSecretRefPlan(home: string) { { id: "acme-plugin", origin: "global", + channels: [], configContracts: { secretInputs: { paths: [{ path: "apiKey", expected: "string" }], diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index d6a4409d937a..f0deb68263ff 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveAuthProfileDatabasePath, @@ -72,6 +72,7 @@ async function writeExecSecretsAuditConfig(params: { baseUrl: string; modelId: string; modelName: string; + headerRefId?: string; }>; }) { await writeJsonFile(params.fixture.configPath, { @@ -98,6 +99,17 @@ async function writeExecSecretsAuditConfig(params: { provider: "execmain", id: `providers/${provider.id}/apiKey`, }, + ...(provider.headerRefId + ? { + headers: { + Authorization: { + source: "exec", + provider: "execmain", + id: provider.headerRefId, + }, + }, + } + : {}), models: [{ id: provider.modelId, name: provider.modelName }], }, ]), @@ -216,18 +228,6 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { describe("secrets audit", () => { let fixture: AuditFixture; - beforeAll(async () => { - const warmFixture = await createAuditFixture(); - try { - await writeJsonFile(warmFixture.configPath, {}); - await runSecretsAudit({ env: warmFixture.env }); - } finally { - closeOpenClawAgentDatabasesForTest(); - closeOpenClawStateDatabaseForTest(); - await fs.rm(warmFixture.rootDir, { recursive: true, force: true }); - } - }); - async function writeModelsProvider( overrides: Partial<{ apiKey: unknown; @@ -413,7 +413,7 @@ describe("secrets audit", () => { logPath: execLogPath, values: { "providers/openai/apiKey": "value:providers/openai/apiKey", - "providers/moonshot/apiKey": "value:providers/moonshot/apiKey", + "providers/openai/headers/Authorization": "value:providers/openai/headers/Authorization", }, }); await writeExecSecretsAuditConfig({ @@ -425,18 +425,14 @@ describe("secrets audit", () => { baseUrl: "https://api.openai.com/v1", modelId: "gpt-5", modelName: "gpt-5", - }, - { - id: "moonshot", - baseUrl: "https://api.moonshot.cn/v1", - modelId: "moonshot-v1-8k", - modelName: "moonshot-v1-8k", + headerRefId: "providers/openai/headers/Authorization", }, ], }); const report = await runSecretsAudit({ env: fixture.env, allowExec: true }); expect(report.summary.unresolvedRefCount).toBe(0); + expect(report.resolution.refsChecked).toBe(2); const callLog = await fs.readFile(execLogPath, "utf8"); const callCount = countNonEmptyLines(callLog); @@ -480,14 +476,15 @@ describe("secrets audit", () => { baseUrl: "https://api.openai.com/v1", api: "openai-completions", apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" }, + headers: { + Authorization: { + source: "exec", + provider: "execmain", + id: "providers/openai/headers/Authorization", + }, + }, models: [{ id: "gpt-5", name: "gpt-5" }], }, - moonshot: { - baseUrl: "https://api.moonshot.cn/v1", - api: "openai-completions", - apiKey: { source: "exec", provider: "execmain", id: "providers/moonshot/apiKey" }, - models: [{ id: "moonshot-v1-8k", name: "moonshot-v1-8k" }], - }, }, }, }, @@ -545,24 +542,27 @@ describe("secrets audit", () => { }); }); - it("does not flag models.json marker values as plaintext", async () => { - await writeModelsProvider(); + it("exempts only known models.json apiKey markers from plaintext audit", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + knownMarker: { + apiKey: OPENAI_API_KEY_MARKER, + }, + arbitraryAllCaps: { + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }); const report = await runSecretsAudit({ env: fixture.env }); expectModelsFinding(report, { code: "PLAINTEXT_FOUND", - jsonPath: "providers.openai.apiKey", + jsonPath: "providers.knownMarker.apiKey", present: false, }); - }); - - it("flags arbitrary all-caps models.json apiKey values as plaintext", async () => { - await writeModelsProvider({ apiKey: "ALLCAPS_SAMPLE" }); // pragma: allowlist secret - - const report = await runSecretsAudit({ env: fixture.env }); expectModelsFinding(report, { code: "PLAINTEXT_FOUND", - jsonPath: "providers.openai.apiKey", + jsonPath: "providers.arbitraryAllCaps.apiKey", }); }); @@ -660,53 +660,33 @@ describe("secrets audit", () => { expect(report.filesScanned).toContain(externalModelsPath); }); - it("does not flag $VAR shorthand env refs in auth profiles as plaintext", async () => { + it("classifies auth profile env shorthands as refs with or without explicit keyRef", async () => { writeAuthStore(fixture, { version: 1, profiles: { - "openai:default": { + "openai:dollar": { type: "api_key", provider: "openai", key: "$OPENAI_API_KEY", // pragma: allowlist secret }, - }, - }); - - const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => entry.code === "PLAINTEXT_FOUND" && entry.file === fixture.authStorePath, - ), - ).toBe(false); - }); - - it("does not flag ${VAR} env refs in auth profiles as plaintext", async () => { - writeAuthStore(fixture, { - version: 1, - profiles: { - "openai:default": { + "openai:braced": { type: "api_key", provider: "openai", key: "${OPENAI_API_KEY}", // pragma: allowlist secret }, - }, - }); - - const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => entry.code === "PLAINTEXT_FOUND" && entry.file === fixture.authStorePath, - ), - ).toBe(false); - }); - - it("still flags auth profile plaintext when an explicit ref is also configured", async () => { - writeAuthStore(fixture, { - version: 1, - profiles: { - "openai:default": { + "openai:dollar-with-ref": { + type: "api_key", + provider: "openai", + key: "$OPENAI_API_KEY", // pragma: allowlist secret + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + "openai:braced-with-ref": { + type: "api_key", + provider: "openai", + key: "${OPENAI_API_KEY}", // pragma: allowlist secret + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + "openai:plaintext-with-ref": { type: "api_key", provider: "openai", key: "sk-leftover-plaintext", // pragma: allowlist secret @@ -716,46 +696,13 @@ describe("secrets audit", () => { }); const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.authStorePath && - entry.jsonPath === "profiles.openai:default.key", - ), - ).toBe(true); + const authPlaintextPaths = report.findings + .filter((entry) => entry.code === "PLAINTEXT_FOUND" && entry.file === fixture.authStorePath) + .map((entry) => entry.jsonPath); + expect(authPlaintextPaths).toEqual(["profiles.openai:plaintext-with-ref.key"]); }); - it.each(["$OPENAI_API_KEY", "${OPENAI_API_KEY}"])( - "does not flag %s auth profile env refs when an explicit ref is also configured", - async (value) => { - writeAuthStore(fixture, { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: value, - keyRef: { source: "env", id: "OPENAI_API_KEY" }, - }, - }, - }); - - const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.authStorePath && - entry.jsonPath === "profiles.openai:default.key", - ), - ).toBe(false); - }, - ); - - it("does not flag non-sensitive routing headers in openclaw config", async () => { + it("exempts direct routing headers but audits request headers in openclaw config", async () => { await writeJsonFile(fixture.configPath, { models: { providers: { @@ -766,32 +713,6 @@ describe("secrets audit", () => { headers: { "X-Proxy-Region": "us-west", }, - models: [{ id: "gpt-5", name: "gpt-5" }], - }, - }, - }, - }); - - const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.configPath && - entry.jsonPath === "models.providers.openai.headers.X-Proxy-Region", - ), - ).toBe(false); - }); - - it("keeps request headers in openclaw config covered by plaintext audit", async () => { - await writeJsonFile(fixture.configPath, { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - api: "openai-completions", - apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, request: { headers: { "X-Proxy-Region": "us-west", @@ -804,6 +725,15 @@ describe("secrets audit", () => { }); const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.configPath && + entry.jsonPath === "models.providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); expect( hasFinding( report, @@ -815,60 +745,36 @@ describe("secrets audit", () => { ).toBe(true); }); - it("does not flag openclaw.json model provider apiKey marker values as plaintext", async () => { - await writeJsonFile(fixture.configPath, { - models: { - providers: { - lmstudio: { - baseUrl: "http://127.0.0.1:1234/v1", - api: "openai-completions", - apiKey: "lmstudio-local", - models: [{ id: "lmstudio-local", name: "lmstudio-local" }], - }, - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - apiKey: "ollama-local", - models: [{ id: "ollama-local", name: "ollama-local" }], - }, - openai: { - baseUrl: "https://api.openai.com/v1", - api: "openai-completions", - apiKey: "sk-real-plaintext", - models: [{ id: "gpt-5", name: "gpt-5" }], + it("exempts only known openclaw.json model provider apiKey markers", async () => { + for (const { apiKey, isPlaintext } of [ + { apiKey: "lmstudio-local", isPlaintext: false }, + { apiKey: "ollama-local", isPlaintext: false }, + { apiKey: "sk-real-plaintext", isPlaintext: true }, + ]) { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, }, }, - }, - }); + }); - const report = await runSecretsAudit({ env: fixture.env }); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.configPath && - entry.jsonPath === "models.providers.lmstudio.apiKey", - ), - ).toBe(false); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.configPath && - entry.jsonPath === "models.providers.ollama.apiKey", - ), - ).toBe(false); - expect( - hasFinding( - report, - (entry) => - entry.code === "PLAINTEXT_FOUND" && - entry.file === fixture.configPath && - entry.jsonPath === "models.providers.openai.apiKey", - ), - ).toBe(true); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.configPath && + entry.jsonPath === "models.providers.openai.apiKey", + ), + ).toBe(isPlaintext); + } }); it("scans .env in legacy .clawdbot state directory via automatic fallback", async () => { diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 698b84da1679..ddda19a6b629 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -194,9 +194,10 @@ function collectConfigSecrets(params: { config: OpenClawConfig; configPath: string; collector: AuditCollector; + env: NodeJS.ProcessEnv; }): void { const defaults = params.config.secrets?.defaults; - for (const target of discoverConfigSecretTargets(params.config)) { + for (const target of discoverConfigSecretTargets(params.config, { env: params.env })) { if (!target.entry.includeInAudit) { continue; } @@ -236,14 +237,7 @@ function collectConfigSecrets(params: { } continue; } - - if (isNonSecretHeader) { - continue; - } - if (isModelMarker) { - continue; - } - if (!hasPlaintext) { + if (isNonSecretHeader || isModelMarker || !hasPlaintext) { continue; } addFinding(params.collector, { @@ -670,6 +664,7 @@ export async function runSecretsAudit( config, configPath, collector, + env, }); for (const agentDir of listAuthProfileStoreAgentDirs(config, stateDir)) { collectAuthStoreSecrets({ diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index c97a2d00a17b..882a05243eeb 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -196,9 +196,10 @@ export function loadChannelSecretContractApi(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; loadablePluginOrigins?: ReadonlyMap; + bundledOnly?: boolean; }): BundledChannelSecretContractApi | undefined { const bundled = loadBundledChannelSecretContractApi(params.channelId); - if (bundled) { + if (bundled || params.bundledOnly) { return bundled; } // External contracts are considered only after bundled artifacts so core channels keep their diff --git a/src/secrets/target-registry-data.current-snapshot.test.ts b/src/secrets/target-registry-data.current-snapshot.test.ts index ca9afec9e74b..b4115c464e93 100644 --- a/src/secrets/target-registry-data.current-snapshot.test.ts +++ b/src/secrets/target-registry-data.current-snapshot.test.ts @@ -1,9 +1,18 @@ /** Tests target-registry data built from the current runtime snapshot. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; const metadataMocks = vi.hoisted(() => ({ listBundledPluginMetadata: vi.fn(), - resolvePluginMetadataSnapshot: vi.fn(() => ({ plugins: [] })), + resolvePluginMetadataSnapshot: vi.fn< + (params?: { config?: { plugins?: { load?: { paths?: string[] } } } }) => { + plugins: never[]; + } + >(() => ({ plugins: [] })), })); vi.mock("../plugins/bundled-plugin-metadata.js", () => ({ @@ -14,6 +23,37 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: metadataMocks.resolvePluginMetadataSnapshot, })); +function writeChannelContract(params: { + channelId: string; + pluginId: string; + targetId: string; + ownership: "channelConfigs" | "channels"; +}) { + const rootDir = makeTrackedTempDir("openclaw-target-registry-channel", tempDirs); + fs.writeFileSync( + path.join(rootDir, "secret-contract-api.cjs"), + `module.exports = { secretTargetRegistryEntries: [${JSON.stringify({ + id: params.targetId, + targetType: params.targetId, + configFile: "openclaw.json", + pathPattern: params.targetId, + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + })}] };`, + "utf8", + ); + return { + id: params.pluginId, + origin: "config", + channels: params.ownership === "channels" ? [params.channelId] : [], + channelConfigs: params.ownership === "channelConfigs" ? { [params.channelId]: {} } : {}, + rootDir, + }; +} + describe("getSecretTargetRegistry metadata reuse", () => { beforeEach(() => { vi.resetModules(); @@ -25,6 +65,10 @@ describe("getSecretTargetRegistry metadata reuse", () => { metadataMocks.resolvePluginMetadataSnapshot.mockReturnValue({ plugins: [] }); }); + afterEach(() => { + cleanupTrackedTempDirs(tempDirs); + }); + it("allows configless runtime targets to reuse the lifecycle workspace", async () => { const { getSecretTargetRegistry } = await import("./target-registry-data.js"); @@ -96,4 +140,67 @@ describe("getSecretTargetRegistry metadata reuse", () => { expect(ids).toContain("channels.qqbot.clientSecret"); expect(ids).toContain("channels.qqbot.accounts.*.clientSecret"); }); + + it("builds config-scoped registries independently instead of reusing the singleton", async () => { + metadataMocks.resolvePluginMetadataSnapshot.mockImplementation( + (params?: { config?: { plugins?: { load?: { paths?: string[] } } } }) => { + const pluginId = params?.config?.plugins?.load?.paths?.[0] ?? "missing"; + return { + plugins: [ + { + id: pluginId, + origin: "config", + channels: [], + configContracts: { + secretInputs: { paths: [{ path: "credentials.token" }] }, + }, + }, + ], + } as never; + }, + ); + const { getSecretTargetRegistry } = await import("./target-registry-data.js"); + const firstConfig = { plugins: { load: { paths: ["first-plugin"] }, entries: {} } }; + const secondConfig = { plugins: { load: { paths: ["second-plugin"] }, entries: {} } }; + + const firstIds = getSecretTargetRegistry({ config: firstConfig, env: {} }).map( + (entry) => entry.id, + ); + const secondIds = getSecretTargetRegistry({ config: secondConfig, env: {} }).map( + (entry) => entry.id, + ); + + expect(firstIds).toContain("plugins.entries.first-plugin.config.credentials.token"); + expect(firstIds).not.toContain("plugins.entries.second-plugin.config.credentials.token"); + expect(secondIds).toContain("plugins.entries.second-plugin.config.credentials.token"); + expect(secondIds).not.toContain("plugins.entries.first-plugin.config.credentials.token"); + }); + + it("loads channel contracts from every supported ownership field", async () => { + const records = [ + writeChannelContract({ + channelId: "custom", + pluginId: "custom-primary", + targetId: "channels.custom.primaryToken", + ownership: "channels", + }), + writeChannelContract({ + channelId: "custom", + pluginId: "custom-secondary", + targetId: "channels.custom.secondaryToken", + ownership: "channelConfigs", + }), + ]; + metadataMocks.resolvePluginMetadataSnapshot.mockReturnValue({ plugins: records } as never); + const { getSecretTargetRegistry } = await import("./target-registry-data.js"); + + const ids = getSecretTargetRegistry({ + config: { plugins: { load: { paths: records.map((record) => record.rootDir) } } }, + env: {}, + }).map((entry) => entry.id); + + expect(ids).toEqual( + expect.arrayContaining(["channels.custom.primaryToken", "channels.custom.secondaryToken"]), + ); + }); }); diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 9d7648ffb519..8e4a993e2646 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,4 +1,5 @@ /** Builds the static and plugin-derived registry of secret migration targets. */ +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js"; @@ -92,10 +93,6 @@ function listChannelSecretTargetRegistryEntries( const entries: SecretTargetRegistryEntry[] = []; for (const record of channelPlugins) { - const channelIds = record.channels; - if (channelIds.length === 0) { - continue; - } try { const contractApi = loadChannelSecretContractApiForRecord(record); entries.push(...(contractApi?.secretTargetRegistryEntries ?? [])); @@ -445,15 +442,23 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ let cachedSecretTargetRegistry: SecretTargetRegistryEntry[] | null = null; function loadSecretTargetRegistryFromPluginMetadata(params: { + config?: OpenClawConfig; env: NodeJS.ProcessEnv; preferPersisted?: boolean; }): SecretTargetRegistryEntry[] { const plugins = resolvePluginMetadataSnapshot({ + ...(params.config !== undefined ? { config: params.config } : {}), env: params.env, allowWorkspaceScopedCurrent: true, ...(params.preferPersisted !== undefined ? { preferPersisted: params.preferPersisted } : {}), }).plugins; - const channelPlugins = plugins.filter((record) => record.channels.length > 0); + const channelPlugins = plugins.filter( + (record) => + record.channels.length > 0 || + Object.keys(record.channelConfigs ?? {}).length > 0 || + Boolean(record.channelCatalogMeta?.id) || + Boolean(record.packageChannel?.id), + ); // Installed/workspace plugins own secret targets exactly like bundled ones // (#104320: the Exa split moved web providers out of bundled origin and their // targets vanished from the gateway's known-target registry). Entries stay @@ -487,6 +492,8 @@ export function getCoreSecretTargetRegistry(): SecretTargetRegistryEntry[] { /** Returns the process-cached registry including bundled plugin/channel metadata. */ /** Returns core plus plugin/channel secret target registry entries for the current metadata view. */ export function getSecretTargetRegistry(params?: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; sourceTree?: boolean; }): SecretTargetRegistryEntry[] { if (params?.sourceTree) { @@ -499,6 +506,14 @@ export function getSecretTargetRegistry(params?: { preferPersisted: false, }); } + if (params?.config) { + // Config-scoped plugin roots and policy are not process-stable. Compile these registries per + // request so one config cannot poison discovery for a later config in the same process. + return loadSecretTargetRegistryFromPluginMetadata({ + config: params.config, + env: params.env ?? process.env, + }); + } if (cachedSecretTargetRegistry) { return cachedSecretTargetRegistry; } diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 2b41bb60d0f1..907177e61dc5 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -78,18 +78,15 @@ function buildConfigTargetIdIndex( return byId; } -function getCompiledSecretTargetRegistryState() { - if (compiledSecretTargetRegistryState) { - return compiledSecretTargetRegistryState; - } - const compiledSecretTargetRegistry = getSecretTargetRegistry().map(compileTargetRegistryEntry); +function compileSecretTargetRegistryState(registry: SecretTargetRegistryEntry[]) { + const compiledSecretTargetRegistry = registry.map(compileTargetRegistryEntry); const openClawCompiledSecretTargets = compiledSecretTargetRegistry.filter( (entry) => entry.configFile === "openclaw.json", ); const authProfilesCompiledSecretTargets = compiledSecretTargetRegistry.filter( (entry) => entry.configFile === "auth-profiles.json", ); - compiledSecretTargetRegistryState = { + return { authProfilesCompiledSecretTargets, authProfilesTargetsById: buildConfigTargetIdIndex(authProfilesCompiledSecretTargets), compiledSecretTargetRegistry, @@ -98,9 +95,20 @@ function getCompiledSecretTargetRegistryState() { openClawTargetsById: buildConfigTargetIdIndex(openClawCompiledSecretTargets), targetsByType: buildTargetTypeIndex(compiledSecretTargetRegistry), }; +} + +function getCompiledSecretTargetRegistryState() { + if (compiledSecretTargetRegistryState) { + return compiledSecretTargetRegistryState; + } + compiledSecretTargetRegistryState = compileSecretTargetRegistryState(getSecretTargetRegistry()); return compiledSecretTargetRegistryState; } +function getConfiguredSecretTargetRegistryState(config: OpenClawConfig, env: NodeJS.ProcessEnv) { + return compileSecretTargetRegistryState(getSecretTargetRegistry({ config, env })); +} + function getCompiledCoreOpenClawTargetState() { if (compiledCoreOpenClawTargetState) { return compiledCoreOpenClawTargetState; @@ -176,10 +184,31 @@ function configHasPluginEntries(config: OpenClawConfig): boolean { function getConfiguredChannelOpenClawTargets( config: OpenClawConfig, -): CompiledTargetRegistryEntry[] { - return Object.keys(config.channels ?? {}).flatMap( - (channelId) => getCompiledChannelOpenClawTargets(channelId) ?? [], - ); + env: NodeJS.ProcessEnv, +): CompiledTargetRegistryEntry[] | null { + const entries: CompiledTargetRegistryEntry[] = []; + for (const channelId of Object.keys(config.channels ?? {})) { + if (channelId === "defaults" || channelId === "modelByChannel" || channelId === "tools") { + continue; + } + const contract = loadChannelSecretContractApi({ + channelId, + config, + env, + bundledOnly: true, + }); + if (!contract) { + // External/custom channels may have multiple manifest owners. Only the full registry can + // prove their target set is complete; a config-scoped first-contract lookup cannot. + return null; + } + entries.push( + ...(contract.secretTargetRegistryEntries + ?.filter((entry) => entry.configFile === "openclaw.json") + .map(compileTargetRegistryEntry) ?? []), + ); + } + return entries; } function resolveDiscoveryEntries(params: { @@ -463,13 +492,12 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve return null; } -/** - * Discovers configured secret-bearing values in openclaw.json using the full registry. - */ +/** Discovers configured secret-bearing values in openclaw.json. */ export function discoverConfigSecretTargets( config: OpenClawConfig, + options: { env?: NodeJS.ProcessEnv } = {}, ): DiscoveredConfigSecretTarget[] { - return discoverConfigSecretTargetsByIds(config); + return discoverConfigSecretTargetsByIds(config, undefined, options); } /** @@ -478,25 +506,33 @@ export function discoverConfigSecretTargets( export function discoverConfigSecretTargetsByIds( config: OpenClawConfig, targetIds?: Iterable, + options: { env?: NodeJS.ProcessEnv } = {}, ): DiscoveredConfigSecretTarget[] { + const env = options.env ?? process.env; const allowedTargetIds = normalizeAllowedTargetIds(targetIds); const coreState = getCompiledCoreOpenClawTargetState(); const hasOnlyCoreTargetIds = allowedTargetIds !== null && Array.from(allowedTargetIds).every((targetId) => coreState.knownTargetIds.has(targetId)); + const configuredChannelEntries = + !hasOnlyCoreTargetIds && !configHasPluginEntries(config) + ? getConfiguredChannelOpenClawTargets(config, env) + : null; const configuredEntries = hasOnlyCoreTargetIds ? coreState.openClawCompiledSecretTargets - : allowedTargetIds !== null && !configHasPluginEntries(config) - ? [...coreState.openClawCompiledSecretTargets, ...getConfiguredChannelOpenClawTargets(config)] + : configuredChannelEntries + ? [...coreState.openClawCompiledSecretTargets, ...configuredChannelEntries] : null; const configuredEntriesById = configuredEntries ? buildConfigTargetIdIndex(configuredEntries) : null; const canUseConfiguredEntries = configuredEntries !== null && - allowedTargetIds !== null && - Array.from(allowedTargetIds).every((targetId) => configuredEntriesById?.has(targetId)); - const registryState = canUseConfiguredEntries ? null : getCompiledSecretTargetRegistryState(); + (allowedTargetIds === null || + Array.from(allowedTargetIds).every((targetId) => configuredEntriesById?.has(targetId))); + const registryState = canUseConfiguredEntries + ? null + : getConfiguredSecretTargetRegistryState(config, env); const discoveryEntries = resolveDiscoveryEntries({ allowedTargetIds, defaultEntries: configuredEntries ?? registryState?.openClawCompiledSecretTargets ?? [], diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 3b187099344b..f3c44a60e26f 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -1,12 +1,16 @@ -/** Tests that explicit channel secret target lookup avoids broad manifest rediscovery. */ +/** Tests that configured-only secret target lookup avoids broad manifest rediscovery. */ import { beforeEach, describe, expect, it, vi } from "vitest"; const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({ loadPluginManifestRegistryMock: vi.fn(() => { - throw new Error("manifest registry should stay off the explicit channel target fast path"); + throw new Error("manifest registry should stay off configured-only target fast paths"); }), })); +const { getSecretTargetRegistryMock } = vi.hoisted(() => ({ + getSecretTargetRegistryMock: vi.fn(), +})); + const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { @@ -60,7 +64,38 @@ vi.mock("../plugins/public-surface-loader.js", () => ({ loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, })); +vi.mock("./target-registry-data.js", async (importOriginal) => { + const actual = await importOriginal(); + const channelTarget = (id: string) => ({ + id, + targetType: id, + configFile: "openclaw.json" as const, + pathPattern: id, + secretShape: "secret_input" as const, + expectedResolvedValue: "string" as const, + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }); + getSecretTargetRegistryMock.mockImplementation( + (params?: { config?: { plugins?: { load?: { paths?: string[] } } } }) => { + const loadPath = params?.config?.plugins?.load?.paths?.[0]; + const channelEntries = + loadPath === "/plugins/custom-next" + ? [channelTarget("channels.customNext.token")] + : [ + channelTarget("channels.qqbot.clientSecret"), + channelTarget("channels.custom.primaryToken"), + channelTarget("channels.custom.secondaryToken"), + ]; + return [...actual.getCoreSecretTargetRegistry(), ...channelEntries]; + }, + ); + return { ...actual, getSecretTargetRegistry: getSecretTargetRegistryMock }; +}); + import { + discoverConfigSecretTargets, discoverConfigSecretTargetsByIds, resolveConfigSecretTargetByPath, resolvePlanTargetAgainstRegistry, @@ -70,6 +105,7 @@ describe("secret target registry fast path", () => { beforeEach(() => { loadPluginManifestRegistryMock.mockClear(); loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + getSecretTargetRegistryMock.mockClear(); }); it("resolves bundled channel targets by explicit channel id without manifest scans", () => { @@ -111,6 +147,52 @@ describe("secret target registry fast path", () => { expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); + it("discovers all core and configured channel targets without loading plugin metadata", () => { + const targets = discoverConfigSecretTargets({ + gateway: { auth: { token: "gateway-token" } }, + channels: { telegram: { botToken: "telegram-token" } }, + }); + + const targetIds = targets.map((target) => target.entry.id); + expect(targetIds).toEqual( + expect.arrayContaining(["gateway.auth.token", "channels.telegram.botToken"]), + ); + expect(targetIds.some((targetId) => targetId.startsWith("plugins.entries."))).toBe(false); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); + + it("uses the complete registry for configured external and custom channels", () => { + const env = { HOME: "/audit-home" }; + const config = { + plugins: { load: { paths: ["/plugins/custom"] }, entries: {} }, + channels: { + qqbot: { clientSecret: "qqbot-secret" }, + custom: { + primaryToken: "primary-secret", + secondaryToken: "secondary-secret", + }, + }, + }; + const targets = discoverConfigSecretTargets(config, { env }); + + expect(targets.map((target) => target.entry.id)).toEqual( + expect.arrayContaining([ + "channels.qqbot.clientSecret", + "channels.custom.primaryToken", + "channels.custom.secondaryToken", + ]), + ); + expect(getSecretTargetRegistryMock).toHaveBeenLastCalledWith({ config, env }); + + const nextConfig = { + plugins: { load: { paths: ["/plugins/custom-next"] }, entries: {} }, + channels: { customNext: { token: "next-secret" } }, + }; + const nextTargets = discoverConfigSecretTargets(nextConfig, { env }); + expect(nextTargets.map((target) => target.entry.id)).toContain("channels.customNext.token"); + expect(getSecretTargetRegistryMock).toHaveBeenLastCalledWith({ config: nextConfig, env }); + }); + it("resolves channel plan targets without loading plugin metadata", () => { const target = resolvePlanTargetAgainstRegistry({ type: "channels.telegram.botToken", diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index f2297b025589..5e38506d238c 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -8,6 +8,7 @@ import { } from "../test-utils/talk-test-provider.js"; import { getCoreSecretTargetRegistry } from "./target-registry-data.js"; import { + discoverConfigSecretTargets, discoverConfigSecretTargetsByIds, resolveConfigSecretTargetByPath, resolveSecretPlanTargetByPathCore, @@ -101,6 +102,21 @@ describe("secret target registry", () => { "apiKey", ]); expect(fetchTarget?.entry?.id).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); + + const configuredTargets = discoverConfigSecretTargets({ + plugins: { + entries: { + exa: { + config: { + webSearch: { apiKey: "configured-plugin-key" }, + }, + }, + }, + }, + } as OpenClawConfig); + expect(configuredTargets.map((entry) => entry.entry.id)).toContain( + "plugins.entries.exa.config.webSearch.apiKey", + ); }); it("derives bundled plugin SecretInput contract target paths from plugin manifests", () => { From b46181bfc0ca6f3f2422d32d831986daaa97d275 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:31:55 -0700 Subject: [PATCH 058/165] fix(agent): post-tool timeout does not replay completed tools (#122516) * fix(agent): prevent replay after post-tool timeout * fix(agent): narrow settled tool assistant evidence --- ...ete-turn.settled-tool-continuation.test.ts | 11 +- ...omplete-turn.settled-tool-recovery.test.ts | 29 +---- ....incomplete-turn.terminal-evidence.test.ts | 122 +++++++++++++++--- ...un.prompt-timeout-fallback.test-support.ts | 100 ++++++++++++++ .../run/assistant-failure.test.ts | 65 +++++++++- .../run/assistant-failure.ts | 39 +++--- .../run/attempt-recovery.ts | 62 +++++---- .../run/attempt-terminal-evidence.ts | 8 ++ .../run/incomplete-turn-classification.ts | 1 + .../run/incomplete-turn-recovery.ts | 83 ++++++++---- .../run/settled-turn-finalization.ts | 50 ++----- .../run/terminal-resolution.ts | 19 ++- 12 files changed, 426 insertions(+), 163 deletions(-) diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-continuation.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-continuation.test.ts index 046b690b3110..c115d3b7b99a 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-continuation.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-continuation.test.ts @@ -236,7 +236,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expectNoWarnMessageWith("settled post-tool turn lacked a final answer"); }); - it("records silent success when the settled-tool finalization completes empty", async () => { + it("surfaces an incomplete turn when a required settled-tool finalizer completes empty", async () => { const emptyStopAssistant = makeLastAssistant(); mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { @@ -261,16 +261,19 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { const result = await runEmbeddedAgent( makeRunParams("run-empty-stop-settled-tool-continuation-exhausted", { allowEmptyAssistantReplyAsSilent: true, + terminalReplyExpectation: "required", }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(result.payloads).toBeUndefined(); - expect(result.meta.error).toBeUndefined(); + expect(result.payloads?.[0]).toMatchObject({ isError: true }); + expect(result.payloads?.[0]?.text).toContain( + "some tool actions may have already been executed", + ); + expect(result.meta.error?.kind).toBe("incomplete_turn"); expect(result.meta.terminalReplyKind).toBeUndefined(); expect(result.meta.finalAssistantVisibleText).toBeUndefined(); expect(result.meta.finalAssistantRawText).toBeUndefined(); - expect(result.meta.stopReason).toBe("stop"); expectNoWarnMessageWith("empty response detected"); expectWarnMessageWith("settled-turn finalization completed without a visible answer"); }); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts index b3170e6e6e5a..c4d652bb38cc 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.settled-tool-recovery.test.ts @@ -404,32 +404,17 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { }); it("continues once after settled side-effecting tools finish without a final answer", async () => { - const acceptedSessionSpawns = [ - { runId: "child-run", childSessionKey: "agent:main:subagent:child" }, - ]; const toolUseAssistant = makeLastAssistant({ stopReason: "toolUse", content: [ { type: "toolCall", id: "tool_write", name: "write", arguments: { path: "note.txt" } }, { type: "toolCall", id: "tool_cron", name: "cron", arguments: { action: "add" } }, - { - type: "toolCall", - id: "tool_spawn", - name: "sessions_spawn", - arguments: { task: "follow up" }, - }, ], }); const settledToolResults = [ toolUseAssistant, { role: "toolResult", toolCallId: "tool_write", toolName: "write", isError: false }, { role: "toolResult", toolCallId: "tool_cron", toolName: "cron", isError: false }, - { - role: "toolResult", - toolCallId: "tool_spawn", - toolName: "sessions_spawn", - isError: false, - }, ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"]; mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { @@ -437,15 +422,10 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { return makeAttemptResult({ assistantTexts: [], latestMcpAppChannelView: { viewId: "view-after-tools" }, - toolMetas: [ - { toolName: "write", meta: "path=note.txt" }, - { toolName: "cron" }, - { toolName: "sessions_spawn" }, - ], + toolMetas: [{ toolName: "write", meta: "path=note.txt" }, { toolName: "cron" }], successfulNestedToolNames: ["read"], - acceptedSessionSpawns, successfulCronAdds: 1, - itemLifecycle: { startedCount: 3, completedCount: 3, activeCount: 0 }, + itemLifecycle: { startedCount: 2, completedCount: 2, activeCount: 0 }, messagesSnapshot: settledToolResults, lastAssistant: toolUseAssistant, currentAttemptAssistant: toolUseAssistant, @@ -475,10 +455,9 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(result.payloads?.[0]?.text).toBe("Write completed. Here is the final answer."); expect(result.latestMcpAppChannelView).toEqual({ viewId: "view-after-tools" }); expect(result.successfulCronAdds).toBe(1); - expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); expect(result.meta.toolSummary).toEqual({ - calls: 3, - tools: ["write", "cron", "sessions_spawn"], + calls: 2, + tools: ["write", "cron"], failures: 0, }); expect(result.meta.agentMeta).toMatchObject({ diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts index 6b6f6e45c759..8d3fc0cb9e7b 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.terminal-evidence.test.ts @@ -17,6 +17,35 @@ import { } from "./run/incomplete-turn-resolution.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; +function makeSettledIdleWriteAttempt(options?: { + terminal?: EmbeddedRunAttemptResult["terminal"]; + stalePriorTurn?: boolean; +}) { + const toolUseAssistant = makeLastAssistant({ + stopReason: "toolUse", + content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: {} }], + }); + const abortedAssistant = makeLastAssistant({ stopReason: "aborted", content: [] }); + return makeAttemptResult({ + terminal: options?.terminal ?? { kind: "timeout", phase: "prompt", source: "idle" }, + assistantTexts: [], + toolMetas: [{ toolName: "write", replaySafe: false }], + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + messagesSnapshot: [ + { role: "user", content: [{ type: "text", text: "old turn" }] }, + toolUseAssistant, + { role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false }, + ...(options?.stalePriorTurn + ? [{ role: "user", content: [{ type: "text", text: "current turn" }] }] + : []), + abortedAssistant, + ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"], + lastAssistant: abortedAssistant, + currentAttemptAssistant: abortedAssistant, + currentAttemptReplayMetadata: { hadPotentialSideEffects: true, replaySafe: false }, + }); +} + describe("runEmbeddedAgent incomplete-turn safety", () => { beforeEach(() => { resetRunIncompleteTurnOwnerMocks(); @@ -87,26 +116,81 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { ).toBe(true); }); - it.each([ - { label: "aborted", aborted: true, timedOut: false, promptError: null }, - { label: "timed out", aborted: false, timedOut: true, promptError: null }, - { label: "prompt error", aborted: false, timedOut: false, promptError: new Error("closed") }, - ])("does not continue a $label tool-use terminal turn", ({ aborted, timedOut, promptError }) => { - const toolUseAssistant = makeLastAssistant({ - stopReason: "toolUse", - content: [{ type: "tool_use", id: "tool_1", name: "bash", input: {} }], - }); + it("continues an exactly settled current-turn tool batch after an idle prompt timeout", () => { const instruction = resolveSettledToolTerminalContinuationInstruction( - makeSettledContinuationParams( - { - assistantTexts: [], - toolMetas: [{ toolName: "bash" }], - itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, - lastAssistant: toolUseAssistant, - currentAttemptAssistant: toolUseAssistant, - }, - { aborted, timedOut, promptError }, - ), + makeSettledContinuationParams(makeSettledIdleWriteAttempt(), { + timedOut: true, + promptError: new Error("LLM idle timeout"), + }), + ); + + expect(instruction).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); + }); + + it.each([ + { + label: "external abort", + terminal: { kind: "timeout", phase: "prompt", source: "external" } as const, + aborted: true, + timedOut: true, + }, + { + label: "runtime timeout", + terminal: { kind: "timeout", phase: "prompt", source: "runtime" } as const, + aborted: false, + timedOut: true, + }, + { + label: "run budget timeout", + terminal: { kind: "timeout", phase: "prompt", source: "run_budget" } as const, + aborted: false, + timedOut: true, + }, + { + label: "compaction timeout", + terminal: { kind: "timeout", phase: "compaction", source: "idle" } as const, + aborted: false, + timedOut: true, + }, + { + label: "tool execution timeout", + terminal: { kind: "timeout", phase: "tool_execution", source: "idle" } as const, + aborted: false, + timedOut: true, + }, + { + label: "timeout observation", + terminal: { kind: "timeout", phase: "tool_execution", source: "observation" } as const, + aborted: false, + timedOut: false, + }, + { + label: "prompt error without idle timeout", + terminal: { kind: "ok" } as const, + aborted: false, + timedOut: false, + promptError: new Error("closed"), + }, + ])( + "does not finalize settled tools after a $label", + ({ terminal, aborted, timedOut, promptError }) => { + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams(makeSettledIdleWriteAttempt({ terminal }), { + aborted, + timedOut, + promptError, + }), + ); + + expect(instruction).toBeNull(); + }, + ); + + it("does not use a settled prior-turn batch to authorize idle-timeout finalization", () => { + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams(makeSettledIdleWriteAttempt({ stalePriorTurn: true }), { + timedOut: true, + }), ); expect(instruction).toBeNull(); diff --git a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test-support.ts b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test-support.ts index 89651ae2d386..5fc40ac91108 100644 --- a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test-support.ts +++ b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test-support.ts @@ -4,7 +4,9 @@ import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixt import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { MockedFailoverError, + mockedBuildEmbeddedRunPayloads, mockedClassifyFailoverReason, + mockedGetApiKeyForModel, mockedRunEmbeddedAttempt, overflowBaseRunParams, resetSharedRunIntegrationHarnessMocks, @@ -58,4 +60,102 @@ describe("runEmbeddedAgent prompt timeout fallback handoff", () => { await expect(promise).rejects.toThrow("LLM request timed out."); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); }); + + it("finalizes a settled write after an idle timeout without replaying the prompt", async () => { + const toolUseAssistant = { + role: "assistant" as const, + stopReason: "toolUse" as const, + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "toolCall", + id: "tool_write", + name: "write", + arguments: { path: "note.txt", content: "done" }, + }, + ], + }; + const abortedAssistant = { + role: "assistant" as const, + stopReason: "aborted" as const, + provider: "openai", + model: "gpt-5.4", + content: [], + }; + const finalAssistant = { + role: "assistant" as const, + stopReason: "stop" as const, + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "The note was written once." }], + }; + mockedClassifyFailoverReason.mockReturnValue("timeout"); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + terminal: { kind: "timeout", phase: "prompt", source: "idle" }, + toolMetas: [{ toolName: "write", replaySafe: false }], + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + messagesSnapshot: [ + { role: "user", content: [{ type: "text", text: "Write note.txt" }] }, + toolUseAssistant, + { + role: "toolResult", + toolCallId: "tool_write", + toolName: "write", + isError: false, + }, + abortedAssistant, + ] as never, + lastAssistant: abortedAssistant as never, + currentAttemptAssistant: abortedAssistant as never, + currentAttemptReplayMetadata: { + hadPotentialSideEffects: true, + replaySafe: false, + }, + }), + ) + .mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: ["The note was written once."], + lastAssistant: finalAssistant as never, + currentAttemptAssistant: finalAssistant as never, + currentAttemptCompletedAssistant: finalAssistant as never, + }), + ); + mockedBuildEmbeddedRunPayloads + .mockReturnValueOnce([]) + .mockReturnValueOnce([{ text: "The note was written once." }]); + + const result = await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-post-tool-idle-finalization", + config: makeModelFallbackCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + }, + }, + }), + }); + + expect(result.payloads).toEqual([{ text: "The note was written once." }]); + expect(result.meta.executionTrace?.fallbackUsed).toBe(false); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt.mock.calls[1]?.[0]).toMatchObject({ + operation: "settled-tool-finalization", + disableTools: true, + skipPreparedUserTurnMessage: true, + prompt: + "The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch.", + }); + expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/embedded-agent-runner/run/assistant-failure.test.ts b/src/agents/embedded-agent-runner/run/assistant-failure.test.ts index c7b3765c207c..87d1828f7d17 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failure.test.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failure.test.ts @@ -110,6 +110,37 @@ function makeExhaustedCredentialFailureInput(options?: { replaySafe?: boolean }) }; } +function makeIdleTimeoutFailureInput(options?: { replaySafe?: boolean }) { + const fixture = makeExhaustedCredentialFailureInput(); + const replaySafe = options?.replaySafe === true; + const assistant = buildEmbeddedRunnerAssistant({ + provider: "anthropic", + model: "mock-1", + stopReason: "aborted", + }); + const replayMetadata = { + hadPotentialSideEffects: !replaySafe, + replaySafe, + }; + const attempt = makeEmbeddedRunnerAttempt({ + terminal: { kind: "timeout", phase: "prompt", source: "idle" }, + lastAssistant: assistant, + currentAttemptAssistant: assistant, + toolMetas: replaySafe ? [] : [{ toolName: "write", replaySafe: false }], + replayMetadata, + currentAttemptReplayMetadata: replayMetadata, + }); + fixture.input.attempt = attempt; + fixture.input.attemptAssistant = assistant; + fixture.input.currentAttemptAssistant = assistant; + fixture.input.terminalState = resolveEmbeddedRunAttemptTerminalState({ attempt, assistant }); + fixture.input.emptyErrorRetries = 0; + fixture.input.maybeRefreshRuntimeAuthForAuthError = vi.fn(async () => true); + fixture.input.maybeRetrySameModelRateLimit = vi.fn(async () => true); + fixture.input.advanceRateLimitAuthProfile = vi.fn(async () => true); + return fixture; +} + describe("handleEmbeddedAssistantFailure", () => { it("uses prepared OpenRouter ownership for custom-provider billing failures", async () => { const fixture = makeExhaustedCredentialFailureInput(); @@ -165,10 +196,11 @@ describe("handleEmbeddedAssistantFailure", () => { } fixture.input.attemptAssistant.errorCode = PROVIDER_POST_DISPATCH_AMBIGUITY_ERROR_CODE; fixture.input.attemptAssistant.errorMessage = "reasoning is required"; + fixture.input.resolveAuthProfileFailureReason = vi.fn(() => "timeout" as const); const outcome = await handleEmbeddedAssistantFailure(fixture.input); - expect(outcome.action).toBe("proceed"); + expect(outcome).toMatchObject({ action: "proceed", assistantProfileFailureReason: null }); expect(fixture.advanceAuthProfile).not.toHaveBeenCalled(); expect(fixture.maybeMarkAuthProfileFailure).not.toHaveBeenCalled(); expect(fixture.traceAttempts).toEqual([]); @@ -212,6 +244,37 @@ describe("handleEmbeddedAssistantFailure", () => { expect(fixture.traceAttempts).toEqual([]); }); + it("closes every failover retry after an idle timeout commits a write", async () => { + const fixture = makeIdleTimeoutFailureInput(); + + const outcome = await handleEmbeddedAssistantFailure(fixture.input); + + expect(outcome.action).toBe("proceed"); + expect(fixture.input.maybeRefreshRuntimeAuthForAuthError).not.toHaveBeenCalled(); + expect(fixture.input.maybeRetrySameModelRateLimit).not.toHaveBeenCalled(); + expect(fixture.advanceAuthProfile).not.toHaveBeenCalled(); + expect(fixture.input.advanceRateLimitAuthProfile).not.toHaveBeenCalled(); + expect(fixture.traceAttempts).toEqual([]); + }); + + it("keeps replay-safe idle timeout profile rotation available", async () => { + const fixture = makeIdleTimeoutFailureInput({ replaySafe: true }); + fixture.input.maybeRefreshRuntimeAuthForAuthError = vi.fn(async () => false); + + const outcome = await handleEmbeddedAssistantFailure(fixture.input); + + expect(outcome).toMatchObject({ action: "retry", lastRetryFailoverReason: "timeout" }); + expect(fixture.advanceAuthProfile).toHaveBeenCalledOnce(); + expect(fixture.traceAttempts).toEqual([ + { + provider: "anthropic", + model: "mock-1", + result: "rotate_profile", + stage: "assistant", + }, + ]); + }); + it("does not cache an exact credential-file failure from a fallback candidate", async () => { const previous = process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS; process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = "60000"; diff --git a/src/agents/embedded-agent-runner/run/assistant-failure.ts b/src/agents/embedded-agent-runner/run/assistant-failure.ts index b9ce09fff744..8c721e4a94a3 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failure.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failure.ts @@ -23,6 +23,7 @@ import { import { log } from "../logger.js"; import type { TraceAttempt } from "../types.js"; import { handleAssistantFailover, isShortWindowRateLimitMessage } from "./assistant-failover.js"; +import { isCurrentAttemptReplaySafe } from "./attempt-terminal-evidence.js"; import { createFailoverDecisionLogger } from "./failover-observation.js"; import { resolveRunFailoverDecision } from "./failover-policy.js"; import { shouldRetrySilentErrorAssistantTurn } from "./incomplete-turn-recovery.js"; @@ -100,28 +101,10 @@ export async function handleEmbeddedAssistantFailure(input: { projectAgentRunAttemptTerminal(input.attempt.terminal); const terminalInterrupted = isEmbeddedRunTerminalInterrupted(input.terminalState.outcome); const { signalOwnedInterruption } = input.terminalState; - if (isReplayUnsafeAssistantError(input.attemptAssistant)) { - return buildOutcome(input, { - action: "proceed", - assistantProfileFailureReason: null, - }); - } const fallbackThinking = pickFallbackThinkingLevel({ message: input.attemptAssistant?.errorMessage, attempted: input.attemptedThinking, }); - if (fallbackThinking && !terminalInterrupted) { - log.warn( - `unsupported thinking level for ${input.provider}/${input.modelId}; retrying with ${fallbackThinking}`, - ); - return buildOutcome(input, { - action: "retry", - thinkLevel: fallbackThinking, - preserveSameModelRateLimitRetryCount: true, - assistantProfileFailureReason: null, - }); - } - const authFailure = isAuthAssistantError(input.attemptAssistant); const rateLimitFailure = isRateLimitAssistantError(input.attemptAssistant); const billingFailure = isBillingAssistantError(input.attemptAssistant); @@ -144,6 +127,26 @@ export async function handleEmbeddedAssistantFailure(input: { isShortWindowRateLimitMessage(input.attemptAssistant?.errorMessage), }, ); + const replayUnsafeAssistantError = isReplayUnsafeAssistantError(input.attemptAssistant); + if (replayUnsafeAssistantError || !isCurrentAttemptReplaySafe(input.attempt)) { + return buildOutcome(input, { + action: "proceed", + assistantProfileFailureReason: replayUnsafeAssistantError + ? null + : assistantProfileFailureReason, + }); + } + if (fallbackThinking && !terminalInterrupted) { + log.warn( + `unsupported thinking level for ${input.provider}/${input.modelId}; retrying with ${fallbackThinking}`, + ); + return buildOutcome(input, { + action: "retry", + thinkLevel: fallbackThinking, + preserveSameModelRateLimitRetryCount: true, + assistantProfileFailureReason, + }); + } const cloudCodeAssistFormatError = input.attempt.cloudCodeAssistFormatError; const imageDimensionError = parseImageDimensionError(input.attemptAssistant?.errorMessage ?? ""); const genericUnknownReasoningError = diff --git a/src/agents/embedded-agent-runner/run/attempt-recovery.ts b/src/agents/embedded-agent-runner/run/attempt-recovery.ts index b5034dcd076e..31696d7b0cb5 100644 --- a/src/agents/embedded-agent-runner/run/attempt-recovery.ts +++ b/src/agents/embedded-agent-runner/run/attempt-recovery.ts @@ -11,6 +11,7 @@ import type { EmbeddedAgentRunResult, TraceAttempt } from "../types.js"; import type { createUsageAccumulator } from "../usage-accumulator.js"; import type { prepareAndDispatchEmbeddedRunAttempt } from "./attempt-dispatch-preparation.js"; import type { normalizeEmbeddedRunAttempt } from "./attempt-normalization.js"; +import { isCurrentAttemptReplaySafe } from "./attempt-terminal-evidence.js"; import { buildEmbeddedRunBlockedResult } from "./blocked-run-result.js"; import { resolveCodexAppServerRecoveryRetry } from "./codex-app-server-recovery.js"; import { resolveCompactionLiveModelSelection } from "./compaction-live-model-selection.js"; @@ -110,6 +111,7 @@ export async function recoverEmbeddedRunAttempt(input: { timedOutByRunBudget, } = projectAgentRunAttemptTerminal(attempt.terminal); const terminalInterrupted = isEmbeddedRunTerminalInterrupted(terminalState.outcome); + const currentAttemptReplaySafe = isCurrentAttemptReplaySafe(attempt); const { signalOwnedInterruption } = terminalState; const assistantOverflowCandidate = currentAttemptCompletedAssistant !== undefined @@ -137,6 +139,40 @@ export async function recoverEmbeddedRunAttempt(input: { thinkLevel: updates?.thinkLevel ?? runtime.thinkLevel, }); + if (promptErrorSource === "hook:before_agent_run" && !terminalInterrupted) { + const errorText = formatErrorMessage(promptError); + const replayInvalid = resolveReplayInvalidForAttempt(); + setTerminalLifecycleMeta({ replayInvalid, livenessState: "blocked" }); + return { + action: "complete", + result: buildEmbeddedRunBlockedResult({ + text: errorText, + errorKind: "hook_block", + errorMessage: errorText, + durationMs: Date.now() - runInput.startedAtMs, + agentMeta: buildErrorAgentMeta({ + sessionId: sessionIdUsed, + sessionFile: sessionPromptState.sessionFile, + provider: preparedRuntime.provider, + model: preparedRuntime.model.id, + ...runtime.outerContextTokenMeta, + usageAccumulator: input.usageAccumulator, + lastRunPromptUsage: input.lastRunPromptUsage, + currentAttemptAssistant, + }), + attempt, + replayInvalid, + }), + }; + } + if (!currentAttemptReplaySafe) { + return { + action: "proceed", + shouldSurfaceCodexCompletionTimeout: + attempt.codexAppServerFailure?.kind === "turn_completion_idle_timeout" && timedOut, + }; + } + const requestedSelection = shouldSwitchToLiveModel({ cfg: params.config, sessionKey: runInput.resolvedSessionKey, @@ -255,32 +291,6 @@ export async function recoverEmbeddedRunAttempt(input: { }), }; } - if (promptErrorSource === "hook:before_agent_run" && !terminalInterrupted) { - const errorText = formatErrorMessage(promptError); - const replayInvalid = resolveReplayInvalidForAttempt(); - setTerminalLifecycleMeta({ replayInvalid, livenessState: "blocked" }); - return { - action: "complete", - result: buildEmbeddedRunBlockedResult({ - text: errorText, - errorKind: "hook_block", - errorMessage: errorText, - durationMs: Date.now() - runInput.startedAtMs, - agentMeta: buildErrorAgentMeta({ - sessionId: sessionIdUsed, - sessionFile: sessionPromptState.sessionFile, - provider: preparedRuntime.provider, - model: preparedRuntime.model.id, - ...runtime.outerContextTokenMeta, - usageAccumulator: input.usageAccumulator, - lastRunPromptUsage: input.lastRunPromptUsage, - currentAttemptAssistant, - }), - attempt, - replayInvalid, - }), - }; - } const hasRecoverableCodexAppServerTimeoutOutcome = Boolean( attempt.codexAppServerFailure && attempt.promptTimeoutOutcome, ); diff --git a/src/agents/embedded-agent-runner/run/attempt-terminal-evidence.ts b/src/agents/embedded-agent-runner/run/attempt-terminal-evidence.ts index 8f9852b4f63e..ea8b87ca5639 100644 --- a/src/agents/embedded-agent-runner/run/attempt-terminal-evidence.ts +++ b/src/agents/embedded-agent-runner/run/attempt-terminal-evidence.ts @@ -16,6 +16,14 @@ type ReplayMetadataAttempt = Pick< > & Partial>; +/** Uses current-attempt evidence when available and otherwise preserves fail-closed legacy state. */ +export function isCurrentAttemptReplaySafe( + attempt: Pick, +): boolean { + const replayMetadata = attempt.currentAttemptReplayMetadata ?? attempt.replayMetadata; + return replayMetadata.replaySafe && !replayMetadata.hadPotentialSideEffects; +} + /** * Marks whether retrying the attempt can safely replay the prompt. Concrete * tool-instance policy, async work, committed delivery, spawned sessions, and diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts index fc532f8578a0..51e1b49a245f 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-classification.ts @@ -33,6 +33,7 @@ export type IncompleteTurnAttempt = Pick< | "itemLifecycle" | "messagesSnapshot" | "replayMetadata" + | "currentAttemptReplayMetadata" | "terminal" | "toolMetas" > & diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts index 10f4b24ed93d..dcaff3f76d58 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts @@ -7,7 +7,11 @@ import { hasCompletedMessagingToolDeliveryEvidence, } from "../delivery-evidence.js"; import { isZeroUsageEmptyStopAssistantTurn } from "../empty-assistant-turn.js"; -import { hasAsyncActivity, hasAttemptTerminalState } from "./attempt-terminal-evidence.js"; +import { + hasAsyncActivity, + hasAttemptTerminalState, + isCurrentAttemptReplaySafe, +} from "./attempt-terminal-evidence.js"; import { hasOnlySilentAssistantReply, hasPositiveOutputTokenUsage, @@ -60,9 +64,7 @@ export function shouldRetrySilentErrorAssistantTurn(params: { } // Current-attempt evidence avoids blocking on prior committed effects; older // harnesses retain the cumulative, fail-closed behavior. - const retryReplayMetadata = - params.attempt.currentAttemptReplayMetadata ?? params.attempt.replayMetadata; - if (retryReplayMetadata.hadPotentialSideEffects) { + if (!isCurrentAttemptReplaySafe(params.attempt)) { return false; } @@ -187,6 +189,27 @@ export function resolveReasoningOnlyRetryInstruction(params: { return REASONING_ONLY_RETRY_INSTRUCTION; } +type SettledToolCall = { id: string | null; name: string | null }; + +function readSettledToolCalls( + message: EmbeddedRunAttemptResult["currentAttemptAssistant"] | null | undefined, +): SettledToolCall[] { + if (!Array.isArray(message?.content)) { + return []; + } + return message.content.flatMap((item) => { + const block = item as { type?: unknown; id?: unknown; name?: unknown } | null; + return block?.type === "toolCall" + ? [ + { + id: typeof block.id === "string" ? block.id : null, + name: typeof block.name === "string" ? block.name : null, + }, + ] + : []; + }); +} + /** Builds one fresh continuation after settled tools ended without a visible final answer. */ export function resolveSettledToolTerminalContinuationInstruction(params: { provider?: string; @@ -201,8 +224,27 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { timedOut: boolean; attempt: IncompleteTurnAttempt; }): string | null { - const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant; const currentAttemptAssistant = params.attempt.currentAttemptAssistant; + const snapshot = params.attempt.messagesSnapshot ?? []; + const latestUserIndex = snapshot.findLastIndex((message) => message.role === "user"); + let assistant: EmbeddedRunAttemptResult["currentAttemptAssistant"] = currentAttemptAssistant; + let assistantIndex = assistant ? snapshot.indexOf(assistant) : -1; + if (assistantIndex <= latestUserIndex || readSettledToolCalls(assistant).length === 0) { + assistantIndex = snapshot.findLastIndex( + (message, index) => + index > latestUserIndex && + message.role === "assistant" && + readSettledToolCalls(message).length > 0, + ); + const assistantCandidate = assistantIndex >= 0 ? snapshot[assistantIndex] : undefined; + assistant = assistantCandidate?.role === "assistant" ? assistantCandidate : undefined; + } + const terminal = params.attempt.terminal; + const idlePromptTimeout = + terminal.kind === "timeout" && + terminal.phase === "prompt" && + terminal.source === "idle" && + params.attempt.currentAttemptReplayMetadata?.hadPotentialSideEffects === true; const emptyStopAfterSettledTools = Boolean( params.allowEmptyStopContinuation && currentAttemptAssistant?.stopReason === "stop" && @@ -220,25 +262,11 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { // Idle is not proof of settlement: skipped or partially dispatched tools must // never be described as completed. Match each terminal call's id and owner to // its own current-batch result; a reported failure is settled, not successful. - const requestedToolCalls = Array.isArray(assistant?.content) - ? assistant.content.flatMap((item) => { - const block = item as { type?: unknown; id?: unknown; name?: unknown } | null; - return block?.type === "toolCall" - ? [ - { - id: typeof block.id === "string" ? block.id : null, - name: typeof block.name === "string" ? block.name : null, - }, - ] - : []; - }) - : []; + const requestedToolCalls = readSettledToolCalls(assistant); // Scan only results AFTER the terminal assistant: the snapshot spans the whole // session, and a prior turn's toolResult with a model-reused id would otherwise // prove "completion" for a batch that never dispatched. Assistant not found in // the snapshot fails closed to the existing incomplete-turn error. - const snapshot = params.attempt.messagesSnapshot ?? []; - const assistantIndex = assistant ? snapshot.indexOf(assistant) : -1; const settledToolResults = new Map( (assistantIndex >= 0 ? snapshot.slice(assistantIndex + 1) : []).flatMap((message) => { const result = message as { @@ -260,7 +288,9 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { }), ); const allToolsProvenSettled = - params.attempt.itemLifecycle?.activeCount === 0 && + params.attempt.itemLifecycle.startedCount > 0 && + params.attempt.itemLifecycle.completedCount === params.attempt.itemLifecycle.startedCount && + params.attempt.itemLifecycle.activeCount === 0 && requestedToolCalls.length > 0 && requestedToolCalls.every( ({ id, name }) => @@ -284,13 +314,14 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { params.payloadCount !== 0 || params.hasTerminalToolPresentation || params.aborted || - params.promptError != null || - params.timedOut || + ((params.promptError != null || + params.timedOut || + params.attempt.terminal.kind === "timeout") && + !idlePromptTimeout) || (assistant?.stopReason === "toolUse" ? !allToolsProvenSettled : !emptyStopAfterSettledTools) || hasUnsettledToolError || - (hasSettledTerminalToolFailure && - (hasAsyncActivity(params.attempt.toolMetas) || - hasAcceptedSessionSpawn(params.attempt.acceptedSessionSpawns))) || + hasAsyncActivity(params.attempt.toolMetas) || + hasAcceptedSessionSpawn(params.attempt.acceptedSessionSpawns) || params.attempt.clientToolCalls || params.attempt.yieldDetected || params.attempt.didSendDeterministicApprovalPrompt diff --git a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts index 3aea8ff8d73c..192a0e5aeff8 100644 --- a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts +++ b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts @@ -107,34 +107,16 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { prompt, noteLaneTaskProgress: input.finalization.noteLaneTaskProgress, }); - if (finalization.outcome === "empty") { - mergeUsageIntoAccumulator(input.terminalBase.usageAccumulator, finalization.result.usage); - lastRunPromptUsage = finalization.result.usage ?? lastRunPromptUsage; - log.warn( - `settled-turn finalization completed without a visible answer: runId=${runParams.runId} sessionId=${runParams.sessionId} ` + - `provider=${errorContext.provider}/${errorContext.model} — recording completed-empty outcome`, - ); - const emptyAssistant = finalization.result.assistant; - const completedEmptyAttempt = { - ...initial.attempt, - lastAssistant: emptyAssistant, - currentAttemptAssistant: emptyAssistant, - currentAttemptCompletedAssistant: emptyAssistant, - }; - return { - ...initial, - attempt: completedEmptyAttempt, - attemptAssistant: emptyAssistant, - currentAttemptCompletedAssistant: emptyAssistant, - prepared, - lastRunPromptUsage, - finalizationOutcome: "completed-empty" as const, - }; - } attempt = finalization.attempt; mergeUsageIntoAccumulator(input.terminalBase.usageAccumulator, attempt.attemptUsage); mergeAttemptRunStatsIntoAccumulator(input.terminalBase.usageAccumulator, attempt); lastRunPromptUsage = attempt.attemptUsage ?? lastRunPromptUsage; + if (finalization.outcome === "empty") { + log.warn( + `settled-turn finalization completed without a visible answer: runId=${runParams.runId} sessionId=${runParams.sessionId} ` + + `provider=${errorContext.provider}/${errorContext.model} — recording completed-empty outcome`, + ); + } // Successful isolated finalization owns a fresh terminal, never the original abort signal. const terminalState: EmbeddedRunTerminalState = { outcome: resolveEmbeddedRunAttemptTerminalOutcome({ @@ -164,7 +146,8 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { sessionFileUsed: attempt.sessionFileUsed, prepared, lastRunPromptUsage, - finalizationOutcome: "answered" as const, + finalizationOutcome: + finalization.outcome === "empty" ? ("completed-empty" as const) : ("answered" as const), }; } catch (error) { log.warn( @@ -186,13 +169,7 @@ async function runPreparedSettledTurnFinalization(input: { harness: AgentHarness; prompt: string; noteLaneTaskProgress: () => void; -}): Promise< - | { outcome: "answered"; attempt: EmbeddedRunAttemptWithReceiptEvidence } - | { - outcome: "empty"; - result: AgentHarnessSettledTurnFinalizationResult; - } -> { +}): Promise<{ outcome: "answered" | "empty"; attempt: EmbeddedRunAttemptWithReceiptEvidence }> { return await withEmbeddedRunLaneProgressHeartbeat(input.noteLaneTaskProgress, async () => { const finalization = await runEmbeddedSettledTurnFinalizationWithBackend( { @@ -206,12 +183,10 @@ async function runPreparedSettledTurnFinalization(input: { input.settledAttempt, input.harness, ); - if (finalization.outcome === "empty") { - return finalization; - } return { - outcome: "answered", + outcome: finalization.outcome, attempt: buildSettledTurnFinalizationAttemptResult({ + outcome: finalization.outcome, result: finalization.result, settledAttempt: input.settledAttempt, prompt: input.prompt, @@ -222,13 +197,14 @@ async function runPreparedSettledTurnFinalization(input: { } function buildSettledTurnFinalizationAttemptResult(input: { + outcome: "answered" | "empty"; result: AgentHarnessSettledTurnFinalizationResult; settledAttempt: EmbeddedRunAttemptWithReceiptEvidence; prompt: string; agentHarnessId?: string; }): EmbeddedRunAttemptWithReceiptEvidence { const { result, settledAttempt } = input; - const text = resolveSettledTurnFinalizationText(result); + const text = input.outcome === "empty" ? "" : resolveSettledTurnFinalizationText(result); // Finalization replaces terminal ownership, not host-private facts from settled tools. // Keep those facts while replay, abort, and lifecycle state remain finalizer-local. return { diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.ts index 9fc501936976..0e78982afa0d 100644 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.ts +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.ts @@ -63,6 +63,14 @@ type TerminalResolution = | { action: "retry" } | { action: "complete"; result: EmbeddedAgentRunResult }; +function requiresVisibleTerminalReply(runParams: TerminalRunParams): boolean { + return ( + runParams.terminalReplyExpectation === "required" || + (runParams.terminalReplyExpectation == null && + (runParams.trigger == null || runParams.trigger === "user" || runParams.trigger === "manual")) + ); +} + export function resolveSettledTurnFinalizationRequest(input: { runParams: TerminalRunParams; attempt: EmbeddedRunAttemptResult; @@ -131,12 +139,7 @@ export function resolveSettledTurnFinalizationRequest(input: { modelId: input.activeErrorContext.model, modelApi: input.modelApi, executionContract: input.executionContract, - allowEmptyStopContinuation: - input.runParams.terminalReplyExpectation === "required" || - (input.runParams.terminalReplyExpectation == null && - (input.runParams.trigger == null || - input.runParams.trigger === "user" || - input.runParams.trigger === "manual")), + allowEmptyStopContinuation: requiresVisibleTerminalReply(input.runParams), payloadCount, hasTerminalToolPresentation: input.hasTerminalToolPresentation, aborted: terminalAborted, @@ -311,8 +314,10 @@ export async function resolveEmbeddedRunTerminal(input: { ); return { action: "retry" }; } + const completedEmptyFinalization = input.settledTurnFinalizationOutcome === "completed-empty"; const incompleteTurnText = - emptyAssistantReplyIsSilent || input.settledTurnFinalizationOutcome === "completed-empty" + emptyAssistantReplyIsSilent || + (completedEmptyFinalization && !requiresVisibleTerminalReply(runParams)) ? null : resolveIncompleteTurnPayloadText({ payloadCount, From af3550df73f9d1846a02f76cae084246a48b1b98 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:41:17 -0700 Subject: [PATCH 059/165] fix(ui): confirm automation removal (#122505) Bind irreversible Cron removal to the current Gateway, admin scope, state object, and job revision across the confirmation modal. --- ui/src/e2e/cron-remove.e2e.test.ts | 106 ++++++++++++++++++++++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/pages/cron/cron-page.test.ts | 5 ++ ui/src/pages/cron/cron-page.ts | 58 ++++++++++++--- 4 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 ui/src/e2e/cron-remove.e2e.test.ts diff --git a/ui/src/e2e/cron-remove.e2e.test.ts b/ui/src/e2e/cron-remove.e2e.test.ts new file mode 100644 index 000000000000..f2d49f8008a6 --- /dev/null +++ b/ui/src/e2e/cron-remove.e2e.test.ts @@ -0,0 +1,106 @@ +// Control UI tests own the destructive Automation removal flow through the rendered page. +import type { Page } from "playwright"; +import { expect, it } from "vitest"; +import { installMockGateway, waitForConfirmModal } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI cron removal mocked Gateway E2E", + startServerBeforeBrowser: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is not installed or cannot start at ${executablePath}.`, +}); + +const job = { + id: "nightly-digest", + name: "Nightly digest", + enabled: true, + createdAtMs: Date.parse("2026-08-11T08:00:00.000Z"), + updatedAtMs: Date.parse("2026-08-11T08:05:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Summarize the overnight activity" }, + state: {}, +}; + +function cronListResponse(jobs: unknown[]) { + return { + jobs, + snapshotRevision: jobs.length > 0 ? "cron-remove-present" : "cron-remove-empty", + total: jobs.length, + offset: 0, + limit: 50, + hasMore: false, + nextOffset: null, + }; +} + +async function chooseRemove(page: Page) { + const menu = page.locator("wa-dropdown.cron-job-menu").first(); + await menu.locator(".cron-job-menu__trigger").click(); + await menu.locator('wa-dropdown-item[value="remove"]').click(); +} + +suite.define(() => { + it("confirms removal and rejects a decision captured before reconnect", async () => { + await suite.withPage( + { + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1_280 }, + }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "cron.list": cronListResponse([job]), + "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, + "cron.status": { enabled: true, jobs: 1, nextWakeAtMs: null }, + }, + }); + + const response = await page.goto(`${suite.server.baseUrl}cron`); + expect(response?.status()).toBe(200); + const row = page.locator(`[data-test-id="cron-row-${job.id}"]`); + await row.waitFor({ state: "visible", timeout: 10_000 }); + await row.locator(".cron-table__name-text").click(); + const detail = page.locator('.cron-page[data-panel-mode="job"]'); + await detail.waitFor({ state: "visible" }); + + await chooseRemove(page); + const cancelled = await waitForConfirmModal(page); + await expect(cancelled.textContent()).resolves.toContain(job.name); + await expect(cancelled.textContent()).resolves.toContain("permanently deletes"); + await expect(cancelled.textContent()).resolves.toContain("stops all future runs"); + expect(await cancelled.getByRole("checkbox").count()).toBe(0); + await expect.poll(async () => gateway.getRequests("cron.remove")).toHaveLength(0); + await cancelled.getByRole("button", { name: "Cancel" }).click(); + await expect.poll(async () => gateway.getRequests("cron.remove")).toHaveLength(0); + await detail.waitFor({ state: "visible" }); + await expect + .poll(() => detail.locator(".cron-detail-title").textContent()) + .toContain(job.name); + + await chooseRemove(page); + const stale = await waitForConfirmModal(page); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1012, "Reconnect during automation removal confirmation"); + await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(socketCount); + await stale.getByRole("button", { name: "Remove" }).click(); + await expect.poll(async () => gateway.getRequests("cron.remove")).toHaveLength(0); + await row.waitFor({ state: "visible", timeout: 10_000 }); + + await chooseRemove(page); + const stable = await waitForConfirmModal(page); + const remove = stable.getByRole("button", { name: "Remove" }); + await expect.poll(() => remove.getAttribute("class")).toContain("danger"); + await gateway.setMethodResponse("cron.list", cronListResponse([])); + await remove.click(); + + await expect.poll(async () => gateway.getRequests("cron.remove")).toHaveLength(1); + expect((await gateway.getRequests("cron.remove"))[0]?.params).toEqual({ id: job.id }); + await expect.poll(() => row.count()).toBe(0); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 7681cc675caa..0045edb14d75 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5610,6 +5610,9 @@ export const en: TranslationMap = { resume: "Resume", clone: "Clone", remove: "Remove", + removeConfirmTitle: 'Remove "{name}"?', + removeConfirmMessage: + "This permanently deletes the automation and stops all future runs. This action cannot be undone.", more: "More actions", }, runNotStarted: { diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 90f90ca6a94a..41eb1d7131fb 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -4,9 +4,12 @@ import { createDeferred } from "../../../../test/helpers/promise.js"; import type { GatewayBrowserClient, GatewayEventListener } from "../../api/gateway.ts"; import type { CronJob, CronJobsListResult } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import type { CronState } from "../../lib/cron/index.ts"; import "./cron-page.ts"; +vi.mock("../../components/confirm-dialog.ts", () => ({ showConfirmDialog: vi.fn() })); + type CronTestPage = HTMLElement & { context: ApplicationContext; updateComplete: Promise; @@ -158,6 +161,7 @@ function createRequest() { afterEach(() => { document.body.replaceChildren(); + vi.mocked(showConfirmDialog).mockReset(); vi.restoreAllMocks(); }); @@ -443,6 +447,7 @@ describe("CronPage editor state sync", () => { const removeButton = Array.from(page.querySelectorAll(".cron-job-menu__item")).find( (item) => item.textContent?.trim() === "Remove", ) as HTMLButtonElement; + vi.mocked(showConfirmDialog).mockResolvedValueOnce(true); removeButton.click(); await waitForCronPage(() => expect(page.cron.cronEditingJobId).toBeNull()); await waitForCronPage(() => expect(page.cron.cronRunsScope).toBe("all")); diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts index 8943c687f56a..285232809017 100644 --- a/ui/src/pages/cron/cron-page.ts +++ b/ui/src/pages/cron/cron-page.ts @@ -6,7 +6,9 @@ import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { readGatewayOperatorAccess } from "../../app/operator-access.ts"; import { renderAgentScopeControl } from "../../components/agent-scope-control.ts"; +import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { t } from "../../i18n/index.ts"; import { watchAgentScope } from "../../lib/agents/index.ts"; import { addCronJob, @@ -286,6 +288,51 @@ class CronPage extends OpenClawLightDomElement { this.requestCronUpdate(); } + private async removeJob(job: CronJob) { + const context = this.context; + const cronState = this.cron; + const connectionScope = this.gateway.capture(); + const hadAdminAccess = this.canManageCron; + const selectedJob = cronState.cronJobs.find( + (entry) => entry.id === job.id && entry.updatedAtMs === job.updatedAtMs, + ); + if (!connectionScope || !hadAdminAccess || !selectedJob) { + return; + } + const selectedJobId = selectedJob.id; + const selectedJobRevision = selectedJob.updatedAtMs; + const selectedJobName = selectedJob.name; + const confirmed = await showConfirmDialog({ + title: t("cron.actions.removeConfirmTitle", { name: selectedJobName }), + message: t("cron.actions.removeConfirmMessage"), + confirmLabel: t("cron.actions.remove"), + danger: true, + }); + const currentJob = cronState.cronJobs.find((entry) => entry.id === selectedJobId); + // The modal yields while every owner can rotate. Reject stale decisions so + // an old row can never delete a replacement task on a new page or Gateway. + if ( + !confirmed || + this.context !== context || + this.cron !== cronState || + !this.gateway.isCurrent(connectionScope) || + !this.canManageCron || + !currentJob || + currentJob.updatedAtMs !== selectedJobRevision + ) { + return; + } + await this.runCronTask(async (current) => { + await removeCronJob(current, currentJob); + // Removing the selected task drops the panel back to overview; + // the runs scope must follow or recent activity stays empty. + if (current.cronRunsScope === "job" && current.cronRunsJobId === null) { + updateCronRunsFilter(current, { cronRunsScope: "all" }); + await loadCronRuns(current, null); + } + }); + } + private closePanel() { cancelCronEdit(this.cron); this.cron.cronCreateOpen = false; @@ -426,16 +473,7 @@ class CronPage extends OpenClawLightDomElement { }), onRun: (job, mode) => this.runCronAdminTask((cronState) => runCronJob(cronState, job.id, mode ?? "force")), - onRemove: (job) => - this.runCronAdminTask(async (cronState) => { - await removeCronJob(cronState, job); - // Removing the selected task drops the panel back to overview; - // the runs scope must follow or recent activity stays empty. - if (cronState.cronRunsScope === "job" && cronState.cronRunsJobId === null) { - updateCronRunsFilter(cronState, { cronRunsScope: "all" }); - await loadCronRuns(cronState, null); - } - }), + onRemove: (job) => void this.removeJob(job), onLoadMoreJobs: () => void this.runCronTask((cronState) => loadCronJobsPage(cronState, { append: true, tableFilters: true }), From 94c28e093d694fc8308c42f4a856f89fdf68cfc5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:44:49 -0700 Subject: [PATCH 060/165] fix(test): route agent directory runs to owning shard (#122514) * fix(test): route agent directories to owner projects * fix(test): preserve invalid signal diagnostics --- scripts/run-vitest.mts | 89 ++-- scripts/test-projects.test-support.mts | 18 + src/agents/agent-tools.ts | 24 +- src/agents/bash-process-scope.test.ts | 44 ++ src/agents/bash-process-scope.ts | 22 + .../compact.hooks.harness.ts | 11 - .../prepared-compaction-runtime.ts | 3 +- .../run/attempt-context-engine-helpers.ts | 6 - .../run/attempt-finalize.ts | 8 +- .../run/attempt-history.ts | 8 +- .../run/attempt-prompt-helpers.ts | 2 +- .../run/attempt-session-prepare.ts | 8 +- .../attempt-spawn-workspace.test-support.ts | 17 +- .../run/attempt-system-prompt-prepare.ts | 2 +- .../attempt.context-engine-helpers.test.ts | 127 ++++- ...mpt.spawn-workspace.context-engine.test.ts | 434 +----------------- ....spawn-workspace.context-injection.test.ts | 4 +- .../run/compaction-runtime.ts | 2 +- .../harness/context-engine-lifecycle.test.ts | 133 +++++- test/scripts/run-vitest.test.ts | 36 +- test/scripts/test-projects.test.ts | 36 +- 21 files changed, 466 insertions(+), 568 deletions(-) create mode 100644 src/agents/bash-process-scope.test.ts create mode 100644 src/agents/bash-process-scope.ts diff --git a/scripts/run-vitest.mts b/scripts/run-vitest.mts index 4fffeff8a8ee..62206a6c63db 100644 --- a/scripts/run-vitest.mts +++ b/scripts/run-vitest.mts @@ -5,8 +5,10 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import { constants as osConstants } from "node:os"; import path from "node:path"; -import type { Readable, Writable } from "node:stream"; -import { embeddedAgentVitestProjectOwners } from "../test/vitest/vitest.agents-paths.mjs"; +import { + agentVitestProjectOwners, + embeddedAgentVitestProjectOwners, +} from "../test/vitest/vitest.agents-paths.mjs"; import { toolingIsolatedTestFiles } from "../test/vitest/vitest.tooling-isolated-paths.mjs"; import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs"; @@ -35,6 +37,15 @@ type WatchdogStream = { on(event: string, listener: (...args: unknown[]) => void): unknown; off(event: string, listener: (...args: unknown[]) => void): unknown; }; +type NodeSignal = keyof typeof osConstants.signals; +type VitestOutputStream = { + setEncoding(encoding: "utf8"): unknown; + on(event: "data", listener: (chunk: string) => void): unknown; + on(event: "end", listener: () => void): unknown; +}; +type VitestOutputTarget = { + write(chunk: string): unknown; +}; const ANSI_CSI_PREFIX = `${String.fromCharCode(27)}[`; const ANSI_CSI_SUFFIX_RE = /^[0-?]*[ -/]*[@-~]/u; @@ -117,7 +128,10 @@ const VITEST_OPTIONS_WITH_VALUE = new Set([ "--retry", "--root", "-r", - "--sequence.shuffle.seed", + "--sequence", + "--sequence.hooks", + "--sequence.seed", + "--sequence.setupFiles", "--shard", "--silent", "--slowTestThreshold", @@ -138,7 +152,6 @@ const VITEST_DOTTED_OPTIONS_WITH_VALUE_PREFIXES = [ "--experimental.", "--outputFile.", "--retry.", - "--sequence.", "--typecheck.", ]; const UNBOUNDED_CONFIG_ONLY_OPTIONS = [ @@ -178,16 +191,17 @@ function isErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoExc return error instanceof Error && "code" in error && error.code === code; } -function isNodeSignal(signal: string): signal is NodeJS.Signals { +function isNodeSignal(signal: string): signal is NodeSignal { return Object.hasOwn(osConstants.signals, signal); } -function normalizeNodeSignal(signal: string | null): NodeJS.Signals | null { +function normalizeNodeSignal(signal: string | null): NodeSignal | null { if (!signal) { return null; } + const unknownSignalMessage = `child process exited with unknown signal: ${signal}`; if (!isNodeSignal(signal)) { - throw new Error(`child process exited with unknown signal: ${signal}`); + throw new Error(unknownSignalMessage); } return signal; } @@ -667,6 +681,18 @@ function isDelegableBroadProjectRouterTarget(arg: string, cwd: string): boolean ); } +function isPathAtOrUnder(value: string, root: string): boolean { + return value === root || value.startsWith(`${root}/`); +} + +function isOwnedAgentDirectoryTarget(arg: string, cwd: string, fsImpl: VitestPathFs): boolean { + const relative = toRepoRelativeArg(arg, cwd).replace(/\/+$/u, ""); + return ( + isPathAtOrUnder(relative, agentVitestProjectOwners.all.root) && + isExplicitDirectoryTargetArg(arg, cwd, fsImpl) + ); +} + function isExplicitProjectRouterTargetArg( arg: string, cwd = process.cwd(), @@ -683,7 +709,7 @@ function isExplicitProjectRouterTargetArg( } const filePath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg); return fsImpl.existsSync(filePath) - ? isDelegableBroadProjectRouterTarget(arg, cwd) + ? isDelegableBroadProjectRouterTarget(arg, cwd) || isOwnedAgentDirectoryTarget(arg, cwd, fsImpl) : path.extname(arg) === "" && /^(?:src|test|extensions|ui|packages|apps)\//u.test(toRepoRelativeArg(arg, cwd)); } @@ -810,42 +836,36 @@ function hasExplicitDisabledRunFlag(argv: string[]): boolean { return false; } -function hasSeparateVitestOptionValueArg(argv: string[]): boolean { - for (const arg of argv) { - if (arg === "--") { - return false; - } - if (optionConsumesNextArg(arg)) { - return true; - } - } - return false; -} - -function stripRunSubcommand(argv: string[]): string[] { - const stripped: string[] = []; +function resolveDelegatedVitestArgs(argv: string[]): string[] { + const positionalArgs: string[] = []; + const optionArgs: string[] = []; let canRemoveRunSubcommand = true; + let passthrough = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === undefined) { break; } if (arg === "--") { - stripped.push(arg); + passthrough = true; canRemoveRunSubcommand = false; continue; } - if (canRemoveRunSubcommand && optionConsumesNextArg(arg)) { - stripped.push(arg); + if (passthrough) { + optionArgs.push(arg); + continue; + } + if (optionConsumesNextArg(arg)) { + optionArgs.push(arg); const optionValue = argv[index + 1]; if (optionValue !== undefined) { + optionArgs.push(optionValue); index += 1; - stripped.push(optionValue); } continue; } - if (canRemoveRunSubcommand && arg.startsWith("-")) { - stripped.push(arg); + if (arg.startsWith("-")) { + optionArgs.push(arg); continue; } if (canRemoveRunSubcommand && arg === "run") { @@ -853,9 +873,9 @@ function stripRunSubcommand(argv: string[]): string[] { continue; } canRemoveRunSubcommand = false; - stripped.push(arg); + positionalArgs.push(arg); } - return stripped; + return optionArgs.length > 0 ? [...positionalArgs, "--", ...optionArgs] : positionalArgs; } function hasNonRunVitestSubcommand(argv: string[]): boolean { @@ -893,12 +913,11 @@ export function resolveTestProjectsDelegationArgs( resolveExplicitVitestMode(argv) === "watch" || hasNonRunVitestSubcommand(argv) || hasExplicitDisabledRunFlag(argv) || - hasSeparateVitestOptionValueArg(argv) || collectExplicitProjectRouterTargetArgs(argv, cwd).length === 0 ) { return null; } - return stripRunSubcommand(argv); + return resolveDelegatedVitestArgs(argv); } /** @@ -1138,8 +1157,8 @@ export function installVitestNoOutputWatchdog(params: { * Forwards child output while optionally suppressing complete stderr lines. */ function forwardVitestOutput( - stream: Readable | null, - target: Writable, + stream: VitestOutputStream | null, + target: VitestOutputTarget, shouldSuppressLine: (line: string) => boolean = () => false, ): void { if (!stream) { @@ -1185,7 +1204,7 @@ export function spawnWatchedVitestProcess({ label?: string; onNoOutputTimeout?: () => void; }) { - let forwardedSignal: NodeJS.Signals | null = null; + let forwardedSignal: NodeSignal | null = null; const child = spawnVitestProcess({ pnpmArgs, spawnParams, diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 3331ec29516b..5b0386ad9b2d 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -1233,6 +1233,9 @@ function resolveExactSourceDirectoryTestTargets(targetArg: string, cwd: string) if (!isExactSourceDirectoryTarget(relative)) { return null; } + if (isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) { + return [targetArg]; + } const prefix = `${relative}/`; const lightTargets = uniqueOrdered([ ...getUnitFastTestFiles(), @@ -1242,6 +1245,20 @@ function resolveExactSourceDirectoryTestTargets(targetArg: string, cwd: string) return lightTargets.length > 0 ? [...lightTargets, targetArg] : null; } +function isCanonicalAgentOwnerDirectoryTarget(targetArg: string, cwd: string) { + if (!isExistingDirectoryTarget(targetArg, cwd)) { + return false; + } + const kind = classifyTarget(targetArg, cwd); + if (kind === agentVitestProjectOwners.all.kind) { + return false; + } + const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, ""); + return Object.values(agentVitestProjectOwners).some( + (owner) => owner.kind === kind && isPathAtOrUnder(relative, owner.root), + ); +} + /** * Finds explicit test path targets that do not match any known project plan. */ @@ -3650,6 +3667,7 @@ export function buildVitestRunPlans( const useCliTargetArgs = kind === "e2e" || kind === "packageDocker" || + grouped.every((targetArg) => isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) || (kind === "default" && grouped.every((targetArg) => isFileLikeTarget(toRepoRelativeTarget(targetArg, cwd)))); const useWholeConfigTarget = grouped.some((targetArg) => diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index ede2fe371611..83797a9ffcdd 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -41,6 +41,7 @@ import { import type { AnyAgentTool } from "./agent-tools.types.js"; import { isApplyPatchAllowedForModel } from "./apply-patch-model-policy.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { resolveProcessToolScopeKey } from "./bash-process-scope.js"; import type { ExecToolDefaults } from "./bash-tools.exec-types.js"; import type { ProcessToolDefaults } from "./bash-tools.process.js"; import { listChannelAgentTools } from "./channel-tools.js"; @@ -110,29 +111,6 @@ import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-contex const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); -/** Resolve the process-tool isolation key for exec/process session state. */ -export function resolveProcessToolScopeKey(params: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; -}): string | undefined { - const explicitScopeKey = params.scopeKey?.trim(); - if (explicitScopeKey) { - return explicitScopeKey; - } - const sessionKey = params.sessionKey?.trim(); - if (sessionKey) { - return sessionKey; - } - const sessionId = params.sessionId?.trim(); - if (sessionId) { - return sessionId; - } - const agentId = params.agentId?.trim(); - return agentId ? `agent:${agentId}` : undefined; -} - function applyModelProviderToolPolicy( toolsInput: AnyAgentTool[], params?: { diff --git a/src/agents/bash-process-scope.test.ts b/src/agents/bash-process-scope.test.ts new file mode 100644 index 000000000000..77fe8e8124b3 --- /dev/null +++ b/src/agents/bash-process-scope.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveProcessToolScopeKey } from "./bash-process-scope.js"; + +describe("resolveProcessToolScopeKey", () => { + it.each([ + { + name: "explicit scope before session identifiers", + params: { + scopeKey: " scope:explicit ", + sessionKey: "session-key", + sessionId: "session-id", + agentId: "main", + }, + expected: "scope:explicit", + }, + { + name: "session key before session and agent ids", + params: { + scopeKey: " ", + sessionKey: " session-key ", + sessionId: "session-id", + agentId: "main", + }, + expected: "session-key", + }, + { + name: "session id before agent id", + params: { sessionKey: "\t", sessionId: " session-id ", agentId: "main" }, + expected: "session-id", + }, + { + name: "agent id fallback", + params: { sessionId: "\n", agentId: " main " }, + expected: "agent:main", + }, + { + name: "blank inputs", + params: { scopeKey: " ", sessionKey: "\t", sessionId: "\n", agentId: " " }, + expected: undefined, + }, + ])("uses $name", ({ params, expected }) => { + expect(resolveProcessToolScopeKey(params)).toBe(expected); + }); +}); diff --git a/src/agents/bash-process-scope.ts b/src/agents/bash-process-scope.ts new file mode 100644 index 000000000000..85076b9143b6 --- /dev/null +++ b/src/agents/bash-process-scope.ts @@ -0,0 +1,22 @@ +/** Resolve the process-tool isolation key for exec/process session state. */ +export function resolveProcessToolScopeKey(params: { + scopeKey?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; +}): string | undefined { + const explicitScopeKey = params.scopeKey?.trim(); + if (explicitScopeKey) { + return explicitScopeKey; + } + const sessionKey = params.sessionKey?.trim(); + if (sessionKey) { + return sessionKey; + } + const sessionId = params.sessionId?.trim(); + if (sessionId) { + return sessionId; + } + const agentId = params.agentId?.trim(); + return agentId ? `agent:${agentId}` : undefined; +} diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 7f5f93c58db0..5d7f9bc9d5ca 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -875,17 +875,6 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../agent-tools.js", () => ({ createOpenClawCodingTools: createOpenClawCodingToolsMock, - resolveProcessToolScopeKey: ({ - scopeKey, - sessionKey, - sessionId, - agentId, - }: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - }) => scopeKey ?? sessionKey ?? sessionId ?? (agentId ? `agent:${agentId}` : undefined), })); vi.doMock("./replay-history.js", () => ({ diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index 40b29f75b085..c6150fa11ccc 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -27,8 +27,9 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { createBundleLspToolRuntime } from "../agent-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../agent-bundle-mcp-tools.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; -import { createOpenClawCodingTools, resolveProcessToolScopeKey } from "../agent-tools.js"; +import { createOpenClawCodingTools } from "../agent-tools.js"; import { listActiveProcessSessionReferences } from "../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../bash-process-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun, diff --git a/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts index bdb09493155a..8a374a76c841 100644 --- a/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts @@ -12,12 +12,6 @@ import type { AgentMessage } from "../../runtime/index.js"; import { hasNonzeroUsage, normalizeUsage, type NormalizedUsage } from "../../usage.js"; import type { PromptCacheChange } from "../prompt-cache-observability.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; -export { - assembleHarnessContextEngine as assembleAttemptContextEngine, - bootstrapHarnessContextEngine as runAttemptContextEngineBootstrap, - finalizeHarnessContextEngineTurn as finalizeAttemptContextEngineTurn, -} from "../../harness/context-engine-lifecycle.js"; - export type AttemptContextEngine = ContextEngine; type AttemptBootstrapContext = { diff --git a/src/agents/embedded-agent-runner/run/attempt-finalize.ts b/src/agents/embedded-agent-runner/run/attempt-finalize.ts index 6f1466d8c128..cb0442bccc87 100644 --- a/src/agents/embedded-agent-runner/run/attempt-finalize.ts +++ b/src/agents/embedded-agent-runner/run/attempt-finalize.ts @@ -20,6 +20,7 @@ import type { createCacheTrace } from "../../cache-trace.js"; import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js"; import { isSignalTimeoutReason } from "../../failover-error.js"; import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js"; +import { finalizeHarnessContextEngineTurn } from "../../harness/context-engine-lifecycle.js"; import { runAgentCleanupStep } from "../../run-cleanup-timeout.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { AgentSession, SessionManager } from "../../sessions/index.js"; @@ -28,10 +29,7 @@ import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { log } from "../logger.js"; import { markActiveEmbeddedRunAbandoned, type EmbeddedAgentQueueHandle } from "../runs.js"; import { buildEmbeddedAgentEndContext } from "./agent-end-context.js"; -import { - finalizeAttemptContextEngineTurn, - type buildContextEnginePromptCacheInfo, -} from "./attempt-context-engine-helpers.js"; +import type { buildContextEnginePromptCacheInfo } from "./attempt-context-engine-helpers.js"; import { buildAfterTurnRuntimeContextFromUsage } from "./attempt-prompt-helpers.js"; import { shouldPersistCompletedBootstrapTurn } from "./attempt-thread-helpers.js"; import { @@ -253,7 +251,7 @@ export async function completeEmbeddedAttemptAfterTurn( sessionManager?: SessionManager; withSessionManagerRewriteLock: WithOwnedTranscriptWrite; }) => { - await finalizeAttemptContextEngineTurn({ + await finalizeHarnessContextEngineTurn({ contextEngine: activeContextEngine, promptError: Boolean(state.promptError), aborted: lifecycleState.aborted, diff --git a/src/agents/embedded-agent-runner/run/attempt-history.ts b/src/agents/embedded-agent-runner/run/attempt-history.ts index 41a30614d140..dd6e9e089853 100644 --- a/src/agents/embedded-agent-runner/run/attempt-history.ts +++ b/src/agents/embedded-agent-runner/run/attempt-history.ts @@ -24,6 +24,7 @@ import { import type { createPreparedEmbeddedAgentSettingsManager } from "../../agent-project-settings.js"; import type { createCacheTrace } from "../../cache-trace.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; +import { assembleHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { AgentSession, SessionManager } from "../../sessions/index.js"; @@ -32,10 +33,7 @@ import { resolveTranscriptPolicy, type TranscriptPolicy } from "../../transcript import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; import { sanitizeSessionHistory, validateReplayTurns } from "../replay-history.js"; -import { - assembleAttemptContextEngine, - type AttemptContextEngine, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import type { resolveOrphanRepairPlan } from "./attempt-orphan-repair.js"; import { prependSystemPromptAddition } from "./attempt-prompt-helpers.js"; import { isRunnerToolCallBlockType } from "./attempt-tool-call-block-type.js"; @@ -575,7 +573,7 @@ export async function prepareEmbeddedAttemptHistory(input: { }); const messageBudget = Math.max(1, promptBudget - renderedPromptTokens); const transcriptReadFence = attempt.userTurnTranscriptRecorder?.getAdmissionReceipt(); - const assembled = await assembleAttemptContextEngine({ + const assembled = await assembleHarnessContextEngine({ contextEngine: input.activeContextEngine, sessionId: attempt.sessionId, sessionKey: attempt.sessionKey, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts index 499d06e87d83..a45092a64fd4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts @@ -21,8 +21,8 @@ import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../../sessions/input-provenance.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { truncateUtf16Safe } from "../../../utils.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { wrapPluginSystemContextSection } from "../../hook-system-context-boundary.js"; import { diff --git a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts index 55d43b1c6cf8..ee6516bf732d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts @@ -19,6 +19,7 @@ import { } from "../../agent-settings.js"; import { toToolDefinitions } from "../../agent-tool-definition-adapter.js"; import { resolveUserTimezone } from "../../date-time.js"; +import { bootstrapHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js"; import type { AgentMessage } from "../../runtime/index.js"; import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; @@ -36,10 +37,7 @@ import { log } from "../logger.js"; import { createEmbeddedAgentResourceLoader } from "../resource-loader.js"; import { applySystemPromptToSession } from "../system-prompt.js"; import { prepareEmbeddedAttemptClientTools } from "./attempt-client-tools.js"; -import { - type AttemptContextEngine, - runAttemptContextEngineBootstrap, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import { resolveAttemptTranscriptPolicy } from "./attempt-history.js"; import { normalizeMessagesForLlmBoundary } from "./attempt-llm-boundary.js"; import { @@ -484,7 +482,7 @@ export async function prepareEmbeddedAttemptSessionManager(input: { input.onSessionManagerCreated(sessionManager); await input.withOwnedTranscriptWrite(async () => { - await runAttemptContextEngineBootstrap({ + await bootstrapHarnessContextEngine({ hadSessionFile: transcriptState.hasBootstrapTranscriptState, contextEngine: input.activeContextEngine, sessionId: attempt.sessionId, 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 bd43014ac263..2e4df4e01e80 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 @@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; -import { expect, vi, type Mock } from "vitest"; +import { vi, type Mock } from "vitest"; import type { AssembleResult, BootstrapResult, @@ -667,17 +667,6 @@ vi.mock("../../cache-trace.js", () => ({ vi.mock("../../agent-tools.js", () => ({ createOpenClawCodingTools: (options?: { workspaceDir?: string; spawnWorkspaceDir?: string }) => hoisted.createOpenClawCodingToolsMock(options), - resolveProcessToolScopeKey: ({ - scopeKey, - sessionKey, - sessionId, - agentId, - }: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - }) => scopeKey ?? sessionKey ?? sessionId ?? (agentId ? `agent:${agentId}` : undefined), resolveToolLoopDetectionConfig: () => undefined, })); @@ -1238,10 +1227,6 @@ export function createContextEngineBootstrapAndAssemble() { }; } -export function expectCalledWithSessionKey(mock: ReturnType, sessionKey: string) { - expect(mock).toHaveBeenCalledWith(expect.objectContaining({ sessionKey })); -} - const testModel = { api: "openai-completions", provider: "openai", 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 8cbc9fb547b2..aa895dbe2c62 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 @@ -10,8 +10,8 @@ import { } from "../../../plugins/provider-runtime.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { buildBootstrapPromptWarningNotice, buildBootstrapTruncationReportMeta, diff --git a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts index 3e8d263bfbd9..a54c5a033665 100644 --- a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { AssistantMessage } from "../../../llm/types.js"; -import { findLatestUncompactedAttemptUsageSnapshot } from "./attempt-context-engine-helpers.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import { + buildContextEnginePromptCacheInfo, + buildLoopPromptCacheInfo, + findLatestUncompactedAttemptUsageSnapshot, + resolvePromptCacheTouchTimestamp, +} from "./attempt-context-engine-helpers.js"; const ASSISTANT_WITH_USAGE = { role: "assistant", @@ -41,3 +47,122 @@ describe("findLatestUncompactedAttemptUsageSnapshot", () => { ).toBeUndefined(); }); }); + +describe("context-engine prompt cache metadata", () => { + const seedMessage = { role: "user", content: "seed", timestamp: 1 } as AgentMessage; + + it("builds retention, last-call usage, and cache-touch metadata", () => { + expect( + buildContextEnginePromptCacheInfo({ + retention: "short", + lastCallUsage: { input: 10, output: 5, cacheRead: 40, cacheWrite: 2, total: 57 }, + lastCacheTouchAt: 123, + }), + ).toEqual({ + retention: "short", + lastCallUsage: { input: 10, output: 5, cacheRead: 40, cacheWrite: 2, total: 57 }, + lastCacheTouchAt: 123, + }); + }); + + it("omits metadata when no cache data is available", () => { + expect(buildContextEnginePromptCacheInfo({})).toBeUndefined(); + }); + + it("does not reuse a prior turn's usage when the current attempt has no assistant", () => { + const priorAssistant = { + role: "assistant", + content: "prior turn", + timestamp: 2, + usage: { input: 99, output: 7, cacheRead: 1234, total: 1340 }, + } as unknown as AgentMessage; + + expect( + buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, priorAssistant], + prePromptMessageCount: 2, + retention: "short", + }), + ).toEqual({ retention: "short" }); + }); + + it("derives live loop metadata from the current attempt assistant", () => { + const assistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 1, output: 2, cacheRead: 39036, cacheWrite: 59934, total: 98973 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, assistant], + prePromptMessageCount: 1, + retention: "short", + fallbackLastCacheTouchAt: 123, + }); + expect(promptCache?.retention).toBe("short"); + expect(promptCache?.lastCallUsage).toMatchObject({ + cacheRead: 39036, + cacheWrite: 59934, + total: 98973, + }); + expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); + }); + + it("keeps the latest nonzero usage when an aborted assistant reports zeros", () => { + const completedAssistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 38_333, output: 66, cacheRead: 120_320, total: 158_719 }, + } as unknown as AgentMessage; + const abortedAssistant = { + role: "assistant", + content: "", + timestamp: "2026-04-16T16:50:00.000Z", + stopReason: "aborted", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, completedAssistant, abortedAssistant], + prePromptMessageCount: 1, + retention: "short", + }); + expect(promptCache?.lastCallUsage).toMatchObject({ + input: 38_333, + cacheRead: 120_320, + total: 158_719, + }); + expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); + }); + + it("falls back to the persisted cache touch when loop usage has no cache metrics", () => { + const assistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 1, output: 2, total: 3 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, assistant], + prePromptMessageCount: 1, + retention: "short", + fallbackLastCacheTouchAt: 123, + }); + expect(promptCache?.retention).toBe("short"); + expect(promptCache?.lastCallUsage?.total).toBe(3); + expect(promptCache?.lastCacheTouchAt).toBe(123); + }); + + it("derives a live cache touch timestamp for final afterTurn usage snapshots", () => { + expect( + resolvePromptCacheTouchTimestamp({ + lastCallUsage: { input: 1, output: 2, cacheRead: 39036, cacheWrite: 0, total: 39039 }, + assistantTimestamp: "2026-04-16T17:04:46.974Z", + fallbackLastCacheTouchAt: 123, + }), + ).toBe(Date.parse("2026-04-16T17:04:46.974Z")); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 3f975fac7daf..f22e3a3734de 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -11,29 +11,16 @@ import { createSessionEntryWithTranscript, } from "../../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../../config/types.js"; -import { buildMemorySystemPromptAddition } from "../../../context-engine/delegate.js"; -import { - clearMemoryPluginState, - registerTestMemoryPromptBuilder, -} from "../../../plugins/memory-state.test-fixtures.js"; +import { clearMemoryPluginState } from "../../../plugins/memory-state.test-fixtures.js"; import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js"; import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js"; import { makeAgentAssistantMessage } from "../../test-helpers/agent-message-fixtures.js"; -import { - type AttemptContextEngine, - buildLoopPromptCacheInfo, - assembleAttemptContextEngine, - buildContextEnginePromptCacheInfo, - finalizeAttemptContextEngineTurn, - resolvePromptCacheTouchTimestamp, - runAttemptContextEngineBootstrap, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import { cleanupTempPaths, createDefaultEmbeddedSession, createContextEngineBootstrapAndAssemble, createContextEngineAttemptRunner, - expectCalledWithSessionKey, getHoisted, preloadRunEmbeddedAttemptForTests, resetEmbeddedAttemptHarness, @@ -43,14 +30,12 @@ import type { MidTurnPrecheckRequest } from "./midturn-precheck.js"; const hoisted = getHoisted(); const embeddedSessionId = "embedded-session"; -const sessionFile = "/tmp/session.jsonl"; const seedMessage = { role: "user", content: "seed", timestamp: 1 } as AgentMessage; const doneMessage = { role: "assistant", content: "done", timestamp: 2 } as unknown as AgentMessage; beforeAll(async () => { await preloadRunEmbeddedAttemptForTests(); }); -type AfterTurnPromptCacheCall = { runtimeContext?: { promptCache?: Record } }; type TrajectoryEvent = { type?: string; data?: Record }; type ToolResultGuardInstallParams = { midTurnPrecheck?: { @@ -156,67 +141,6 @@ function createTestContextEngine(params: Partial): Attempt } as AttemptContextEngine; } -async function runBootstrap( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - // Shared bootstrap harness keeps session identifiers stable across context - // engine implementations. - await runAttemptContextEngineBootstrap({ - hadSessionFile: true, - contextEngine, - sessionId: embeddedSessionId, - sessionKey, - sessionFile, - sessionManager: hoisted.sessionManager, - runtimeContext: {}, - runMaintenance: hoisted.runContextEngineMaintenanceMock, - warn: () => {}, - ...overrides, - }); -} - -async function runAssemble( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - return await assembleAttemptContextEngine({ - contextEngine, - sessionId: embeddedSessionId, - sessionKey, - messages: [seedMessage], - tokenBudget: 2048, - modelId: "gpt-test", - ...overrides, - }); -} - -async function finalizeTurn( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - await finalizeAttemptContextEngineTurn({ - contextEngine, - promptError: false, - aborted: false, - yieldAborted: false, - sessionIdUsed: embeddedSessionId, - sessionKey, - sessionFile, - messagesSnapshot: [doneMessage], - prePromptMessageCount: 0, - tokenBudget: 2048, - runtimeContext: {}, - runMaintenance: hoisted.runContextEngineMaintenanceMock, - sessionManager: hoisted.sessionManager, - warn: () => {}, - ...overrides, - }); -} - describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { const sessionKey = "agent:main:guildchat:channel:test-ctx-engine"; const tempPaths: string[] = []; @@ -2678,24 +2602,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(events.slice(0, afterTurnIndex)).toContain("flush"); }); - it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {}); - const contextEngine = createTestContextEngine({ - bootstrap, - assemble, - afterTurn, - }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine); - await finalizeTurn(sessionKey, contextEngine); - - expectCalledWithSessionKey(bootstrap, sessionKey); - expectCalledWithSessionKey(assemble, sessionKey); - expectCalledWithSessionKey(afterTurn, sessionKey); - }); - it("uses SQLite transcript messages for bootstrap without treating the marker as a file", async () => { const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-sqlite-")); tempPaths.push(storeDir); @@ -2753,101 +2659,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(bootstrap).toHaveBeenCalled(); }); - it("forwards modelId to assemble", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const contextEngine = createTestContextEngine({ bootstrap, assemble }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine); - - expect(mockParams(assemble as MockCallSource, 0, "assemble params").model).toBe("gpt-test"); - }); - - it("forwards availableTools and citationsMode to assemble", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const contextEngine = createTestContextEngine({ bootstrap, assemble }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine, { - availableTools: new Set(["memory_search", "wiki_search"]), - citationsMode: "on", - }); - - expectFields(mockParams(assemble as MockCallSource, 0, "assemble params"), { - availableTools: new Set(["memory_search", "wiki_search"]), - citationsMode: "on", - }); - }); - - it("lets non-legacy engines opt into the active memory prompt helper", async () => { - registerTestMemoryPromptBuilder(({ availableTools, citationsMode }) => { - if (!availableTools.has("memory_search")) { - return []; - } - return [ - "## Memory Recall", - `tools=${[...availableTools].toSorted().join(",")}`, - `citations=${citationsMode ?? "auto"}`, - "", - ]; - }); - - const contextEngine = createTestContextEngine({ - assemble: async ({ messages, availableTools, citationsMode }) => ({ - messages, - estimatedTokens: messages.length, - systemPromptAddition: buildMemorySystemPromptAddition({ - availableTools: availableTools ?? new Set(), - citationsMode, - }), - }), - }); - - const result = await runAssemble(sessionKey, contextEngine, { - availableTools: new Set(["wiki_search", "memory_search"]), - citationsMode: "on", - }); - - const assembled = requireRecord(result, "assembled context"); - expect(assembled.estimatedTokens).toBe(1); - expect(assembled.systemPromptAddition).toBe( - "## Memory Recall\ntools=memory_search,wiki_search\ncitations=on", - ); - }); - - it("forwards sessionKey to ingestBatch when afterTurn is absent", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const ingestBatch = vi.fn( - async (_params: { sessionKey?: string; messages: AgentMessage[] }) => ({ ingestedCount: 1 }), - ); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingestBatch }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - expectCalledWithSessionKey(ingestBatch, sessionKey); - }); - - it("forwards sessionKey to per-message ingest when ingestBatch is absent", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const ingest = vi.fn(async (_params: { sessionKey?: string; message: AgentMessage }) => ({ - ingested: true, - })); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingest }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - expect(ingest).toHaveBeenCalledTimes(1); - expect(ingest).toHaveBeenCalledWith({ - message: doneMessage, - sessionId: embeddedSessionId, - sessionKey, - }); - }); - it("forwards silentExpected to the embedded subscription", async () => { await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -2904,247 +2715,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(result.didDeliverSourceReplyViaMessageTool).toBe(true); }); - it("skips maintenance when afterTurn fails", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const afterTurn = vi.fn(async () => { - throw new Error("afterTurn failed"); - }); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, afterTurn })); - - expectCalledWithSessionKey(afterTurn, sessionKey); - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "turn", - ), - ).toBe(false); - }); - - it("runs startup maintenance for existing sessions even without bootstrap()", async () => { - const { assemble } = createContextEngineBootstrapAndAssemble(); - - await runBootstrap( - sessionKey, - createTestContextEngine({ - assemble, - maintain: async () => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "test maintenance", - }), - }), - ); - - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "bootstrap", - ), - ).toBe(true); - }); - - it("builds prompt-cache retention, last-call usage, and cache-touch metadata", () => { - expect( - buildContextEnginePromptCacheInfo({ - retention: "short", - lastCallUsage: { - input: 10, - output: 5, - cacheRead: 40, - cacheWrite: 2, - total: 57, - }, - lastCacheTouchAt: 123, - }), - ).toEqual({ - retention: "short", - lastCallUsage: { - input: 10, - output: 5, - cacheRead: 40, - cacheWrite: 2, - total: 57, - }, - lastCacheTouchAt: 123, - }); - }); - - it("omits prompt-cache metadata when no cache data is available", () => { - expect(buildContextEnginePromptCacheInfo({})).toBeUndefined(); - }); - - it("does not reuse a prior turn's usage when the current attempt has no assistant", () => { - const priorAssistant = { - role: "assistant", - content: "prior turn", - timestamp: 2, - usage: { - input: 99, - output: 7, - cacheRead: 1234, - total: 1340, - }, - } as unknown as AgentMessage; - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, priorAssistant], - prePromptMessageCount: 2, - retention: "short", - }); - - expect(promptCache).toEqual({ retention: "short" }); - }); - - it("derives live loop prompt-cache info from the current attempt assistant", () => { - const toolUseAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { - input: 1, - output: 2, - cacheRead: 39036, - cacheWrite: 59934, - total: 98973, - }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, toolUseAssistant], - prePromptMessageCount: 1, - retention: "short", - fallbackLastCacheTouchAt: 123, - }); - expect(promptCache?.retention).toBe("short"); - expect(promptCache?.lastCallUsage?.cacheRead).toBe(39036); - expect(promptCache?.lastCallUsage?.cacheWrite).toBe(59934); - expect(promptCache?.lastCallUsage?.total).toBe(98973); - expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); - }); - - it("keeps the latest nonzero usage when an aborted assistant reports zeros", () => { - const completedAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { input: 38_333, output: 66, cacheRead: 120_320, total: 158_719 }, - } as unknown as AgentMessage; - const abortedAssistant = { - role: "assistant", - content: "", - timestamp: "2026-04-16T16:50:00.000Z", - stopReason: "aborted", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, completedAssistant, abortedAssistant], - prePromptMessageCount: 1, - retention: "short", - }); - - expect(promptCache?.lastCallUsage).toMatchObject({ - input: 38_333, - cacheRead: 120_320, - total: 158_719, - }); - expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); - }); - - it("falls back to the persisted cache touch when loop usage has no cache metrics", () => { - const toolUseAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { - input: 1, - output: 2, - total: 3, - }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, toolUseAssistant], - prePromptMessageCount: 1, - retention: "short", - fallbackLastCacheTouchAt: 123, - }); - expect(promptCache?.retention).toBe("short"); - expect(promptCache?.lastCallUsage?.total).toBe(3); - expect(promptCache?.lastCacheTouchAt).toBe(123); - }); - - it("derives a live cache touch timestamp for final afterTurn usage snapshots", () => { - const lastCallUsage = { - input: 1, - output: 2, - cacheRead: 39036, - cacheWrite: 0, - total: 39039, - }; - - expect( - resolvePromptCacheTouchTimestamp({ - lastCallUsage, - assistantTimestamp: "2026-04-16T17:04:46.974Z", - fallbackLastCacheTouchAt: 123, - }), - ).toBe(Date.parse("2026-04-16T17:04:46.974Z")); - }); - - it("threads prompt-cache break observations into afterTurn", async () => { - const afterTurn = vi.fn(async (_params: AfterTurnPromptCacheCall) => {}); - - await finalizeTurn(sessionKey, createTestContextEngine({ afterTurn }), { - runtimeContext: { - promptCache: { - observation: { - broke: true, - previousCacheRead: 5000, - cacheRead: 2000, - changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], - }, - }, - }, - }); - - const afterTurnCall = afterTurn.mock.calls.at(0)?.[0]; - const runtimeContext = afterTurnCall?.runtimeContext; - const observation = runtimeContext?.promptCache?.observation as - | { broke?: boolean; previousCacheRead?: number; cacheRead?: number; changes?: unknown[] } - | undefined; - - const observationRecord = requireRecord(observation, "prompt cache observation"); - expectFields(observationRecord, { - broke: true, - previousCacheRead: 5000, - cacheRead: 2000, - }); - expect( - requireRecords(observationRecord.changes, "prompt cache observation changes").some( - (change) => change.code === "systemPrompt", - ), - ).toBe(true); - }); - - it("skips maintenance when ingestBatch fails", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const ingestBatch = vi.fn(async () => { - throw new Error("ingestBatch failed"); - }); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingestBatch }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - expectCalledWithSessionKey(ingestBatch, sessionKey); - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "turn", - ), - ).toBe(false); - }); - it("disposes the session even when teardown cleanup throws", async () => { const disposeMock = vi.fn(); const flushMock = vi.fn(async () => { diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts index a7b58fbd450c..05308177d301 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -4,10 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js"; import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js"; import type { BootstrapContextRunKind } from "../../bootstrap-mode.js"; +import { assembleHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import { limitHistoryTurns } from "../history.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { - assembleAttemptContextEngine, type AttemptContextEngine, resolveAttemptBootstrapContext, } from "./attempt-context-engine-helpers.js"; @@ -232,7 +232,7 @@ describe("embedded attempt context injection", () => { HEARTBEAT_PROMPT, ); const limited = limitHistoryTurns(heartbeatFiltered, 1); - await assembleAttemptContextEngine({ + await assembleHarnessContextEngine({ contextEngine: { info: { id: "test", name: "Test", version: "0.0.1" }, ingest: async () => ({ ingested: true }), diff --git a/src/agents/embedded-agent-runner/run/compaction-runtime.ts b/src/agents/embedded-agent-runner/run/compaction-runtime.ts index cc3289924367..c54cd0ea4802 100644 --- a/src/agents/embedded-agent-runner/run/compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/run/compaction-runtime.ts @@ -4,8 +4,8 @@ import { resolveCompactionSuccessorTranscript, type ContextEngineSessionTarget, } from "../../../context-engine/types.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { compactContextEngineWithSafetyTimeout, diff --git a/src/agents/harness/context-engine-lifecycle.test.ts b/src/agents/harness/context-engine-lifecycle.test.ts index c48ecc35ad15..ef1bcb042c08 100644 --- a/src/agents/harness/context-engine-lifecycle.test.ts +++ b/src/agents/harness/context-engine-lifecycle.test.ts @@ -86,6 +86,52 @@ function uniqueConfiguredProofEngineId() { } describe("harness context engine lifecycle", () => { + it("forwards session keys across bootstrap, assemble, and afterTurn hooks", async () => { + const bootstrap = vi.fn(async () => ({ bootstrapped: true })); + const assemble = vi.fn(async (params: Parameters[0]) => ({ + messages: params.messages, + estimatedTokens: 0, + })); + const afterTurn = vi.fn(async () => {}); + const contextEngine = createContextEngine({ bootstrap, assemble, afterTurn }); + + await bootstrapHarnessContextEngine({ + hadSessionFile: true, + contextEngine, + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + runMaintenance: async () => undefined, + warn: () => {}, + }); + await assembleHarnessContextEngine({ + contextEngine, + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + messages: [textMessage("user", "ask", 1)], + modelId: "gpt-test", + }); + await finalizeHarnessContextEngineTurn({ + contextEngine, + promptError: false, + aborted: false, + yieldAborted: false, + sessionIdUsed: sessionParams.sessionIdUsed, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + messagesSnapshot: [textMessage("assistant", "done", 2)], + prePromptMessageCount: 0, + runMaintenance: async () => undefined, + warn: () => {}, + }); + + for (const hook of [bootstrap, assemble, afterTurn]) { + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: sessionParams.sessionKey }), + ); + } + }); + it("scopes async memory preparation to non-legacy assembly with sandbox context", async () => { const prepare = vi.fn(async ({ sandboxed }) => [ "## Prepared Memory", @@ -212,7 +258,15 @@ describe("harness context engine lifecycle", () => { const bootstrapRuntimeContext = { transcriptStorage: { kind: "sqlite" as const }, sessionTarget, - }; + promptCache: { + observation: { + broke: true, + previousCacheRead: 5000, + cacheRead: 2000, + changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], + }, + }, + } satisfies ContextEngineRuntimeContext; const engine = createContextEngine({ info: { id: engineId, @@ -238,6 +292,7 @@ describe("harness context engine lifecycle", () => { afterTurn: vi.fn(async (params) => { captured.push({ hook: "afterTurn", + runtimeContext: params.runtimeContext, runtimeSettings: params.runtimeSettings, sessionTarget: params.sessionTarget, }); @@ -282,6 +337,7 @@ describe("harness context engine lifecycle", () => { sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "visible ask", 1)], tokenBudget: 2048, + runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", @@ -305,6 +361,7 @@ describe("harness context engine lifecycle", () => { ], prePromptMessageCount: 2, tokenBudget: 2048, + runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", @@ -349,6 +406,9 @@ describe("harness context engine lifecycle", () => { expect(captured.find((entry) => entry.hook === "afterTurn")?.sessionTarget).toEqual( sessionTarget, ); + expect(captured.find((entry) => entry.hook === "afterTurn")?.runtimeContext).toEqual( + bootstrapRuntimeContext, + ); expect(captured.find((entry) => entry.hook === "maintain")?.sessionTarget).toEqual( sessionTarget, ); @@ -555,10 +615,11 @@ describe("harness context engine lifecycle", () => { const ingestBatchCalls = (ingestBatch as unknown as { mock: { calls: unknown[][] } }).mock .calls; const ingestBatchParams = ingestBatchCalls[0]?.[0] as - | { isHeartbeat?: boolean; messages?: AgentMessage[] } + | { isHeartbeat?: boolean; messages?: AgentMessage[]; sessionKey?: string } | undefined; expect(ingestBatchParams?.messages).toEqual([turnUser, turnAssistant]); expect(ingestBatchParams?.isHeartbeat).toBe(true); + expect(ingestBatchParams?.sessionKey).toBe(sessionParams.sessionKey); }); it("forwards heartbeat state to per-message ingest fallbacks", async () => { @@ -586,11 +647,77 @@ describe("harness context engine lifecycle", () => { const ingestCalls = (ingest as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(ingestCalls).toHaveLength(2); for (const call of ingestCalls) { - const ingestParams = call[0] as { isHeartbeat?: boolean }; + const ingestParams = call[0] as { isHeartbeat?: boolean; sessionKey?: string }; expect(ingestParams.isHeartbeat).toBe(true); + expect(ingestParams.sessionKey).toBe(sessionParams.sessionKey); } }); + it.each(["afterTurn", "ingestBatch"] as const)( + "skips turn maintenance when %s fails", + async (failingHook) => { + const runMaintenance = vi.fn(async () => undefined); + const contextEngine = createContextEngine({ + afterTurn: + failingHook === "afterTurn" + ? vi.fn(async () => { + throw new Error("afterTurn failed"); + }) + : undefined, + ingestBatch: + failingHook === "ingestBatch" + ? vi.fn(async () => { + throw new Error("ingestBatch failed"); + }) + : undefined, + }); + + await finalizeHarnessContextEngineTurn({ + contextEngine, + promptError: false, + aborted: false, + yieldAborted: false, + sessionIdUsed: sessionParams.sessionIdUsed, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + messagesSnapshot: [textMessage("assistant", "done", 1)], + prePromptMessageCount: 0, + runMaintenance, + warn: () => {}, + }); + + expect(runMaintenance).not.toHaveBeenCalled(); + }, + ); + + it("runs bootstrap maintenance for existing sessions without bootstrap()", async () => { + const runMaintenance = vi.fn(async () => undefined); + + await bootstrapHarnessContextEngine({ + hadSessionFile: true, + contextEngine: createContextEngine({ + bootstrap: undefined, + maintain: vi.fn(async () => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })), + }), + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + runMaintenance, + warn: () => {}, + }); + + expect(runMaintenance).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "bootstrap", + sessionKey: sessionParams.sessionKey, + }), + ); + }); + it.each([ { promptError: true, aborted: false, yieldAborted: false }, { promptError: false, aborted: true, yieldAborted: false }, diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index b6e077bfabed..9bbc06a69326 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -348,11 +348,11 @@ describe("scripts/run-vitest", () => { [["run", file], [file]], [ ["run", file, "--reporter=verbose"], - [file, "--reporter=verbose"], + [file, "--", "--reporter=verbose"], ], [ ["--reporter=verbose", "run", file], - ["--reporter=verbose", file], + [file, "--", "--reporter=verbose"], ], [ ["run", file, "--", "--watch"], @@ -373,6 +373,7 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs([file])).toEqual([file]); expect(resolveTestProjectsDelegationArgs(["run", file, "--reporter=verbose"])).toEqual([ file, + "--", "--reporter=verbose", ]); }); @@ -381,7 +382,7 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs(["test/scripts"])).toEqual(["test/scripts"]); expect( resolveTestProjectsDelegationArgs(["run", "test/scripts", "--reporter=verbose"]), - ).toEqual(["test/scripts", "--reporter=verbose"]); + ).toEqual(["test/scripts", "--", "--reporter=verbose"]); expect(resolveTestProjectsDelegationArgs(["test/scripts/*.test.ts"])).toEqual([ "test/scripts/*.test.ts", ]); @@ -392,6 +393,15 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs([prefix])).toEqual([prefix]); }); + it("delegates owned agent directories with separate Vitest option values", () => { + const directory = "src/agents/embedded-agent-runner/run"; + + expect(resolveTestProjectsDelegationArgs([directory])).toEqual([directory]); + expect( + resolveTestProjectsDelegationArgs([directory, "--sequence.shuffle", "--sequence.seed", "3"]), + ).toEqual([directory, "--", "--sequence.shuffle", "--sequence.seed", "3"]); + }); + it("delegates mixed filters when an explicit file target is present", () => { expect( resolveTestProjectsDelegationArgs(["src/agents", "test/scripts/run-vitest.test.ts"]), @@ -420,15 +430,29 @@ describe("scripts/run-vitest", () => { ["--run=false", "test/scripts/run-vitest.test.ts"], ["--no-run", "test/scripts/run-vitest.test.ts"], ["--run", "false", "test/scripts/run-vitest.test.ts"], - ["--diff", "scripts/run-vitest.mjs"], - ["--testNamePattern", "run", "test/scripts/run-vitest.test.ts"], - ["run", "test/scripts/run-vitest.test.ts", "-t", "src"], ]; for (const argv of directArgvCases) { expect(resolveTestProjectsDelegationArgs(argv)).toBeNull(); } }); + it.each([ + [ + ["--diff", "scripts/run-vitest.mjs", "test/scripts/run-vitest.test.ts"], + ["test/scripts/run-vitest.test.ts", "--", "--diff", "scripts/run-vitest.mjs"], + ], + [ + ["--testNamePattern", "run", "test/scripts/run-vitest.test.ts"], + ["test/scripts/run-vitest.test.ts", "--", "--testNamePattern", "run"], + ], + [ + ["run", "test/scripts/run-vitest.test.ts", "-t", "src"], + ["test/scripts/run-vitest.test.ts", "--", "-t", "src"], + ], + ])("keeps option value %j out of project target classification", (argv, expected) => { + expect(resolveTestProjectsDelegationArgs(argv)).toEqual(expected); + }); + it("reports missing explicit test files before Vitest can silently ignore them", () => { const fsImpl = { existsSync: (filePath: string) => diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 627ebd8ae902..d1366894e5c2 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1048,21 +1048,29 @@ describe("scripts/test-projects changed-target routing", () => { ["src/agents/runtime-plan", "test/vitest/vitest.agents-support.config.ts"], ["src/agents/tools", "test/vitest/vitest.agents-tools.config.ts"], ])("routes focused agent directory %s to its owning shard", (directory, config) => { - const plans = buildVitestRunPlans([directory]); + expect(buildVitestRunPlans([directory])).toEqual([ + { + config, + forwardedArgs: [directory], + includePatterns: null, + watchMode: false, + }, + ]); + }); - expect(plans).toEqual( - expect.arrayContaining([ - { - config, - forwardedArgs: [], - includePatterns: [`${directory}/**/*.test.ts`], - watchMode: false, - }, - ]), - ); - expect(plans.map((plan) => plan.config)).not.toContain( - "test/vitest/vitest.agents-core.config.ts", - ); + it("keeps shuffle options on the single owning embedded-run shard", () => { + const directory = "src/agents/embedded-agent-runner/run"; + + expect( + buildVitestRunPlans([directory, "--", "--sequence.shuffle", "--sequence.seed", "3"]), + ).toEqual([ + { + config: "test/vitest/vitest.agents-embedded-agent-run.config.ts", + forwardedArgs: ["--sequence.shuffle", "--sequence.seed", "3", directory], + includePatterns: null, + watchMode: false, + }, + ]); }); it("splits the embedded-agent parent directory across every isolated harness", () => { From bfe1f33ea0ec3ccc5bb8738eb1dfc2b6f8dc0524 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:46:35 -0700 Subject: [PATCH 061/165] improve(ui): make Control UI feel native on mobile (#122492) * improve(ui): make Control UI feel native on mobile * fix(ui): keep coarse-pointer input floor text-scale aware * fix(ui): let self-sized controls opt out of the touch input floor * fix(ui): fold per-control coarse-pointer font floors into the shared touch floor --- ui/config/control-ui-hover-guard.ts | 38 ++++++++++++ ui/index.html | 3 + ui/src/app/bootstrap.test.ts | 28 +++++++++ ui/src/app/bootstrap.ts | 7 +++ .../app/control-ui-hover-guard.node.test.ts | 58 +++++++++++++++++++ .../components/form-controls.browser.test.ts | 4 ++ ui/src/styles/base.css | 26 +++++++++ ui/src/styles/chat/layout.css | 8 +-- ui/src/styles/chat/sidebar.css | 8 +-- ui/src/styles/components.css | 4 -- ui/src/styles/layout.css | 16 ++--- ui/src/styles/settings.css | 2 + ui/vite.config.ts | 6 ++ 13 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 ui/config/control-ui-hover-guard.ts create mode 100644 ui/src/app/control-ui-hover-guard.node.test.ts diff --git a/ui/config/control-ui-hover-guard.ts b/ui/config/control-ui-hover-guard.ts new file mode 100644 index 000000000000..6f03bb826afb --- /dev/null +++ b/ui/config/control-ui-hover-guard.ts @@ -0,0 +1,38 @@ +import type { AnyNode, Plugin, Rule } from "postcss"; + +function isHoverGuarded(rule: Rule): boolean { + let ancestor: AnyNode | undefined = rule.parent; + while (ancestor) { + if (ancestor.type === "atrule" && ancestor.params.includes("hover:")) { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + +export function controlUiHoverGuardPlugin(): Plugin { + return { + postcssPlugin: "control-ui-hover-guard", + Rule(rule, { AtRule }) { + if (!rule.selector.includes(":hover") || isHoverGuarded(rule)) { + return; + } + + const hoverSelectors = rule.selectors.filter((selector) => selector.includes(":hover")); + const otherSelectors = rule.selectors.filter((selector) => !selector.includes(":hover")); + const hoverRule = rule.clone(); + hoverRule.selectors = hoverSelectors; + const guard = new AtRule({ name: "media", params: "(hover: hover)" }); + guard.append(hoverRule); + + if (otherSelectors.length === 0) { + rule.replaceWith(guard); + return; + } + + rule.selectors = otherSelectors; + rule.after(guard); + }, + }; +} diff --git a/ui/index.html b/ui/index.html index fd112516b95e..131fc1622245 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,9 @@ /> OpenClaw Control + + + diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index 173c9d16f768..a137178fc5c4 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -778,4 +778,32 @@ describe("normalizeInitialApplicationLocation", () => { window.history.replaceState({}, "", previousUrl); } }); + + it("synchronizes every theme-color meta with the resolved theme background", () => { + const previousSettings = loadSettings(); + const style = document.createElement("style"); + style.textContent = ':root[data-theme="light"] { --bg: #123456; }'; + const lightMeta = document.createElement("meta"); + lightMeta.name = "theme-color"; + lightMeta.media = "(prefers-color-scheme: light)"; + const darkMeta = document.createElement("meta"); + darkMeta.name = "theme-color"; + darkMeta.media = "(prefers-color-scheme: dark)"; + document.head.append(style, lightMeta, darkMeta); + saveSettings({ ...previousSettings, theme: "claw", themeMode: "light" }); + const runtime = bootstrapApplication({ sessionPathBuilderReady: deferred().promise }); + + try { + expect(lightMeta.content).toBe("#123456"); + expect(darkMeta.content).toBe("#123456"); + expect(lightMeta.hasAttribute("media")).toBe(false); + expect(darkMeta.hasAttribute("media")).toBe(false); + } finally { + runtime.stop(); + style.remove(); + lightMeta.remove(); + darkMeta.remove(); + saveSettings(previousSettings); + } + }); }); diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 82426d02697f..678a6cff2180 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -76,6 +76,13 @@ function applyThemePresentation(settings: ReturnType): void root.style.colorScheme = root.dataset.themeMode; root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`); syncCustomThemeStyleTag(settings.customTheme); + const background = getComputedStyle(root).getPropertyValue("--bg").trim(); + if (background) { + for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { + meta.content = background; + meta.removeAttribute("media"); + } + } } function createApplicationTheme( diff --git a/ui/src/app/control-ui-hover-guard.node.test.ts b/ui/src/app/control-ui-hover-guard.node.test.ts new file mode 100644 index 000000000000..ac5d988be24a --- /dev/null +++ b/ui/src/app/control-ui-hover-guard.node.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +import postcss, { type AtRule, type Rule } from "postcss"; +import { describe, expect, it } from "vitest"; +import { controlUiHoverGuardPlugin } from "../../config/control-ui-hover-guard.ts"; + +async function transform(css: string) { + return postcss([controlUiHoverGuardPlugin()]).process(css, { from: undefined }); +} + +function requireRule(node: unknown): Rule { + expect(node).toMatchObject({ type: "rule" }); + return node as Rule; +} + +function requireAtRule(node: unknown): AtRule { + expect(node).toMatchObject({ type: "atrule" }); + return node as AtRule; +} + +describe("Control UI hover guard", () => { + it("wraps a hover rule in a hover-capable media query", async () => { + const result = await transform(".button:hover { color: red; }"); + const guard = requireAtRule(result.root.first); + + expect(guard.params).toBe("(hover: hover)"); + expect(requireRule(guard.first).selector).toBe(".button:hover"); + }); + + it("splits mixed selector lists without moving non-hover selectors", async () => { + const result = await transform(".a:hover, .b:focus { color: red; }"); + const [original, guard] = result.root.nodes; + + expect(requireRule(original).selector).toBe(".b:focus"); + expect(requireRule(requireAtRule(guard).first).selector).toBe(".a:hover"); + }); + + it("does not double-wrap an already guarded hover rule", async () => { + const css = "@media (hover: hover) { .a:hover { color: red; } }"; + + expect((await transform(css)).css).toBe(css); + }); + + it("preserves an outer media condition around the hover guard", async () => { + const result = await transform("@media (max-width: 768px) { .a:hover { color: red; } }"); + const outer = requireAtRule(result.root.first); + const guard = requireAtRule(outer.first); + + expect(outer.params).toBe("(max-width: 768px)"); + expect(guard.params).toBe("(hover: hover)"); + expect(requireRule(guard.first).selector).toBe(".a:hover"); + }); + + it("passes CSS without hover selectors through byte-identically", async () => { + const css = ".button:focus { color: red; }\n"; + + expect((await transform(css)).css).toBe(css); + }); +}); diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts index 4a567d358113..8fdc2658c3f3 100644 --- a/ui/src/components/form-controls.browser.test.ts +++ b/ui/src/components/form-controls.browser.test.ts @@ -485,6 +485,7 @@ describeBrowserLayout("app chrome interaction styles", () => { Mobile navigation +