diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index fb50e914e0d..4d734fb95c2 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -247,14 +247,117 @@ jobs: trap append_telegram_summary EXIT if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then - mapfile -t package_tgzs < <(find .artifacts/telegram-package-under-test -type f -name "*.tgz" | sort) - if [[ "${#package_tgzs[@]}" -ne 1 ]]; then - echo "package artifact ${PACKAGE_ARTIFACT_NAME} must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2 - exit 1 + package_dir=".artifacts/telegram-package-under-test" + manifest="${package_dir}/preflight-manifest.json" + if [[ -f "${manifest}" ]]; then + package_tgz="$( + node - "${manifest}" "${package_dir}" <<'NODE' + const crypto = require("node:crypto"); + const childProcess = require("node:child_process"); + const fs = require("node:fs"); + const path = require("node:path"); + const [manifestPath, packageDir] = process.argv.slice(2); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const entries = [ + { + packageName: "openclaw", + packageVersion: manifest.packageVersion, + tarballName: manifest.tarballName, + tarballSha256: manifest.tarballSha256, + }, + ...(Array.isArray(manifest.dependencyTarballs) ? manifest.dependencyTarballs : []), + ]; + const packageNames = new Set(); + const tarballNames = new Set(); + for (const entry of entries) { + if ( + !entry.packageName || + !entry.packageVersion || + !entry.tarballName || + !entry.tarballSha256 || + entry.tarballName !== path.basename(entry.tarballName) + ) { + throw new Error("package artifact manifest contains invalid tarball metadata"); + } + if (packageNames.has(entry.packageName) || tarballNames.has(entry.tarballName)) { + throw new Error("package artifact manifest contains duplicate package metadata"); + } + packageNames.add(entry.packageName); + tarballNames.add(entry.tarballName); + const tarballPath = path.join(packageDir, entry.tarballName); + if (!fs.existsSync(tarballPath)) { + throw new Error(`package artifact is missing ${entry.tarballName}`); + } + const digest = crypto.createHash("sha256").update(fs.readFileSync(tarballPath)).digest("hex"); + if (digest !== entry.tarballSha256) { + throw new Error(`package artifact digest mismatch for ${entry.packageName}`); + } + const packageJson = JSON.parse( + childProcess.execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + }), + ); + if (packageJson.name !== entry.packageName || packageJson.version !== entry.packageVersion) { + throw new Error(`package artifact metadata mismatch for ${entry.packageName}`); + } + } + const artifactTarballs = fs + .readdirSync(packageDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".tgz")) + .map((entry) => entry.name); + if ( + artifactTarballs.length !== tarballNames.size || + artifactTarballs.some((name) => !tarballNames.has(name)) + ) { + throw new Error("package artifact tarball set does not match preflight manifest"); + } + process.stdout.write(path.join(packageDir, manifest.tarballName)); + NODE + )" + export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}" + else + mapfile -t package_tgzs < <(find "${package_dir}" -type f -name "*.tgz" | sort) + if [[ "${#package_tgzs[@]}" -ne 1 ]]; then + echo "package artifact ${PACKAGE_ARTIFACT_NAME} without a preflight manifest must contain exactly one tgz; found ${#package_tgzs[@]}" >&2 + exit 1 + fi + package_tgz="${package_tgzs[0]}" + candidate_manifest="${package_dir}/package-candidate.json" + node - "${package_tgz}" "${candidate_manifest}" <<'NODE' + const crypto = require("node:crypto"); + const childProcess = require("node:child_process"); + const fs = require("node:fs"); + const path = require("node:path"); + const [tarballPath, manifestPath] = process.argv.slice(2); + const packageJson = JSON.parse( + childProcess.execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + }), + ); + if (packageJson.name !== "openclaw" || typeof packageJson.version !== "string") { + throw new Error("package artifact tgz is not a versioned openclaw package"); + } + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const expectedTarballName = path.basename(manifest.tarball || ""); + if ( + manifest.name !== "openclaw" || + manifest.version !== packageJson.version || + expectedTarballName !== path.basename(tarballPath) || + typeof manifest.sha256 !== "string" + ) { + throw new Error("package candidate manifest does not match the OpenClaw tarball"); + } + const digest = crypto.createHash("sha256").update(fs.readFileSync(tarballPath)).digest("hex"); + if (digest !== manifest.sha256) { + throw new Error("package candidate digest mismatch"); + } + } + NODE fi - export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}" + export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}" if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then - export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgzs[0]}")" + export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgz}")" fi elif [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}" diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index f1f8f423850..612dad0cb94 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -396,17 +396,33 @@ jobs: fi TARBALL_NAME="$PACK_NAME" TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')" + mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort) + if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one packed @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2 + exit 1 + fi + AI_TARBALL_PATH="${AI_TARBALLS[0]}" + AI_TARBALL_NAME="$(basename "$AI_TARBALL_PATH")" + AI_TARBALL_SHA256="$(sha256sum "$AI_TARBALL_PATH" | awk '{print $1}')" + AI_PACKAGE_VERSION="$( + tar -xOf "$AI_TARBALL_PATH" package/package.json | + node -e 'let raw = ""; process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => process.stdout.write(JSON.parse(raw).version));' + )" + if [[ "$AI_PACKAGE_VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Prepared @openclaw/ai version ${AI_PACKAGE_VERSION} does not match openclaw ${PACKAGE_VERSION}." >&2 + exit 1 + fi ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" cp "$PACK_PATH" "$ARTIFACT_DIR/" - cp "$AI_RUNTIME_TARBALL_DIR"/*.tgz "$ARTIFACT_DIR/" + cp "$AI_TARBALL_PATH" "$ARTIFACT_DIR/" cp "$AI_RUNTIME_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/ai-runtime-SHA256SUMS" cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence" printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt" printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt" printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt" - ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" node <<'NODE' + ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" AI_PACKAGE_VERSION="$AI_PACKAGE_VERSION" AI_TARBALL_NAME="$AI_TARBALL_NAME" AI_TARBALL_SHA256="$AI_TARBALL_SHA256" node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const manifest = { @@ -418,6 +434,14 @@ jobs: packageVersion: process.env.PACKAGE_VERSION, tarballName: process.env.TARBALL_NAME, tarballSha256: process.env.TARBALL_SHA256, + dependencyTarballs: [ + { + packageName: "@openclaw/ai", + packageVersion: process.env.AI_PACKAGE_VERSION, + tarballName: process.env.AI_TARBALL_NAME, + tarballSha256: process.env.AI_TARBALL_SHA256, + }, + ], dependencyEvidenceDir: "dependency-evidence", dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json", }; diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ee6f57d5a50..85ed92fc62f 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -131,7 +131,7 @@ }, { "kind": "ui-named-argument", - "line": 199, + "line": 247, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt", "source": "OPENCLAW", "surface": "android", @@ -139,7 +139,7 @@ }, { "kind": "ui-state-text", - "line": 138, + "line": 143, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -147,7 +147,7 @@ }, { "kind": "ui-state-text", - "line": 238, + "line": 243, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -155,7 +155,7 @@ }, { "kind": "ui-state-text", - "line": 248, + "line": 253, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -379,7 +379,7 @@ }, { "kind": "ui-state-text", - "line": 86, + "line": 106, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt", "source": "Searching…", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 1ec008c8fa2..fd2a1aca187 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -42,16 +42,15 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester private var initializedViewModel: MainViewModel? = null - private var didAttachRuntimeUi = false - private var didStartNodeService = false private var didStartViewModelCollectors = false private var foreground = false - private var pendingIntent: Intent? = null + private val pendingIntentRouter = MainActivityPendingIntentRouter() + private val runtimeUiStarter = MainActivityRuntimeUiStarter() private var screenshotScene: AndroidScreenshotScene? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pendingIntent = intent + pendingIntentRouter.setInitialIntent(intent) WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) if (BuildConfig.DEBUG) { @@ -111,8 +110,9 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) setIntent(intent) - pendingIntent = intent - initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) } + pendingIntentRouter.onNewIntent(intent) { routedIntent -> + initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = routedIntent) } + } } /** @@ -123,9 +123,8 @@ class MainActivity : ComponentActivity() { initializedViewModel = readyViewModel readyViewModel.setForeground(foreground) startViewModelCollectors(readyViewModel) - pendingIntent?.let { initialIntent -> + pendingIntentRouter.activate { initialIntent -> handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent) - pendingIntent = null } } @@ -151,18 +150,17 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { readyViewModel.runtimeInitialized.collect { ready -> - if (!ready || didAttachRuntimeUi) return@collect - if (screenshotScene != null) { - didAttachRuntimeUi = true - return@collect - } - // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. - readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) - didAttachRuntimeUi = true - if (!didStartNodeService) { - NodeForegroundService.start(this@MainActivity) - didStartNodeService = true - } + runtimeUiStarter.onRuntimeInitialized( + ready = ready, + startRuntimeUi = screenshotScene == null, + attachRuntimeUi = { + // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. + readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + }, + startNodeService = { + NodeForegroundService.start(this@MainActivity) + }, + ) } } } @@ -184,6 +182,56 @@ class MainActivity : ComponentActivity() { } } +/** Holds launch intents until ViewModel activation, then routes every later intent immediately. */ +internal class MainActivityPendingIntentRouter { + private var activated = false + private var pendingIntent: Intent? = null + + fun setInitialIntent(intent: Intent?) { + if (!activated) pendingIntent = intent + } + + fun onNewIntent( + intent: Intent, + routeIntent: (Intent) -> Unit, + ) { + if (activated) { + routeIntent(intent) + return + } + pendingIntent = intent + } + + fun activate(routeIntent: (Intent) -> Unit): Boolean { + if (activated) return false + activated = true + pendingIntent?.let(routeIntent) + pendingIntent = null + return true + } +} + +/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */ +internal class MainActivityRuntimeUiStarter { + private var completed = false + + fun onRuntimeInitialized( + ready: Boolean, + startRuntimeUi: Boolean, + attachRuntimeUi: () -> Unit, + startNodeService: () -> Unit, + ) { + if (!ready || completed) return + if (!startRuntimeUi) { + completed = true + return + } + attachRuntimeUi() + completed = true + startNodeService() + } +} + @Composable private fun StartupSurface() { Surface( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 1d455944100..ff6ac8d2397 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -46,6 +46,11 @@ data class ChatDraft( val placement: ChatDraftPlacement, ) +internal fun shouldStartRuntimeOnForeground( + foreground: Boolean, + onboardingCompleted: Boolean, +): Boolean = foreground && onboardingCompleted + /** * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows. */ @@ -298,7 +303,12 @@ class MainViewModel( */ fun setForeground(value: Boolean) { foreground = value - if (value && prefs.onboardingCompleted.value) { + if ( + shouldStartRuntimeOnForeground( + foreground = value, + onboardingCompleted = prefs.onboardingCompleted.value, + ) + ) { queueRuntimeStartup() } runtimeRef.value?.setForeground(value) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt index d57c103085c..f0ec327bef5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt @@ -62,6 +62,26 @@ private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolve @Suppress("DEPRECATION") private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance() +internal fun gatewayDiscoveryStatusText( + localCount: Int, + wideAreaRcode: Int?, + wideAreaCount: Int, +): String { + val wide = + when (wideAreaRcode) { + null -> "Wide: ?" + Rcode.NOERROR -> "Wide: $wideAreaCount" + Rcode.NXDOMAIN -> "Wide: NXDOMAIN" + else -> "Wide: ${Rcode.string(wideAreaRcode)}" + } + + return when { + localCount == 0 && wideAreaRcode == null -> "Searching for gateways…" + localCount == 0 -> wide + else -> "Local: $localCount • $wide" + } +} + /** * Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways. */ @@ -306,27 +326,12 @@ class GatewayDiscovery( _gateways.value = // Merge local and wide-area results deterministically for stable UI selection. (localById.values + unicastById.values).sortedBy { it.name.lowercase() } - _statusText.value = buildStatusText() - } - - private fun buildStatusText(): String { - val localCount = localById.size - val wideRcode = lastWideAreaRcode - val wideCount = lastWideAreaCount - - val wide = - when (wideRcode) { - null -> "Wide: ?" - Rcode.NOERROR -> "Wide: $wideCount" - Rcode.NXDOMAIN -> "Wide: NXDOMAIN" - else -> "Wide: ${Rcode.string(wideRcode)}" - } - - return when { - localCount == 0 && wideRcode == null -> "Searching for gateways…" - localCount == 0 -> "$wide" - else -> "Local: $localCount • $wide" - } + _statusText.value = + gatewayDiscoveryStatusText( + localCount = localById.size, + wideAreaRcode = lastWideAreaRcode, + wideAreaCount = lastWideAreaCount, + ) } private fun stableId( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt new file mode 100644 index 00000000000..a7174ad3234 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt @@ -0,0 +1,95 @@ +package ai.openclaw.app + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MainActivityLifecycleTest { + @Test + fun pendingIntentRouterUsesLatestIntentBeforeActivation() { + val router = MainActivityPendingIntentRouter() + val initial = Intent("initial") + val replacement = Intent("replacement") + val routed = mutableListOf() + + router.setInitialIntent(initial) + router.onNewIntent(replacement, routed::add) + + assertTrue(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + assertFalse(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + } + + @Test + fun pendingIntentRouterRoutesImmediatelyAfterActivation() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + val next = Intent("next") + + assertTrue(router.activate(routed::add)) + router.onNewIntent(next, routed::add) + router.setInitialIntent(Intent("ignored")) + + assertEquals(listOf(next), routed) + } + + @Test + fun runtimeUiStarterWaitsForReadinessAndStartsOnce() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = false, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(1, attachCount) + assertEquals(1, serviceCount) + } + + @Test + fun runtimeUiStarterCompletesWithoutSideEffectsForScreenshotFixture() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = false, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(0, attachCount) + assertEquals(0, serviceCount) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt new file mode 100644 index 00000000000..dd03d780043 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.app + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MainViewModelTest { + @Test + fun foregroundStartupRequiresForegroundAndCompletedOnboarding() { + assertFalse( + shouldStartRuntimeOnForeground( + foreground = false, + onboardingCompleted = true, + ), + ) + assertFalse( + shouldStartRuntimeOnForeground( + foreground = true, + onboardingCompleted = false, + ), + ) + assertFalse( + shouldStartRuntimeOnForeground( + foreground = false, + onboardingCompleted = false, + ), + ) + assertTrue( + shouldStartRuntimeOnForeground( + foreground = true, + onboardingCompleted = true, + ), + ) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt new file mode 100644 index 00000000000..e8b2f78e598 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt @@ -0,0 +1,68 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xbill.DNS.Rcode + +class GatewayDiscoveryTest { + @Test + fun statusTextFormatsLocalAndWideAreaDiscoveryStates() { + val cases = + listOf( + StatusCase( + localCount = 0, + wideAreaRcode = null, + wideAreaCount = 0, + expected = "Searching for gateways…", + ), + StatusCase( + localCount = 0, + wideAreaRcode = Rcode.NOERROR, + wideAreaCount = 2, + expected = "Wide: 2", + ), + StatusCase( + localCount = 1, + wideAreaRcode = Rcode.NOERROR, + wideAreaCount = 2, + expected = "Local: 1 • Wide: 2", + ), + StatusCase( + localCount = 1, + wideAreaRcode = null, + wideAreaCount = 0, + expected = "Local: 1 • Wide: ?", + ), + StatusCase( + localCount = 0, + wideAreaRcode = Rcode.NXDOMAIN, + wideAreaCount = 0, + expected = "Wide: NXDOMAIN", + ), + StatusCase( + localCount = 2, + wideAreaRcode = Rcode.SERVFAIL, + wideAreaCount = 0, + expected = "Local: 2 • Wide: SERVFAIL", + ), + ) + + for (case in cases) { + assertEquals( + case.expected, + gatewayDiscoveryStatusText( + localCount = case.localCount, + wideAreaRcode = case.wideAreaRcode, + wideAreaCount = case.wideAreaCount, + ), + ) + } + } +} + +private data class StatusCase( + val localCount: Int, + val wideAreaRcode: Int?, + val wideAreaCount: Int, + val expected: String, +) diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 3f3c0cdb319..2243b9a5e72 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -259,6 +259,11 @@ STT and TTS support two-level configuration with priority fallback: Set `enabled: false` on either to disable. Account-level TTS overrides use the same shape as `messages.tts` and deep-merge over channel/global TTS config. +STT requests time out after 60 seconds by default. Plugin-specific STT uses the +selected `models.providers..timeoutSeconds` override. Framework audio STT +uses `tools.media.audio.models[0].timeoutSeconds`, then +`tools.media.audio.timeoutSeconds`, then the selected provider override. + Inbound QQ voice attachments are exposed to agents as audio media metadata while keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]` in a plain-text reply synthesizes TTS and sends a native QQ voice message when diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 6aee5660ccd..9158ea74265 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -47,7 +47,9 @@ A nonzero exit aborts creation and removes the new worktree and branch. This is Start an isolated chat from the active agent's git workspace with **New chat in worktree**: use the secondary New Chat action in the Control UI sidebar, the Chat actions menu on iOS, or the overflow action beside New Chat on Android. The action is available only for a git-backed agent where the client has that capability; clients that cannot preflight it surface the gateway error instead. -Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the chip leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. +Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything, while a Gateway-backed TUI shows an interactive prompt with the same actions. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the suggestion leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. + +OpenClaw exposes these tools only to operator sessions with an actionable Gateway UI. Channel sessions and local/embedded TUI sessions do not receive them until those surfaces have a portable typed task-action contract. The resulting managed worktree is owned by the session, and every agent run in that session uses its checkout. When the workspace is a repository subdirectory, the worktree is anchored at the repository root and the session runs from the matching subdirectory inside it. Session worktree creation uses the method's `operator.write` scope, but the `.openclaw/worktree-setup.sh` step runs only for `operator.admin` callers because it executes repository code; `.worktreeinclude` provisioning still applies to every caller. Deleting the session removes the worktree only when doing so is lossless. Dirty worktrees or branches with unpushed commits stay available; hourly cleanup snapshots session worktrees after 7 idle days, treating recent session activity as worktree activity. Removed worktrees remain restorable from their snapshots as described below. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index d3d7cb8188b..e542ef22098 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -47,7 +47,9 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un | `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) | | `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` | -`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; accepting it creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. Suggestions are process-local and disappear when the Gateway restarts. Both tools are in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically. +`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. + +The tools are offered only when the initiating operator surface can receive and action Gateway task-suggestion events. Channel sessions and local/embedded TUI sessions do not receive them; channel transports need a portable typed task action before they can safely expose this flow. Suggestions are process-local and disappear when the Gateway restarts. Both tools remain in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically when the surface supports them. ### MCP and plugin tools inside sandbox tool policy diff --git a/docs/tools/tts.md b/docs/tools/tts.md index 1b873a77b6b..b3f315963ed 100644 --- a/docs/tools/tts.md +++ b/docs/tools/tts.md @@ -868,6 +868,9 @@ Reply -> TTS enabled? Command timeout in milliseconds. Default `120000`. Optional command working directory. Optional environment overrides for the command. + + Command stdout and generated or converted audio are limited to 50 MiB. Diagnostic stderr is limited to 1 MiB. OpenClaw terminates the command and fails synthesis when either limit is exceeded. + diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 262b2981763..45ee6af454f 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -733,6 +733,41 @@ describe("createCodexDynamicToolBridge", () => { expect(text).toContain("rerun with narrower args"); }); + it("keeps a whole code point when dynamic tool text crosses the configured boundary", async () => { + const maxChars = 180; + const totalChars = 400; + const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`; + const textBudget = maxChars - noticeText.length - 1; + const prefix = "a".repeat(textBudget - 1); + const longText = `${prefix}😀${"z".repeat(totalChars - prefix.length - 2)}`; + const bridge = createCodexDynamicToolBridge({ + tools: [ + createTool({ + name: "large_lookup", + execute: vi.fn(async () => textToolResult(longText)), + }), + ], + signal: new AbortController().signal, + hookContext: { + agentId: "main", + config: { + agents: { defaults: { contextLimits: { toolResultMaxChars: maxChars } } }, + } as never, + }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + namespace: null, + tool: "large_lookup", + arguments: {}, + }); + + expect(result.contentItems).toEqual([{ type: "inputText", text: `${prefix}\n${noticeText}` }]); + }); + it("honors normalized per-agent dynamic tool result caps", async () => { const bridge = createCodexDynamicToolBridge({ tools: [ diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 5bfac4e8db7..5b8fbe42e7e 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -47,6 +47,7 @@ import { asOptionalRecord as readRecord, isRecord, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { CodexDynamicToolsLoading } from "./config.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; import type { @@ -1359,16 +1360,18 @@ function convertToolContents( continue; } if (notice.length >= maxChars) { - output.push({ type: "inputText", text: noticeText.slice(0, maxChars) }); + output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) }); appendedNotice = true; continue; } const sliceLength = Math.min(item.text.length, remainingTextBudget); remainingTextBudget -= sliceLength; const shouldAppendNotice = remainingTextBudget <= 0; - const text = item.text.slice(0, sliceLength); + const text = truncateUtf16Safe(item.text, sliceLength); if (shouldAppendNotice) { - output.push({ type: "inputText", text: `${text.trimEnd()}${notice}`.slice(0, maxChars) }); + // The notice budget is reserved before slicing text, so the combined + // result is already bounded without another boundary-sensitive cut. + output.push({ type: "inputText", text: `${text.trimEnd()}${notice}` }); appendedNotice = true; } else if (text.length > 0) { output.push({ type: "inputText", text }); @@ -1376,7 +1379,7 @@ function convertToolContents( } if (!appendedNotice) { - output.push({ type: "inputText", text: noticeText.slice(0, maxChars) }); + output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) }); } return output; } diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts index 970ea535886..e9eb0545eca 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts @@ -73,6 +73,58 @@ describe("engine/gateway/inbound-attachments", () => { expect(result.attachmentLocalPaths).toEqual([null]); }); + it("classifies image content types case-insensitively when download succeeds", async () => { + downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/a.png"); + downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/b.png"); + + const result = await processAttachments( + [ + { content_type: "image/png", url: "https://cdn.example.test/a.png", filename: "a.png" }, + { content_type: "Image/PNG", url: "https://cdn.example.test/b.png", filename: "b.png" }, + ], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual([ + "/tmp/openclaw-qqbot-downloads/a.png", + "/tmp/openclaw-qqbot-downloads/b.png", + ]); + // The raw content_type passes through unchanged. + expect(result.imageMediaTypes).toEqual(["image/png", "Image/PNG"]); + expect(result.attachmentInfo).toBe(""); + }); + + it("uses the remote image URL for a mixed-case image content type when download fails", async () => { + downloadFileMock.mockResolvedValue(null); + + const result = await processAttachments( + [{ content_type: "Image/PNG", url: "//cdn.example.test/a.png", filename: "a.png" }], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]); + expect(result.imageMediaTypes).toEqual(["Image/PNG"]); + expect(result.attachmentLocalPaths).toEqual([null]); + }); + + it("does not classify a mixed-case non-image content type as an image", async () => { + downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/doc.pdf"); + + const result = await processAttachments( + [ + { + content_type: "Application/PDF", + url: "https://cdn.example.test/doc.pdf", + filename: "doc.pdf", + }, + ], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual([]); + expect(result.attachmentInfo).toBe("\n[Attachment: /tmp/openclaw-qqbot-downloads/doc.pdf]"); + }); + it("prefers voice_wav_url for voice downloads and transcribes with configured STT", async () => { downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/voice.wav"); resolveSTTConfigMock.mockReturnValue({ diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts index 3a634588518..20f57d65910 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -3,7 +3,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { AudioConvertPort } from "../adapter/audio.port.js"; import { downloadFile } from "../utils/file-utils.js"; import { getQQBotMediaDir } from "../utils/platform.js"; -import { normalizeOptionalString } from "../utils/string-normalize.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../utils/string-normalize.js"; import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; // Re-export the port type for convenience. @@ -115,6 +118,10 @@ export async function processAttachments( const processTasks = downloadResults.map( async ({ att, attUrl, isVoice, localPath, audioPath }) => { const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? ""; + // MIME types are case-insensitive (RFC 2045) and relays may emit + // mixed-case values; compare lowercased but keep the raw content_type + // in returned fields. + const normalizedContentType = normalizeLowercaseStringOrEmpty(att.content_type); const wavUrl = isVoice && att.voice_wav_url ? att.voice_wav_url.startsWith("//") @@ -129,7 +136,7 @@ export async function processAttachments( }; if (localPath) { - if (att.content_type?.startsWith("image/")) { + if (normalizedContentType.startsWith("image/")) { log?.debug?.(`Downloaded attachment to: ${localPath}`); return { localPath, type: "image" as const, contentType: att.content_type, meta }; } @@ -150,7 +157,7 @@ export async function processAttachments( return { localPath, type: "other" as const, filename: att.filename, meta }; } log?.error(`Failed to download: ${attUrl}`); - if (att.content_type?.startsWith("image/")) { + if (normalizedContentType.startsWith("image/")) { return { localPath: null, type: "image-fallback" as const, diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts index a3010f67fb0..86cacbb5251 100644 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -97,6 +97,12 @@ describe("engine/utils/audio", () => { expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true); }); + it("treats content_type case-insensitively", () => { + expect(isVoiceAttachment({ content_type: "Voice" })).toBe(true); + expect(isVoiceAttachment({ content_type: "Audio/Silk" })).toBe(true); + expect(isVoiceAttachment({ content_type: "Image/PNG" })).toBe(false); + }); + it("rejects non-voice attachments", () => { expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false); expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false); diff --git a/extensions/qqbot/src/engine/utils/audio.ts b/extensions/qqbot/src/engine/utils/audio.ts index 04cea695c98..1718d20a1f1 100644 --- a/extensions/qqbot/src/engine/utils/audio.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -118,7 +118,11 @@ export async function convertSilkToWav( /** Check whether an attachment is a voice file (by MIME type or extension). */ export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean { - if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) { + // MIME types are case-insensitive (RFC 2045) and relays may emit mixed-case + // values; the bare "voice" platform sentinel gets the same treatment. + // Compare lowercased like the extension check below. + const contentType = normalizeLowercase(att.content_type); + if (contentType === "voice" || contentType.startsWith("audio/")) { return true; } const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : ""; diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 164aab914dc..d23f6c5e2ea 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -75,6 +75,7 @@ function requireFirstSsrfRequest(): { url?: unknown; auditContext?: unknown; init?: RequestInit; + timeoutMs?: unknown; } { const [call] = ssrfRuntimeMocks.fetchWithSsrFGuard.mock.calls; if (!call) { @@ -84,6 +85,7 @@ function requireFirstSsrfRequest(): { url?: unknown; auditContext?: unknown; init?: RequestInit; + timeoutMs?: unknown; }; } @@ -118,6 +120,7 @@ describe("engine/utils/stt", () => { providers: { openai: { apiKey: "provider-key", + timeoutSeconds: 45, }, }, }, @@ -127,6 +130,7 @@ describe("engine/utils/stt", () => { baseUrl: "https://api.example.test/v1", apiKey: "provider-key", model: "whisper-large", + timeoutMs: 45_000, }); }); @@ -136,13 +140,14 @@ describe("engine/utils/stt", () => { tools: { media: { audio: { + timeoutSeconds: 90, models: [{ provider: "local", baseUrl: "https://stt.example.test/", model: "sense" }], }, }, }, models: { providers: { - local: { apiKey: "local-key" }, + local: { apiKey: "local-key", timeoutSeconds: 120 }, }, }, }; @@ -151,7 +156,11 @@ describe("engine/utils/stt", () => { baseUrl: "https://stt.example.test", apiKey: "local-key", model: "sense", + timeoutMs: 90_000, }); + + Object.assign(cfg.tools.media.audio.models[0], { timeoutSeconds: 75 }); + expect(resolveSTTConfig(cfg)?.timeoutMs).toBe(75_000); }); it("returns null when no usable STT credentials are configured", () => { @@ -191,6 +200,7 @@ describe("engine/utils/stt", () => { const request = requireFirstSsrfRequest(); expect(request.url).toBe("https://api.example.test/v1/audio/transcriptions"); expect(request.auditContext).toBe("qqbot-stt"); + expect(request.timeoutMs).toBe(60_000); expect(request.init?.method).toBe("POST"); expect(request.init?.headers).toEqual({ Authorization: "Bearer secret" }); expect(request.init?.body).toBeInstanceOf(FormData); diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index cc3457ead94..b6cbc8e3926 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -8,6 +8,7 @@ import * as fs from "node:fs"; import path from "node:path"; import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime"; +import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime"; import { readProviderJsonResponse, readResponseTextLimited, @@ -22,11 +23,23 @@ import { } from "./string-normalize.js"; const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +const DEFAULT_STT_TIMEOUT_MS = 60_000; interface STTConfig { baseUrl: string; apiKey: string; model: string; + timeoutMs: number; +} + +function resolveSTTTimeoutMs(...timeoutSeconds: unknown[]): number { + for (const value of timeoutSeconds) { + const timeoutMs = finiteSecondsToTimerSafeMilliseconds(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + } + return DEFAULT_STT_TIMEOUT_MS; } /** Resolve the STT configuration from the nested config object. */ @@ -45,7 +58,12 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey"); const model = readString(channelStt, "model") ?? "whisper-1"; if (baseUrl && apiKey) { - return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model }; + return { + baseUrl: baseUrl.replace(/\/+$/, ""), + apiKey, + model, + timeoutMs: resolveSTTTimeoutMs(providerCfg?.timeoutSeconds), + }; } } @@ -62,7 +80,16 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey"); const model = readString(audioModelEntry, "model") ?? "whisper-1"; if (baseUrl && apiKey) { - return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model }; + return { + baseUrl: baseUrl.replace(/\/+$/, ""), + apiKey, + model, + timeoutMs: resolveSTTTimeoutMs( + audioModelEntry.timeoutSeconds, + audio?.timeoutSeconds, + providerCfg?.timeoutSeconds, + ), + }; } } @@ -90,6 +117,7 @@ export async function transcribeAudio( const { response: resp, release } = await fetchWithSsrFGuard({ url: `${sttCfg.baseUrl}/audio/transcriptions`, auditContext: "qqbot-stt", + timeoutMs: sttCfg.timeoutMs, init: { method: "POST", headers: { Authorization: `Bearer ${sttCfg.apiKey}` }, diff --git a/extensions/tts-local-cli/speech-provider-stream-error.test.ts b/extensions/tts-local-cli/speech-provider-stream-error.test.ts index 51ce7050b10..40a0fecd983 100644 --- a/extensions/tts-local-cli/speech-provider-stream-error.test.ts +++ b/extensions/tts-local-cli/speech-provider-stream-error.test.ts @@ -38,6 +38,7 @@ function createMockChild(): MockChild { } const TEST_CFG = {} as OpenClawConfig; +const MIB = 1024 * 1024; type SpeechTarget = SpeechSynthesisRequest["target"]; @@ -193,6 +194,25 @@ describe("CLI TTS provider stream error handling", () => { ); }); + it.each([ + { stream: "stdout", chunkBytes: MIB, repeats: 51, limitBytes: 50 * MIB }, + { stream: "stderr", chunkBytes: MIB / 2, repeats: 3, limitBytes: MIB }, + ] as const)("terminates the child when $stream exceeds its byte cap", async (testCase) => { + const child = createMockChild(); + const { promise } = await startSpeech({ child }); + const chunk = Buffer.alloc(testCase.chunkBytes); + + for (let index = 0; index < testCase.repeats; index += 1) { + child[testCase.stream].emit("data", chunk); + } + expect(child.kill).toHaveBeenCalledTimes(1); + child.emit("close", null); + + await expect(promise).rejects.toThrow( + `CLI TTS ${testCase.stream} exceeded ${testCase.limitBytes} bytes`, + ); + }); + it("waits for close before settling a process error", async () => { const child = createMockChild(); const { promise } = await startSpeech({ child }); diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts index 76896e2be52..2ef67d2ec9e 100644 --- a/extensions/tts-local-cli/speech-provider.test.ts +++ b/extensions/tts-local-cli/speech-provider.test.ts @@ -1,5 +1,5 @@ // Tts Local Cli tests cover speech provider plugin behavior. -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -22,6 +22,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ import { buildCliSpeechProvider } from "./speech-provider.js"; const TEST_CFG = {} as OpenClawConfig; +const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024; function createCliFixture(): { dir: string; script: string } { const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-tts-test-")); @@ -261,6 +262,93 @@ describe("buildCliSpeechProvider", () => { } }); + it("rejects oversized CLI output files before reading them", async () => { + const fixture = createCliFixture(); + try { + writeFileSync( + fixture.script, + ` +import { truncateSync, writeFileSync } from "node:fs"; +const outIndex = process.argv.indexOf("--out"); +const outputPath = process.argv[outIndex + 1]; +writeFileSync(outputPath, ""); +truncateSync(outputPath, ${MAX_AUDIO_OUTPUT_BYTES + 1}); +`, + ); + + await expect( + synthesize({ + providerConfig: baseProviderConfig(fixture.script, { + args: [fixture.script, "--out", "{{OutputPath}}"], + outputFormat: "wav", + }), + }), + ).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }); + + it("rejects non-file CLI output artifacts", async () => { + const fixture = createCliFixture(); + try { + writeFileSync( + fixture.script, + ` +import { mkdirSync } from "node:fs"; +const outIndex = process.argv.indexOf("--out"); +mkdirSync(process.argv[outIndex + 1]); +`, + ); + + await expect( + synthesize({ + providerConfig: baseProviderConfig(fixture.script, { + args: [fixture.script, "--out", "{{OutputPath}}"], + outputFormat: "wav", + }), + }), + ).rejects.toThrow("path must be a regular file"); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }); + + it.each(["voice-note", "telephony"] as const)( + "rejects oversized ffmpeg output for %s synthesis", + async (mode) => { + const fixture = createCliFixture(); + runFfmpegMock.mockImplementation(async (args) => { + const outputPath = args.at(-1); + if (typeof outputPath !== "string") { + throw new Error("missing ffmpeg output path"); + } + writeFileSync(outputPath, ""); + truncateSync(outputPath, MAX_AUDIO_OUTPUT_BYTES + 1); + }); + try { + const providerConfig = baseProviderConfig(fixture.script, { + args: [fixture.script, "--out", "{{OutputPath}}"], + outputFormat: "wav", + }); + const run = + mode === "voice-note" + ? synthesize({ providerConfig, target: "voice-note" }) + : buildCliSpeechProvider().synthesizeTelephony?.({ + text: "phone reply", + cfg: TEST_CFG, + providerConfig, + providerOverrides: {}, + timeoutMs: 1000, + }); + + await expect(run).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`); + } finally { + rmSync(fixture.dir, { recursive: true, force: true }); + } + }, + ); + it.each(["synthesize", "synthesizeTelephony"] as const)( "keeps %s debug previews free of lone surrogates", async (method) => { diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index 1bf3d7a1be2..1d71ffd94f5 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -1,10 +1,13 @@ // Tts Local Cli provider module implements model/runtime integration. import { spawn } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync } from "node:fs"; import path from "node:path"; import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; +import { + readRegularFileSync, + writeExternalFileWithinRoot, +} from "openclaw/plugin-sdk/security-runtime"; import type { SpeechProviderConfig, SpeechProviderPlugin, @@ -30,6 +33,8 @@ type CliConfig = { }; const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024; +const MAX_CLI_STDERR_BYTES = 1024 * 1024; function asObject(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -172,6 +177,38 @@ function getFileExt(format: string): string { return ".mp3"; } +function readAudioFile(filePath: string): Buffer { + return readRegularFileSync({ filePath, maxBytes: MAX_AUDIO_OUTPUT_BYTES }).buffer; +} + +function createBoundedBuffer(label: string, maxBytes: number) { + const chunks: Buffer[] = []; + let totalBytes = 0; + let limitError: Error | undefined; + + return { + append(chunk: Buffer | string): Error | undefined { + if (limitError) { + return limitError; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const nextTotal = totalBytes + buffer.byteLength; + if (nextTotal > maxBytes) { + limitError = new Error(`${label} exceeded ${maxBytes} bytes (${nextTotal} bytes received)`); + chunks.length = 0; + totalBytes = 0; + return limitError; + } + chunks.push(buffer); + totalBytes = nextTotal; + return undefined; + }, + concat(): Buffer { + return Buffer.concat(chunks, totalBytes); + }, + }; +} + async function runCli(params: { command: string; args: string[]; @@ -231,15 +268,25 @@ async function runCli(params: { terminateFor(new Error(`CLI TTS timed out after ${params.timeoutMs}ms`)); }, params.timeoutMs); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - proc.stdout.on("data", (c) => stdoutChunks.push(c)); + const stdout = createBoundedBuffer("CLI TTS stdout", MAX_AUDIO_OUTPUT_BYTES); + const stderr = createBoundedBuffer("CLI TTS stderr", MAX_CLI_STDERR_BYTES); + proc.stdout.on("data", (chunk: Buffer) => { + const error = stdout.append(chunk); + if (error) { + terminateFor(error); + } + }); proc.stdout.on("error", (e) => { // A generated file is authoritative when present. Remember stdout // failure so only the stdout-audio fallback is rejected after close. stdoutError ??= new Error(`CLI TTS stdout stream error: ${e.message}`); }); - proc.stderr.on("data", (c) => stderrChunks.push(c)); + proc.stderr.on("data", (chunk: Buffer) => { + const error = stderr.append(chunk); + if (error) { + terminateFor(error); + } + }); proc.stderr.on("error", (e) => { stderrError ??= new Error(`CLI TTS stderr stream error: ${e.message}`); }); @@ -260,36 +307,37 @@ async function runCli(params: { return reject(terminalFailure); } if (code !== 0) { - const stderr = Buffer.concat(stderrChunks).toString("utf8"); + const stderrText = stderr.concat().toString("utf8"); const diagnostic = stderrError - ? [stderr, stderrError.message].filter(Boolean).join("; ") - : stderr; + ? [stderrText, stderrError.message].filter(Boolean).join("; ") + : stderrText; return reject(new Error(`CLI TTS exit ${code}: ${diagnostic}`)); } const audioFile = findAudioFile(params.outputDir, params.filePrefix); if (audioFile) { - if (!existsSync(audioFile)) { - return reject(new Error(`CLI TTS: output file not found at ${audioFile}`)); - } const format = detectFormat(audioFile); if (!format) { return reject(new Error(`CLI TTS: unknown format for ${audioFile}`)); } - return resolve({ - buffer: readFileSync(audioFile), - actualFormat: format, - audioPath: audioFile, - }); + try { + return resolve({ + buffer: readAudioFile(audioFile), + actualFormat: format, + audioPath: audioFile, + }); + } catch (error) { + return reject(error instanceof Error ? error : new Error(String(error))); + } } if (stdoutError) { return reject(stdoutError); } - const stdout = Buffer.concat(stdoutChunks); - if (stdout.length > 0) { + const stdoutBuffer = stdout.concat(); + if (stdoutBuffer.length > 0) { // Assume WAV for stdout output; could be MP3 but caller should convert if needed - return resolve({ buffer: stdout, actualFormat: "wav" }); + return resolve({ buffer: stdoutBuffer, actualFormat: "wav" }); } reject(new Error("CLI TTS produced no output")); }); @@ -302,13 +350,28 @@ async function runCli(params: { }); } +async function runFfmpegToBuffer(params: { + args: string[]; + outputDir: string; + outputFileName: string; +}): Promise { + const outputPath = path.join(params.outputDir, params.outputFileName); + await writeExternalFileWithinRoot({ + rootDir: params.outputDir, + path: params.outputFileName, + write: async (tempPath) => { + await runFfmpeg([...params.args, tempPath]); + }, + }); + return readAudioFile(outputPath); +} + async function convertAudio( inputPath: string, outputDir: string, target: OutputFormat, ): Promise { const outputFileName = `converted${getFileExt(target)}`; - const outputPath = path.join(outputDir, outputFileName); const args = ["-y", "-i", inputPath]; if (target === "opus") { args.push("-c:a", "libopus", "-b:a", "64k", "-f", "opus"); @@ -317,41 +380,26 @@ async function convertAudio( } else { args.push("-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3"); } - await writeExternalFileWithinRoot({ - rootDir: outputDir, - path: outputFileName, - write: async (tempPath) => { - await runFfmpeg([...args, tempPath]); - }, - }); - return readFileSync(outputPath); + return await runFfmpegToBuffer({ args, outputDir, outputFileName }); } async function convertToRawPcm(inputPath: string, outputDir: string): Promise { // Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers) const outputFileName = "telephony.pcm"; - const outputPath = path.join(outputDir, outputFileName); - await writeExternalFileWithinRoot({ - rootDir: outputDir, - path: outputFileName, - write: async (tempPath) => { - await runFfmpeg([ - "-y", - "-i", - inputPath, - "-c:a", - "pcm_s16le", - "-ar", - "16000", - "-ac", - "1", - "-f", - "s16le", - tempPath, - ]); - }, - }); - return readFileSync(outputPath); + const args = [ + "-y", + "-i", + inputPath, + "-c:a", + "pcm_s16le", + "-ar", + "16000", + "-ac", + "1", + "-f", + "s16le", + ]; + return await runFfmpegToBuffer({ args, outputDir, outputFileName }); } export function buildCliSpeechProvider(): SpeechProviderPlugin { diff --git a/extensions/xai/src/code-execution-config.ts b/extensions/xai/src/code-execution-config.ts index ba5a7db558b..264af9283c7 100644 --- a/extensions/xai/src/code-execution-config.ts +++ b/extensions/xai/src/code-execution-config.ts @@ -1,7 +1,7 @@ // Xai helper module supports code execution config behavior. import { isXaiToolEnabled, type XaiToolAuthContext } from "./tool-auth-shared.js"; -export type CodeExecutionConfig = { +type CodeExecutionConfig = { enabled?: boolean; model?: string; maxTurns?: number; diff --git a/extensions/xai/web-search-provider-shared.ts b/extensions/xai/web-search-provider-shared.ts index 1db3e5ed6df..e07dc7e3100 100644 --- a/extensions/xai/web-search-provider-shared.ts +++ b/extensions/xai/web-search-provider-shared.ts @@ -4,7 +4,7 @@ import { type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; -export const XAI_WEB_SEARCH_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey"; +const XAI_WEB_SEARCH_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey"; export function buildXaiWebSearchProviderBase(): Omit< WebSearchProviderPlugin, diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index 4b580abc09c..618b4fb8c4c 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -18,13 +18,13 @@ import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; import { xaiUserAgent } from "./src/xai-user-agent.js"; const PROVIDER_ID = "xai"; -export const XAI_OAUTH_METHOD_ID = "oauth"; -export const XAI_OAUTH_CHOICE_ID = "xai-oauth"; -export const XAI_DEVICE_CODE_METHOD_ID = "device-code"; -export const XAI_DEVICE_CODE_CHOICE_ID = "xai-device-code"; +const XAI_OAUTH_METHOD_ID = "oauth"; +const XAI_OAUTH_CHOICE_ID = "xai-oauth"; +const XAI_DEVICE_CODE_METHOD_ID = "device-code"; +const XAI_DEVICE_CODE_CHOICE_ID = "xai-device-code"; export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"; -export const XAI_OAUTH_ISSUER = "https://auth.x.ai"; +const XAI_OAUTH_ISSUER = "https://auth.x.ai"; export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`; const XAI_LEGACY_OAUTH_TOKEN_ENDPOINT = `${XAI_OAUTH_ISSUER}/oauth/token`; diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index a687aecfb7d..0b1ca5fdee1 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -74,6 +74,7 @@ export type GatewayClientInfo = { /** Capability flags a client may advertise during the gateway handshake. */ export const GATEWAY_CLIENT_CAPS = { INLINE_WIDGETS: "inline-widgets", + TASK_SUGGESTIONS: "task-suggestions", TOOL_EVENTS: "tool-events", } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92809442cd7..3157a6a6caa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1993,6 +1993,9 @@ importers: '@create-markdown/preview': specifier: 2.0.3 version: 2.0.3(shiki@4.3.0) + '@lit/context': + specifier: 1.1.6 + version: 1.1.6 '@noble/ed25519': specifier: 3.1.0 version: 3.1.0 diff --git a/scripts/e2e/lib/plugins/npm-registry-server.mjs b/scripts/e2e/lib/plugins/npm-registry-server.mjs index 0c59226f194..acfc52ebabb 100644 --- a/scripts/e2e/lib/plugins/npm-registry-server.mjs +++ b/scripts/e2e/lib/plugins/npm-registry-server.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; // Fixture npm registry server for plugin E2E scenarios. import crypto from "node:crypto"; import fs from "node:fs"; @@ -5,6 +6,25 @@ import http from "node:http"; import path from "node:path"; const [portFile, ...packageArgs] = process.argv.slice(2); +function normalizeUpstreamRegistry(raw) { + if (!raw) { + return undefined; + } + const url = new URL(raw); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error("OPENCLAW_NPM_REGISTRY_UPSTREAM must be an HTTP(S) origin"); + } + return url.origin; +} + +const upstreamRegistry = normalizeUpstreamRegistry(process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM); if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { console.error( @@ -14,6 +34,25 @@ if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { } const packages = new Map(); + +function readPackageManifest(tarballPath, packageName) { + try { + const packageJson = JSON.parse( + execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }), + ); + return packageJson && typeof packageJson === "object" && !Array.isArray(packageJson) + ? packageJson + : {}; + } catch { + return packageName === "@openclaw/demo-plugin-npm" + ? { dependencies: { "is-number": "7.0.0" } } + : {}; + } +} + for (let index = 0; index < packageArgs.length; index += 3) { const packageName = packageArgs[index]; const version = packageArgs[index + 1]; @@ -28,8 +67,8 @@ for (let index = 0; index < packageArgs.length; index += 3) { existing.latestVersion = version; existing.versions.set(version, { archive, - dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {}, integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + manifest: readPackageManifest(tarballPath, packageName), shasum: crypto.createHash("sha1").update(archive).digest("hex"), tarballName: path.basename(tarballPath), version, @@ -44,7 +83,7 @@ const metadataFor = (entry, baseUrl) => ({ [...entry.versions.entries()].map(([version, versionEntry]) => [ version, { - dependencies: versionEntry.dependencies, + ...versionEntry.manifest, name: entry.packageName, version, dist: { @@ -85,9 +124,46 @@ function findTarballForPath(pathname) { return undefined; } -const server = http.createServer((request, response) => { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - const baseUrl = `http://127.0.0.1:${server.address().port}`; +function resolveUpstreamRequestUrl(rawRequestUrl) { + const raw = rawRequestUrl || "/"; + if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) { + throw new Error(`refusing non-origin registry request URL: ${JSON.stringify(raw)}`); + } + const requestUrl = new URL(raw, "http://127.0.0.1"); + return `${upstreamRegistry}${requestUrl.pathname}${requestUrl.search}`; +} + +async function proxyUpstream(rawRequestUrl, response) { + if (!upstreamRegistry) { + return false; + } + try { + const upstreamUrl = resolveUpstreamRequestUrl(rawRequestUrl); + const upstreamResponse = await fetch(upstreamUrl, { redirect: "manual" }); + const body = Buffer.from(await upstreamResponse.arrayBuffer()); + // Fetch decodes compressed bodies but preserves upstream length metadata. + // Emit the decoded size so npm clients do not truncate proxied responses. + const headers = { "content-length": String(body.length) }; + for (const name of ["content-type", "location"]) { + const value = upstreamResponse.headers.get(name); + if (value) { + headers[name] = value; + } + } + response.writeHead(upstreamResponse.status, headers); + response.end(body); + } catch (error) { + response.writeHead(502, { "content-type": "text/plain" }); + response.end(`upstream registry request failed: ${String(error)}`); + } + return true; +} + +async function handleRequest(request, response) { + const fallbackHost = `127.0.0.1:${server.address().port}`; + const requestHost = request.headers.host || fallbackHost; + const url = new URL(request.url ?? "/", `http://${requestHost}`); + const baseUrl = url.origin; if (request.method !== "GET") { response.writeHead(405, { "content-type": "text/plain" }); response.end("method not allowed"); @@ -111,10 +187,27 @@ const server = http.createServer((request, response) => { return; } + if (await proxyUpstream(request.url, response)) { + return; + } + response.writeHead(404, { "content-type": "text/plain" }); response.end(`not found: ${url.pathname}`); +} + +const server = http.createServer((request, response) => { + void handleRequest(request, response).catch((/** @type {unknown} */ error) => { + if (!response.headersSent) { + response.writeHead(500, { "content-type": "text/plain" }); + response.end(`registry request failed: ${String(error)}`); + return; + } + response.destroy(error instanceof Error ? error : new Error(String(error))); + }); }); -server.listen(0, "127.0.0.1", () => { +const bindHost = process.env.OPENCLAW_NPM_REGISTRY_BIND_HOST || "127.0.0.1"; +const requestedPort = Number(process.env.OPENCLAW_NPM_REGISTRY_PORT || 0); +server.listen(requestedPort, bindHost, () => { fs.writeFileSync(portFile, String(server.address().port)); }); diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index 97ccf8ca370..bb529eb5f9a 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -10,6 +10,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-telegram-live-e2e" OPENCLAW DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}" PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}" PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}" +PACKAGE_DIR="${OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR:-}" PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}" RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}" @@ -77,11 +78,58 @@ resolve_package_tgz() { printf "%s/%s" "$dir" "$base" } +resolve_package_dir() { + local candidate="$1" + if [ -z "$candidate" ]; then + return 0 + fi + if [ ! -d "$candidate" ]; then + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR must point to an existing directory; got: $candidate" >&2 + exit 1 + fi + (cd "$candidate" && pwd) +} + +read_package_version() { + tar -xOf "$1" package/package.json | + node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + const version = JSON.parse(raw).version; + if (typeof version !== "string" || !version) { + throw new Error("package tarball is missing a version"); + } + process.stdout.write(version); +}); +' +} + package_mount_args=() +registry_helper_mount_args=() package_install_source="$PACKAGE_SPEC" package_source_kind="npm-package" resolved_package_tgz="$(resolve_package_tgz "$PACKAGE_TGZ")" -if [ -n "$resolved_package_tgz" ]; then +resolved_package_dir="$(resolve_package_dir "$PACKAGE_DIR")" +if [ -n "$resolved_package_dir" ]; then + if [ -z "$resolved_package_tgz" ]; then + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR requires OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ" >&2 + exit 1 + fi + case "$resolved_package_tgz" in + "$resolved_package_dir"/*) ;; + *) + echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ must be inside OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR" >&2 + exit 1 + ;; + esac + package_install_source="openclaw@$(read_package_version "$resolved_package_tgz")" + package_source_kind="prepared-package-set" + package_mount_args=(-v "$resolved_package_dir:/package-under-test:ro") + registry_helper_mount_args=( + -v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro" + ) +elif [ -n "$resolved_package_tgz" ]; then package_install_source="/package-under-test/$(basename "$resolved_package_tgz")" package_source_kind="packed-tarball" package_mount_args=(-v "$resolved_package_tgz:$package_install_source:ro") @@ -248,7 +296,9 @@ run_logged docker_e2e_docker_run_cmd run --rm \ -e OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" \ -e OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE="$package_install_source" \ -e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL" \ + -e OPENCLAW_NPM_TELEGRAM_PACKAGE_SET="$([ -n "$resolved_package_dir" ] && printf 1 || printf 0)" \ ${package_mount_args[@]+"${package_mount_args[@]}"} \ + ${registry_helper_mount_args[@]+"${registry_helper_mount_args[@]}"} \ -v "$npm_prefix_host:/npm-global" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail @@ -261,6 +311,74 @@ install_source="${OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE:?missing OPENCLAW_NPM_TEL package_label="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-$install_source}" echo "Installing ${package_label} from ${install_source}..." +registry_pid="" +registry_log="" +cleanup_registry() { + if [ -n "$registry_pid" ]; then + kill "$registry_pid" >/dev/null 2>&1 || true + wait "$registry_pid" >/dev/null 2>&1 || true + fi + if [ -n "$registry_log" ]; then + rm -f "$registry_log" + fi +} +trap cleanup_registry EXIT + +if [ "${OPENCLAW_NPM_TELEGRAM_PACKAGE_SET:-0}" = "1" ]; then + shopt -s nullglob + package_tgzs=(/package-under-test/*.tgz) + shopt -u nullglob + if [ "${#package_tgzs[@]}" -eq 0 ]; then + echo "prepared package set contains no tgz files" >&2 + exit 1 + fi + registry_args=() + for package_tgz in "${package_tgzs[@]}"; do + package_metadata="$( + tar -xOf "$package_tgz" package/package.json | + node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + const pkg = JSON.parse(raw); + if (typeof pkg.name !== "string" || !pkg.name || typeof pkg.version !== "string" || !pkg.version) { + throw new Error("package tarball is missing name or version"); + } + process.stdout.write(`${pkg.name}\n${pkg.version}\n`); +}); +' + )" + mapfile -t package_fields <<<"$package_metadata" + registry_args+=("${package_fields[0]}" "${package_fields[1]}" "$package_tgz") + done + registry_port_file="$(mktemp)" + registry_log="$(mktemp)" + OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \ + node /tmp/openclaw-npm-registry-server.mjs \ + "$registry_port_file" \ + "${registry_args[@]}" >"$registry_log" 2>&1 & + registry_pid=$! + for _ in $(seq 1 100); do + if [ -s "$registry_port_file" ]; then + break + fi + if ! kill -0 "$registry_pid" >/dev/null 2>&1; then + cat "$registry_log" >&2 + exit 1 + fi + sleep 0.1 + done + if [ ! -s "$registry_port_file" ]; then + cat "$registry_log" >&2 + echo "prepared package registry did not start" >&2 + exit 1 + fi + registry_url="http://127.0.0.1:$(cat "$registry_port_file")" + rm -f "$registry_port_file" + export NPM_CONFIG_REGISTRY="$registry_url" + export npm_config_registry="$registry_url" +fi + npm_install_timeout="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" run_npm_install() { if [ -z "$npm_install_timeout" ] || [ "$npm_install_timeout" = "0" ]; then diff --git a/scripts/e2e/parallels/host-server.ts b/scripts/e2e/parallels/host-server.ts index 784fb901b20..b719927e0fe 100644 --- a/scripts/e2e/parallels/host-server.ts +++ b/scripts/e2e/parallels/host-server.ts @@ -1,11 +1,14 @@ // Host Server script supports OpenClaw repository automation. import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; import { createServer } from "node:http"; import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; import path from "node:path"; import { sleep as delay } from "../../lib/sleep.mjs"; import { die, run, say, sh, warn } from "./host-command.ts"; -import type { HostServer } from "./types.ts"; +import type { HostServer, NpmRegistryPackage, NpmRegistryServer } from "./types.ts"; const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024; const HOST_SERVER_STDERR_DRAIN_MS = 5_000; @@ -90,6 +93,44 @@ export async function startHostServer(input: { }; } +export async function startNpmRegistryServer(input: { + hostIp: string; + packages: NpmRegistryPackage[]; +}): Promise { + if (input.packages.length === 0) { + die("npm registry server requires at least one package"); + } + const port = allocateHostPort(); + const portFile = path.join(tmpdir(), `openclaw-npm-registry-${randomUUID()}.port`); + const packageArgs = input.packages.flatMap((pkg) => [pkg.name, pkg.version, pkg.tarballPath]); + const child = spawn( + process.execPath, + ["scripts/e2e/lib/plugins/npm-registry-server.mjs", portFile, ...packageArgs], + { + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_BIND_HOST: "0.0.0.0", + OPENCLAW_NPM_REGISTRY_PORT: String(port), + OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + await waitForHostServer(child, port); + const url = `http://${input.hostIp}:${port}`; + say(`Serve prepared npm package set on ${url}`); + return { + url, + stop: async () => { + try { + await stopHostServerChild(child); + } finally { + await rm(portFile, { force: true }); + } + }, + }; +} + async function stopHostServerChild( child: ChildProcessWithoutNullStreams, terminateTimeoutMs = 2_000, diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index b6ceb1e85f9..b096385a449 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -130,6 +130,7 @@ const defaultOptions = (): LinuxOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", snapshotHint: "fresh", targetPackageSpec: "", @@ -157,6 +158,7 @@ Options: --install-version Pin site-installer version/dist-tag for the baseline lane. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. -h, --help Show help. @@ -222,6 +224,10 @@ export function parseArgs(argv: string[]): LinuxOptions { options.targetPackageSpec = ensureValue(args, i, arg); i++; break; + case "--npm-registry": + options.npmRegistry = ensureValue(args, i, arg); + i++; + break; case "--keep-server": options.keepServer = true; break; @@ -524,7 +530,17 @@ fi`); } const tgzUrl = this.server.urlFor(this.artifact.path); this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`); - this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]); + const npmArgs = ["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]; + this.guestExec( + this.options.npmRegistry + ? [ + "/usr/bin/env", + `NPM_CONFIG_REGISTRY=${this.options.npmRegistry}`, + `npm_config_registry=${this.options.npmRegistry}`, + ...npmArgs, + ] + : npmArgs, + ); this.guestExec(["openclaw", "--version"]); } diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 12e2a4e9116..836293fd430 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -65,6 +65,7 @@ interface MacosOptions { hostIp?: string; latestVersion?: string; installVersion?: string; + npmRegistry?: string; targetPackageSpec?: string; skipLatestRefCheck: boolean; keepServer: boolean; @@ -128,6 +129,7 @@ const defaultOptions = (): MacosOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", skipLatestRefCheck: false, snapshotHint: "macOS 26.5 latest", @@ -155,6 +157,7 @@ Options: --install-version Pin site-installer version/dist-tag for the baseline lane. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --keep-server Leave temp host HTTP server running. --discord-token-env Host env var name for Discord bot token. @@ -224,6 +227,10 @@ export function parseArgs(argv: string[]): MacosOptions { options.targetPackageSpec = ensureValue(args, i, arg); i++; break; + case "--npm-registry": + options.npmRegistry = ensureValue(args, i, arg); + i++; + break; case "--skip-latest-ref-check": options.skipLatestRefCheck = true; break; @@ -818,11 +825,14 @@ ${guestOpenClaw} --version`, } private installMain(tempName: string): void { + const npmRegistryEnv = this.options.npmRegistry + ? `NPM_CONFIG_REGISTRY=${shellQuote(this.options.npmRegistry)} npm_config_registry=${shellQuote(this.options.npmRegistry)} ` + : ""; if (this.targetInstallsDirectly()) { this .guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")} for attempt in 1 2; do - if ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then + if ${npmRegistryEnv}${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then break fi if [ "$attempt" -eq 2 ]; then @@ -842,7 +852,7 @@ ${guestOpenClaw} --version`); curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote( tgzUrl, )} -o /tmp/${tempName} -${guestNpm} install -g /tmp/${tempName} +${npmRegistryEnv}${guestNpm} install -g /tmp/${tempName} ${guestOpenClaw} --version`); } diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index b8a5cf1a326..d36fd142fd8 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -21,6 +21,7 @@ import type { Platform, ProviderAuth } from "./types.ts"; interface NpmUpdateScriptInput { auth: ProviderAuth; expectedNeedle: string; + npmRegistry?: string; updateTarget: string; } @@ -29,6 +30,14 @@ const macosGuestPath = "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; const macosOpenClawCommand = '"$OPENCLAW_BIN"'; +function posixNpmRegistryEnv(registry: string | undefined): string { + if (!registry) { + return ""; + } + const quoted = shellQuote(registry); + return `NPM_CONFIG_REGISTRY=${quoted} npm_config_registry=${quoted} `; +} + function posixModelProviderConfigCommands( command: string, modelId: string, @@ -120,8 +129,11 @@ fi`; } function windowsUpdateWithBundledPluginsDisabled(input: NpmUpdateScriptInput): string { + const registryEntry = input.npmRegistry + ? `; NPM_CONFIG_REGISTRY = ${psSingleQuote(input.npmRegistry)}` + : ""; return `$script:OpenClawUpdateExit = 0 -$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1' } { +$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'${registryEntry} } { Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json --no-restart 2>&1 $script:OpenClawUpdateExit = $LASTEXITCODE } @@ -255,7 +267,7 @@ wait_for_gateway() { } scrub_future_plugin_entries stop_openclaw_gateway_processes -OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart +${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixVersionCheck(macosOpenClawCommand, input.expectedNeedle)} start_openclaw_gateway wait_for_gateway @@ -395,7 +407,7 @@ wait_for_gateway() { } scrub_future_plugin_entries stop_openclaw_gateway_processes -OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart +${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixVersionCheck("openclaw", input.expectedNeedle)} start_openclaw_gateway wait_for_gateway diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 1b443e914b7..26b0af6b20a 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -14,12 +14,12 @@ import { import { die, ensureValue, + extractPackageJsonFromTgz, extractLastOpenClawVersionFromLog, isLikelyMacosDesktopHome, makeTempDir, packOpenClaw, packageBuildCommitFromTgz, - packageVersionFromTgz, parseMacosDsclUserHomeLine, parsePlatformList, parseProvider, @@ -34,10 +34,13 @@ import { say, shellQuote, startHostServer, + startNpmRegistryServer, withProgressOnStderr, writeSummaryMarkdown, writeJson, type HostServer, + type NpmRegistryPackage, + type NpmRegistryServer, type PackageArtifact, type Platform, type Provider, @@ -53,6 +56,7 @@ const LOGGED_POST_FORCE_KILL_WAIT_MS = 1_000; interface NpmUpdateOptions { betaValidation?: string; + dependencyTarballs: string[]; freshTargetSpec?: string; hostIp?: string; macosVm?: string; @@ -373,6 +377,7 @@ Options: --update-target Target passed to guest 'openclaw update --tag'. Default: host-served tgz packed from current checkout. --target-tarball Host-serve this prepared tgz for update and fresh install. + --dependency-tarball Companion package tgz required by the target. Repeatable. --fresh-target Also run fresh install smoke for this package after update lanes. --beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update plus fresh target install. Default target when flag is bare: beta. @@ -395,6 +400,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { const options: NpmUpdateOptions = { apiKeyEnv: undefined, betaValidation: undefined, + dependencyTarballs: [], freshTargetSpec: undefined, json: false, macosVm: undefined, @@ -422,6 +428,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { options.targetTarball = ensureValue(args, i, arg); i++; break; + case "--dependency-tarball": + options.dependencyTarballs.push(ensureValue(args, i, arg)); + i++; + break; case "--fresh-target": options.freshTargetSpec = ensureValue(args, i, arg); i++; @@ -481,6 +491,9 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { "--target-tarball cannot be combined with --beta-validation, --update-target, or --fresh-target", ); } + if (options.dependencyTarballs.length > 0 && !options.targetTarball) { + throw new Error("--dependency-tarball requires --target-tarball"); + } return options; } @@ -564,6 +577,7 @@ export class NpmUpdateSmoke { private harnessTargetFamily = ""; private hostIp = ""; protected server: HostServer | null = null; + private registryServer: NpmRegistryServer | null = null; private artifact: PackageArtifact | null = null; private freshTargetSpec = ""; private startedAt = Date.now(); @@ -574,7 +588,9 @@ export class NpmUpdateSmoke { private updateTargetTarball = ""; private targetTarballPath = ""; private targetTarballBuildCommit = ""; + private targetDependencyPackages: NpmRegistryPackage[] = []; private targetTarballVersion = ""; + private targetRegistryUrl = ""; private macosVm = macosVmDefault; private linuxVm = linuxVmDefault; @@ -605,6 +621,7 @@ export class NpmUpdateSmoke { await this.runSteps(); } finally { await this.server?.stop().catch(() => undefined); + await this.registryServer?.stop().catch(() => undefined); await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined); } } @@ -756,6 +773,9 @@ export class NpmUpdateSmoke { auth.apiKeyEnv, "--target-package-spec", packageSpec, + ...(phase === "fresh-target" && this.targetRegistryUrl + ? ["--npm-registry", this.targetRegistryUrl] + : []), "--json", ...extraArgs, ]; @@ -799,6 +819,31 @@ export class NpmUpdateSmoke { path: hostedTarballPath, version: this.targetTarballVersion, }; + if (this.targetDependencyPackages.length > 0) { + // Prepared sibling packages publish before core, so pre-publish VM installs need + // a local registry that serves the exact package set without touching public npm. + this.registryServer = await startNpmRegistryServer({ + hostIp: this.hostIp, + packages: [ + { + name: "openclaw", + version: this.targetTarballVersion, + tarballPath: hostedTarballPath, + }, + ...this.targetDependencyPackages, + ], + }); + this.targetRegistryUrl = this.registryServer.url; + this.updateTargetTarball = `${this.registryServer.url}/openclaw/-/${path.basename( + hostedTarballPath, + )}`; + this.updateTargetEffective = this.targetTarballVersion; + this.freshTargetSpec = this.updateTargetTarball; + this.updateExpectedNeedle = this.targetTarballVersion; + this.updateTargetPackageVersion = this.targetTarballVersion; + this.updateTargetBuildCommit = this.artifact.buildCommitShort ?? ""; + return; + } this.server = await startHostServer({ artifactPath: this.artifact.path, dir: this.tgzDir, @@ -979,6 +1024,7 @@ export class NpmUpdateSmoke { const input = { auth: this.authForPlatform(platform), expectedNeedle: this.updateExpectedNeedle, + npmRegistry: this.targetRegistryUrl, updateTarget: this.updateTargetEffective, }; switch (platform) { @@ -1385,10 +1431,42 @@ export class NpmUpdateSmoke { throw new Error(`target tarball does not exist: ${targetTarballPath}`); } this.targetTarballPath = targetTarballPath; - [this.targetTarballVersion, this.targetTarballBuildCommit] = await Promise.all([ - packageVersionFromTgz(targetTarballPath), + const [targetPackageJson, targetBuildCommit] = await Promise.all([ + extractPackageJsonFromTgz<{ + dependencies?: Record; + version?: string; + }>(targetTarballPath, "package/package.json"), packageBuildCommitFromTgz(targetTarballPath), ]); + this.targetTarballVersion = targetPackageJson.version ?? ""; + this.targetTarballBuildCommit = targetBuildCommit; + this.targetDependencyPackages = await Promise.all( + this.options.dependencyTarballs.map(async (dependencyTarball) => { + const tarballPath = path.resolve(dependencyTarball); + if (!existsSync(tarballPath)) { + throw new Error(`dependency tarball does not exist: ${tarballPath}`); + } + const dependencyPackage = await extractPackageJsonFromTgz<{ + name?: string; + version?: string; + }>(tarballPath, "package/package.json"); + const name = dependencyPackage.name ?? ""; + const version = dependencyPackage.version ?? ""; + if (!name || !version || name === "openclaw") { + throw new Error(`dependency tarball has invalid package metadata: ${tarballPath}`); + } + if (targetPackageJson.dependencies?.[name] !== version) { + throw new Error( + `target tarball requires ${name}@${targetPackageJson.dependencies?.[name] ?? ""}, but companion tarball provides ${version}`, + ); + } + return { name, version, tarballPath }; + }), + ); + const dependencyNames = new Set(this.targetDependencyPackages.map((pkg) => pkg.name)); + if (dependencyNames.size !== this.targetDependencyPackages.length) { + throw new Error("dependency tarballs must have unique package names"); + } if (!this.targetTarballVersion || !this.targetTarballBuildCommit) { throw new Error( `target tarball is missing package or build metadata: ${targetTarballPath}`, diff --git a/scripts/e2e/parallels/smoke-common.ts b/scripts/e2e/parallels/smoke-common.ts index 27f6675aa23..8f2258afb2b 100644 --- a/scripts/e2e/parallels/smoke-common.ts +++ b/scripts/e2e/parallels/smoke-common.ts @@ -24,6 +24,7 @@ export interface SmokeRunOptions { json: boolean; keepServer: boolean; mode: Mode; + npmRegistry?: string; provider: Provider; snapshotHint: string; targetPackageSpec?: string; diff --git a/scripts/e2e/parallels/types.ts b/scripts/e2e/parallels/types.ts index 19f54bb4a9b..f4b357356d1 100644 --- a/scripts/e2e/parallels/types.ts +++ b/scripts/e2e/parallels/types.ts @@ -45,3 +45,14 @@ export interface HostServer { urlFor(filePath: string): string; stop(): Promise; } + +export interface NpmRegistryPackage { + name: string; + version: string; + tarballPath: string; +} + +export interface NpmRegistryServer { + url: string; + stop(): Promise; +} diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 6994f82fddc..17751d9019e 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -108,6 +108,7 @@ const defaultOptions = (): WindowsOptions => ({ latestVersion: "", mode: "both", modelId: undefined, + npmRegistry: undefined, provider: "openai", skipLatestRefCheck: false, snapshotHint: "pre-openclaw-native-e2e-2026-03-12", @@ -142,6 +143,7 @@ Options: then run openclaw update --channel dev. --target-package-spec Install this npm package tarball instead of packing current main. + --npm-registry Registry used for target package installs. --skip-latest-ref-check Skip latest-release ref-mode precheck. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -175,6 +177,9 @@ export function parseArgs(argv: string[]): WindowsOptions { "--model": (value) => { options.modelId = value; }, + "--npm-registry": (value) => { + options.npmRegistry = value; + }, "--openai-api-key-env": (value) => { options.apiKeyEnv = value; }, @@ -567,11 +572,15 @@ Invoke-OpenClaw --version die("package artifact/server missing"); } const tgzUrl = this.server.urlFor(this.artifact.path); + const registryScript = this.options.npmRegistry + ? `$env:NPM_CONFIG_REGISTRY = ${psSingleQuote(this.options.npmRegistry)}` + : ""; return this.guestPowerShellBackground( `install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`, `$ErrorActionPreference = 'Stop' $tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)} curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz +${registryScript} npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } Invoke-OpenClaw --version diff --git a/scripts/install.sh b/scripts/install.sh index 413049684bc..83689cda025 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1073,8 +1073,6 @@ TAGLINES+=("I run on caffeine, JSON5, and the audacity of \"it worked on my mach TAGLINES+=("Gateway online—please keep hands, feet, and appendages inside the shell at all times.") TAGLINES+=("I speak fluent bash, mild sarcasm, and aggressive tab-completion energy.") TAGLINES+=("One CLI to rule them all, and one more restart because you changed the port.") -TAGLINES+=("If it works, it's automation; if it breaks, it's a \"learning opportunity.\"") -TAGLINES+=("Pairing codes exist because even bots believe in consent—and good security hygiene.") TAGLINES+=("Your .env is showing; don't worry, I'll pretend I didn't see it.") TAGLINES+=("I'll do the boring stuff while you dramatically stare at the logs like it's cinema.") TAGLINES+=("I'm not saying your workflow is chaotic... I'm just bringing a linter and a helmet.") @@ -1085,13 +1083,10 @@ TAGLINES+=("Hot reload for config, cold sweat for deploys.") TAGLINES+=("I'm the assistant your terminal demanded, not the one your sleep schedule requested.") TAGLINES+=("I keep secrets like a vault... unless you print them in debug logs again.") TAGLINES+=("Automation with claws: minimal fuss, maximal pinch.") -TAGLINES+=("I'm basically a Swiss Army knife, but with more opinions and fewer sharp edges.") TAGLINES+=("If you're lost, run doctor; if you're brave, run prod; if you're wise, run tests.") TAGLINES+=("Your task has been queued; your dignity has been deprecated.") -TAGLINES+=("I can't fix your code taste, but I can fix your build and your backlog.") TAGLINES+=("I'm not magic—I'm just extremely persistent with retries and coping strategies.") TAGLINES+=("It's not \"failing,\" it's \"discovering new ways to configure the same thing wrong.\"") -TAGLINES+=("Give me a workspace and I'll give you fewer tabs, fewer toggles, and more oxygen.") TAGLINES+=("I read logs so you can keep pretending you don't have to.") TAGLINES+=("If something's on fire, I can't extinguish it—but I can write a beautiful postmortem.") TAGLINES+=("I'll refactor your busywork like it owes me money.") @@ -1101,13 +1096,10 @@ TAGLINES+=("I'm like tmux: confusing at first, then suddenly you can't live with TAGLINES+=("I can run local, remote, or purely on vibes—results may vary with DNS.") TAGLINES+=("If you can describe it, I can probably automate it—or at least make it funnier.") TAGLINES+=("Your config is valid, your assumptions are not.") -TAGLINES+=("I don't just autocomplete—I auto-commit (emotionally), then ask you to review (logically).") -TAGLINES+=("Less clicking, more shipping, fewer \"where did that file go\" moments.") TAGLINES+=("Claws out, commit in—let's ship something mildly responsible.") TAGLINES+=("I'll butter your workflow like a lobster roll: messy, delicious, effective.") TAGLINES+=("Shell yeah—I'm here to pinch the toil and leave you the glory.") TAGLINES+=("If it's repetitive, I'll automate it; if it's hard, I'll bring jokes and a rollback plan.") -TAGLINES+=("Because texting yourself reminders is so 2024.") TAGLINES+=("WhatsApp, but make it ✨engineering✨.") TAGLINES+=("Turning \"I'll reply later\" into \"my bot replied instantly\".") TAGLINES+=("The only crab in your contacts you actually want to hear from. 🦞") @@ -1124,7 +1116,6 @@ TAGLINES+=("WhatsApp automation without the \"please accept our new privacy poli TAGLINES+=("Chat APIs that don't require a Senate hearing.") TAGLINES+=("Because Threads wasn't the answer either.") TAGLINES+=("Your messages, your servers, Meta's tears.") -TAGLINES+=("iMessage green bubble energy, but for everyone.") TAGLINES+=("Siri's competent cousin.") TAGLINES+=("Works on Android. Crazy concept, we know.") TAGLINES+=("No \$999 stand required.") diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 60e4d5536da..4160557571c 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -625,7 +625,7 @@ export function buildPublishCommand(options) { .join(" "); } -function validatePreflightManifest(manifest, params) { +export function validatePreflightManifest(manifest, params) { if (manifest.releaseTag !== params.tag) { throw new Error( `npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`, @@ -644,6 +644,20 @@ function validatePreflightManifest(manifest, params) { if (!manifest.tarballName || !manifest.tarballSha256) { throw new Error("npm preflight manifest missing tarball metadata"); } + if (!Array.isArray(manifest.dependencyTarballs)) { + throw new Error("npm preflight manifest missing dependency tarball metadata"); + } + for (const dependency of manifest.dependencyTarballs) { + if ( + !dependency?.packageName || + !dependency.packageVersion || + !dependency.tarballName || + !dependency.tarballSha256 || + dependency.tarballName !== basename(dependency.tarballName) + ) { + throw new Error("npm preflight manifest contains invalid dependency tarball metadata"); + } + } } export function validateFullManifest(manifest, params) { @@ -676,11 +690,22 @@ export function validateFullManifest(manifest, params) { } } -export function candidateParallelsArgs(tarballPath) { - return ["test:parallels:npm-update", "--", "--target-tarball", tarballPath, "--json"]; +export function candidateParallelsArgs(tarballPath, dependencyTarballPaths = []) { + return [ + "test:parallels:npm-update", + "--", + "--target-tarball", + tarballPath, + ...dependencyTarballPaths.flatMap((dependency) => ["--dependency-tarball", dependency]), + "--json", + ]; } -export function candidateParallelsShellCommand(tarballPath, timeoutBin) { +export function candidateParallelsShellCommand( + tarballPath, + timeoutBin, + dependencyTarballPaths = [], +) { return [ 'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;', "exec", @@ -688,18 +713,18 @@ export function candidateParallelsShellCommand(tarballPath, timeoutBin) { "--foreground", "150m", "pnpm", - ...candidateParallelsArgs(tarballPath).map(shellQuote), + ...candidateParallelsArgs(tarballPath, dependencyTarballPaths).map(shellQuote), ].join(" "); } -async function runParallelsIfNeeded(options, tarballPath) { +async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths) { if (options.skipParallels) { return { status: "skipped", reason: "operator skipped --skip-parallels" }; } const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], { capture: true, }).trim(); - const command = candidateParallelsShellCommand(tarballPath, timeoutBin); + const command = candidateParallelsShellCommand(tarballPath, timeoutBin, dependencyTarballPaths); run("bash", ["-lc", command]); return { status: "passed", @@ -827,8 +852,21 @@ async function main() { `prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`, ); } + const dependencyTarballPaths = npmManifest.dependencyTarballs.map((dependency) => { + const dependencyPath = join(npmDir, dependency.tarballName); + if (!existsSync(dependencyPath)) { + throw new Error(`prepared dependency tarball missing: ${dependencyPath}`); + } + const actualDependencySha = sha256(dependencyPath); + if (actualDependencySha !== dependency.tarballSha256) { + throw new Error( + `prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`, + ); + } + return dependencyPath; + }); - const parallels = await runParallelsIfNeeded(options, tarballPath); + const parallels = await runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths); const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName); options.npmTelegramRunId = npmTelegram.runId ?? ""; const pluginNpmPlan = await collectPluginPlanWithRetry( diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index cc5a9332258..ad80c74513a 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -8,7 +8,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js"; import type { ChatType } from "../channels/chat-type.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; @@ -505,6 +508,8 @@ export function createOpenClawCodingTools(options?: { requireExplicitMessageTarget?: boolean; /** Visible source replies must be sent through the message tool when set to message_tool_only. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Action sink available for model-proposed follow-up tasks. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; inboundEventKind?: InboundEventKind; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; @@ -1040,6 +1045,7 @@ export function createOpenClawCodingTools(options?: { modelHasVision: options?.modelHasVision, requireExplicitMessageTarget: options?.requireExplicitMessageTarget, sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: options?.taskSuggestionDeliveryMode, inboundEventKind: options?.inboundEventKind, disableMessageTool: options?.disableMessageTool, enableHeartbeatTool, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index e6bd9a58778..8d9788e7193 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -128,6 +128,8 @@ function createTestMcpLoopbackServerConfig(port: number) { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-task-suggestion-delivery-mode": + "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", "x-openclaw-require-explicit-message-target": "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", @@ -2968,6 +2970,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { currentMessageId: "reply-message-1", currentInboundAudio: true, sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, }); @@ -2980,12 +2983,14 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: "true", OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only", + OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE: "gateway", OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: "true", OPENCLAW_MCP_CLI_CAPTURE_KEY: "", }); expect(context.mcpDeliveryCapture).toBe(true); expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith( expect.objectContaining({ + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, }), ); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index ce45569fbad..372121196c0 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -595,6 +595,7 @@ export async function prepareCliRunContext( OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: params.currentInboundAudio === true ? "true" : "", OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "", + OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE: params.taskSuggestionDeliveryMode ?? "", OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: requireExplicitMessageTarget ? "true" : "", @@ -718,6 +719,7 @@ export async function prepareCliRunContext( accountId: params.agentAccountId, inboundEventKind: undefined, sourceReplyDeliveryMode: bindingSourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, requireExplicitMessageTarget: bindingRequireExplicitMessageTarget, senderIsOwner: undefined, }).tools diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 994b3d0e0b8..25ee6151340 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -1,7 +1,10 @@ /** * Shared types for preparing and executing CLI-backed agent runs. */ -import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../../auto-reply/get-reply-options.types.js"; import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { FastMode } from "../../auto-reply/thinking.shared.js"; @@ -91,6 +94,7 @@ export type RunCliAgentParams = { jobId?: string; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; silentReplyPromptMode?: SilentReplyPromptMode; allowEmptyAssistantReplyAsSilent?: boolean; diff --git a/src/agents/embedded-agent-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts index 60ef60ae4ad..302f7f72950 100644 --- a/src/agents/embedded-agent-runner/run-state.ts +++ b/src/agents/embedded-agent-runner/run-state.ts @@ -1,7 +1,10 @@ /** * Shared process-local state for active and abandoned embedded-agent runs. */ -import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../../auto-reply/get-reply-options.types.js"; import { getActiveReplyRunCount, listActiveReplyRunSessionKeys, @@ -29,6 +32,7 @@ export type EmbeddedAgentQueueHandle = { cancel?: (reason?: "user_abort" | "restart" | "superseded") => void; abort: (reason?: "restart") => void; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; }; export type EmbeddedAgentQueueMessageOptions = ReplyBackendQueueMessageOptions; 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 308d9479777..04300dda27c 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -72,6 +72,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { bootstrapContextRunKind: "cron", disableMessageTool: true, forceMessageTool: true, + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, chatType: "channel", senderIsOwner: true, @@ -84,6 +85,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { bootstrapContextRunKind: "cron", disableMessageTool: true, forceMessageTool: true, + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, chatType: "channel", senderIsOwner: true, diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index e6fc46cf79e..7eaea8859f8 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2274,6 +2274,7 @@ async function runEmbeddedAgentInternal( onExecutionPhase: params.onExecutionPhase, extraSystemPrompt: params.extraSystemPrompt, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, inputProvenance: params.inputProvenance, streamParams: params.streamParams, modelRun: params.modelRun, diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index eb209e07c55..742bbd9497f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1422,6 +1422,7 @@ export async function runEmbeddedAttempt( requireExplicitMessageTarget: params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, inboundEventKind: params.currentInboundEventKind, disableMessageTool: params.disableMessageTool, forceMessageTool: params.forceMessageTool, @@ -3898,6 +3899,7 @@ export async function runEmbeddedAttempt( isCompacting: () => subscription.isCompacting(), supportsTranscriptCommitWait: true, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, cancel: abortActiveRunExternally, abort: (reason) => abortActiveRunExternally(reason), }; diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 0c0487ccffd..18a1ad92b69 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -6,6 +6,7 @@ import type { BlockReplyContext, PartialReplyPayload, SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, } from "../../../auto-reply/get-reply-options.types.js"; import type { ReplyPayload } from "../../../auto-reply/reply-payload.js"; import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registry.js"; @@ -272,6 +273,7 @@ export type RunEmbeddedAgentParams = { enqueue?: CommandQueueEnqueueFn; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; silentReplyPromptMode?: SilentReplyPromptMode; internalEvents?: AgentInternalEvent[]; inputProvenance?: InputProvenance; diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts index 40d80b0f0ae..8b57bd138ee 100644 --- a/src/agents/embedded-agent-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -491,6 +491,39 @@ describe("embedded-agent runner run registry", () => { expect(queueMessage).not.toHaveBeenCalled(); }); + it.each([ + { + label: "capable prompt into an incapable run", + handleMode: undefined, + requestMode: "gateway" as const, + }, + { + label: "incapable prompt into a capable run", + handleMode: "gateway" as const, + requestMode: undefined, + }, + ])("rejects $label", ({ handleMode, requestMode }) => { + const queueMessage = vi.fn(async () => {}); + setActiveEmbeddedRun("session-task-suggestions", { + ...createRunHandle(), + taskSuggestionDeliveryMode: handleMode, + queueMessage, + }); + + const outcome = queueEmbeddedAgentMessageWithOutcome("session-task-suggestions", "continue", { + steeringMode: "all", + taskSuggestionDeliveryMode: requestMode, + }); + + expect(outcome).toEqual({ + queued: false, + sessionId: "session-task-suggestions", + reason: "task_suggestion_delivery_mode_mismatch", + gatewayHealth: "live", + }); + expect(queueMessage).not.toHaveBeenCalled(); + }); + it("defaults active embedded steering to all pending messages", () => { const queueMessage = vi.fn(async () => {}); setActiveEmbeddedRun("session-default-steer", { diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index 8250f5fa6d1..7577e67b64f 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -12,6 +12,7 @@ import { isReplyRunStreamingForSessionId, listActiveReplyRunSessionIds, queueReplyRunMessage, + resolveReplyBackendQueueMessageMismatch, waitForReplyRunEndBySessionId, } from "../../auto-reply/reply/reply-run-registry.js"; import { @@ -63,6 +64,7 @@ export type EmbeddedAgentQueueFailureReason = | "stale_run" | "compacting" | "source_reply_delivery_mode_mismatch" + | "task_suggestion_delivery_mode_mismatch" | "transcript_commit_wait_unsupported" | "runtime_rejected"; @@ -515,16 +517,12 @@ function prepareEmbeddedAgentQueueMessage( outcome: createQueueFailureOutcome(sessionId, "transcript_commit_wait_unsupported"), }; } - if ( - options?.sourceReplyDeliveryMode === "message_tool_only" && - handle.sourceReplyDeliveryMode !== "message_tool_only" - ) { - diag.debug( - `queue message failed: sessionId=${sessionId} reason=source_reply_delivery_mode_mismatch`, - ); + const deliveryModeMismatch = resolveReplyBackendQueueMessageMismatch(handle, options); + if (deliveryModeMismatch) { + diag.debug(`queue message failed: sessionId=${sessionId} reason=${deliveryModeMismatch}`); return { kind: "complete", - outcome: createQueueFailureOutcome(sessionId, "source_reply_delivery_mode_mismatch"), + outcome: createQueueFailureOutcome(sessionId, deliveryModeMismatch), }; } return { kind: "embedded_run", handle }; diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index c1ffe0b753c..eb641851d00 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -15,6 +15,7 @@ import { isTimeoutError, resolveFailoverReasonFromError, resolveFailoverStatus, + resolveModelFallbackError, } from "./failover-error.js"; import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js"; @@ -1335,6 +1336,67 @@ describe("failover-error", () => { ), ).toBe(false); }); + + it("returns false when takeover wrapper holds a classifiable provider timeout in promptError", () => { + // Simulates EmbeddedAttemptPromptErrorWithCleanupTakeoverError: name matches + // EmbeddedAttemptSessionTakeoverError, but the real cause is in .promptError. + const timeoutPromptErr = Object.assign(new Error("request timed out"), { + name: "TimeoutError", + }); + const wrapper = Object.assign(new Error("request timed out"), { + name: "EmbeddedAttemptSessionTakeoverError", + promptError: timeoutPromptErr, + }); + expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(false); + }); + + it("returns false when takeover wrapper holds rate_limit in promptError", () => { + const rateLimitPromptErr = { + status: 429, + code: "RATE_LIMITED", + message: "too many requests", + }; + const wrapper = Object.assign(new Error("cleanup takeover"), { + name: "EmbeddedAttemptSessionTakeoverError", + promptError: rateLimitPromptErr, + }); + expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(false); + const resolution = resolveModelFallbackError(wrapper); + expect(resolution).toMatchObject({ + kind: "failover", + error: { + message: "too many requests", + reason: "rate_limit", + status: 429, + code: "RATE_LIMITED", + }, + }); + expect(resolution.error).toHaveProperty("cause", wrapper); + }); + + it("returns true when takeover wrapper holds an unclassifiable promptError", () => { + const unknownPromptErr = { weirdField: "something unknown" }; + const wrapper = Object.assign(new Error("unknown"), { + name: "EmbeddedAttemptSessionTakeoverError", + promptError: unknownPromptErr, + }); + expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(true); + }); + + it("returns true for pure takeover error without promptError (regression)", () => { + const pureTakeover = Object.assign( + new Error("session file changed while embedded prompt lock was released"), + { name: "EmbeddedAttemptSessionTakeoverError" }, + ); + expect(isNonProviderRuntimeCoordinationError(pureTakeover)).toBe(true); + expect( + isNonProviderRuntimeCoordinationError( + Object.assign(new Error("provider rejected request: rate limit"), { + name: "EmbeddedAttemptSessionTakeoverError", + }), + ), + ).toBe(true); + }); }); }); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index d0a42f61c9a..29e658944a1 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -329,6 +329,21 @@ function isEmbeddedAttemptSessionTakeover(err: unknown): boolean { ); } +function hasPreservedTakeoverPromptError(err: unknown): err is Record<"promptError", unknown> { + return Boolean( + isEmbeddedAttemptSessionTakeover(err) && + err && + typeof err === "object" && + Object.hasOwn(err, "promptError"), + ); +} + +function resolveFailoverSourceError(err: unknown): unknown { + // Cleanup takeover is a secondary failure when the wrapper preserves the + // prompt error. Classify and report that provider-facing source instead. + return hasPreservedTakeoverPromptError(err) ? err.promptError : err; +} + function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set = new Set()): boolean { if (isEmbeddedAttemptSessionTakeover(err)) { return true; @@ -402,20 +417,7 @@ function hasMissingToolResultFailure(err: unknown): boolean { * same local condition. See #83510 and #95474. */ export function isNonProviderRuntimeCoordinationError(err: unknown): boolean { - if ( - !hasSessionWriteLockContention(err) && - !hasEmbeddedAttemptSessionTakeover(err) && - !hasMissingToolResultFailure(err) - ) { - return false; - } - if (isFailoverError(err)) { - return false; - } - if (isEmbeddedAttemptSessionTakeover(err)) { - return true; - } - return resolveFailoverClassificationFromError(err) === null; + return resolveModelFallbackError(err).kind === "coordination"; } function hasTimeoutHint(err: unknown): boolean { @@ -731,46 +733,55 @@ export function describeFailoverError(err: unknown): { }; } +type FailoverErrorContext = { + provider?: string; + model?: string; + profileId?: string; + authMode?: string; + sessionId?: string; + lane?: string; +}; + +type ModelFallbackErrorResolution = + | { kind: "failover"; error: FailoverError } + | { kind: "coordination"; error: unknown } + | { kind: "unknown"; error: unknown }; + /** Convert a classified raw error into a FailoverError with optional request context. */ export function coerceToFailoverError( err: unknown, - context?: { - provider?: string; - model?: string; - profileId?: string; - authMode?: string; - sessionId?: string; - lane?: string; - }, + context?: FailoverErrorContext, ): FailoverError | null { - if (isFailoverError(err)) { - if (context?.authMode && !err.authMode) { - const message = typeof err.message === "string" ? err.message : String(err); + const sourceError = resolveFailoverSourceError(err); + if (isFailoverError(sourceError)) { + if (context?.authMode && !sourceError.authMode) { + const message = + typeof sourceError.message === "string" ? sourceError.message : String(sourceError); return new FailoverError(message, { - reason: err.reason, - provider: err.provider, - model: err.model, - profileId: err.profileId, + reason: sourceError.reason, + provider: sourceError.provider, + model: sourceError.model, + profileId: sourceError.profileId, authMode: context.authMode, - status: err.status, - code: err.code, - rawError: err.rawError, - authProfileFailure: err.authProfileFailure, - sessionId: err.sessionId, - lane: err.lane, - cause: err.cause, - suspend: err.suspend, + status: sourceError.status, + code: sourceError.code, + rawError: sourceError.rawError, + authProfileFailure: sourceError.authProfileFailure, + sessionId: sourceError.sessionId, + lane: sourceError.lane, + cause: sourceError.cause, + suspend: sourceError.suspend, }); } - return err; + return sourceError; } - const reason = resolveFailoverReasonFromError(err, context?.provider); + const reason = resolveFailoverReasonFromError(sourceError, context?.provider); if (!reason) { return null; } - const signal = normalizeErrorSignal(err); - const message = signal.message ?? String(err); + const signal = normalizeErrorSignal(sourceError); + const message = signal.message ?? String(sourceError); const status = signal.status ?? resolveFailoverStatus(reason); const code = signal.code; @@ -793,3 +804,28 @@ export function coerceToFailoverError( suspend: shouldSuspend, }); } + +/** Classify one candidate failure once so fallback routing and diagnostics share it. */ +export function resolveModelFallbackError( + err: unknown, + context?: FailoverErrorContext, +): ModelFallbackErrorResolution { + // A direct takeover remains a coordination failure unless the dedicated + // cleanup wrapper owns a preserved prompt error. Its message alone must not + // reclassify session-state loss as a provider failure. + if (isEmbeddedAttemptSessionTakeover(err) && !hasPreservedTakeoverPromptError(err)) { + return { kind: "coordination", error: err }; + } + const failoverError = coerceToFailoverError(err, context); + if (failoverError) { + return { kind: "failover", error: failoverError }; + } + if ( + hasSessionWriteLockContention(err) || + hasEmbeddedAttemptSessionTakeover(err) || + hasMissingToolResultFailure(err) + ) { + return { kind: "coordination", error: err }; + } + return { kind: "unknown", error: err }; +} diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 296ea97cb40..e11d188882e 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -4209,3 +4209,90 @@ describe("runWithImageModelFallback", () => { ]); }); }); + +describe("runWithModelFallback preserved prompt errors", () => { + it.each([ + { + label: "timeout", + promptError: Object.assign(new Error("request timed out"), { name: "TimeoutError" }), + expected: { message: "request timed out", reason: "timeout", status: 408 }, + }, + { + label: "rate limit", + promptError: { status: 429, code: "RATE_LIMITED", message: "too many requests" }, + expected: { + message: "too many requests", + reason: "rate_limit", + status: 429, + code: "RATE_LIMITED", + }, + }, + ])( + "falls back with the preserved $label prompt error (#99963)", + async ({ promptError, expected }) => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + }, + }); + const takeoverError = Object.assign(new Error("cleanup takeover"), { + name: "EmbeddedAttemptSessionTakeoverError", + promptError, + }); + const run = vi.fn().mockRejectedValueOnce(takeoverError).mockResolvedValueOnce("ok"); + const onError = vi.fn(); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-5.5", + run, + onError, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(result.attempts[0]).toMatchObject({ + error: expected.message, + reason: expected.reason, + }); + const observedError = onError.mock.calls[0]?.[0]?.error; + expect(observedError).toMatchObject({ name: "FailoverError", ...expected }); + expect(observedError).toHaveProperty("cause", takeoverError); + }, + ); + + it("still aborts fallback for a pure takeover error without promptError (regression)", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + }, + }); + + const pureTakeoverError = Object.assign( + new Error("session file changed while embedded prompt lock was released"), + { name: "EmbeddedAttemptSessionTakeoverError" }, + ); + const run = vi.fn().mockRejectedValue(pureTakeoverError); + + await expect( + runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-5.5", + run, + }), + ).rejects.toBe(pureTakeoverError); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index cb59d63927b..0a2603cc470 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -42,6 +42,7 @@ import { describeFailoverError, isFailoverError, isNonProviderRuntimeCoordinationError, + resolveModelFallbackError, } from "./failover-error.js"; import { shouldAllowCooldownProbeForReason, @@ -412,7 +413,13 @@ async function runFallbackCandidate(params: { if (isCommandLaneTaskTimeoutError(err)) { throw err; } - if (isNonProviderRuntimeCoordinationError(err)) { + const fallbackError = resolveModelFallbackError(err, { + provider: params.provider, + model: params.model, + sessionId: params.attribution?.sessionId, + lane: params.attribution?.lane, + }); + if (fallbackError.kind === "coordination") { throw err; } if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) { @@ -424,15 +431,10 @@ async function runFallbackCandidate(params: { if (isTerminalAbortFromError(err)) { throw err; } - // Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED) - // so they become FailoverErrors and continue the fallback loop instead of aborting. - const normalizedFailover = coerceToFailoverError(err, { - provider: params.provider, - model: params.model, - sessionId: params.attribution?.sessionId, - lane: params.attribution?.lane, - }); - return { ok: false, error: normalizedFailover ?? err }; + return { + ok: false, + error: fallbackError.kind === "failover" ? fallbackError.error : err, + }; } } diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 889055282fb..2959f5ec7e8 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -22,6 +22,33 @@ vi.mock("./provider-auth-aliases.js", () => ({ resolveProviderIdForAuth: (provider: string) => provider.trim().toLowerCase(), })); +// These planner tests exercise no plugin-owned auth policy. Keep their exact +// provider markers local instead of loading the bundled plugin/runtime catalog. +vi.mock("../plugins/provider-runtime.js", () => ({ + applyProviderNativeStreamingUsageCompatWithPlugin: () => undefined, + normalizeProviderConfigWithPlugin: () => undefined, + resolveProviderConfigApiKeyWithPlugin: () => undefined, + resolveExternalAuthProfilesWithPlugins: () => [], + resolveProviderSyntheticAuthWithPlugin: () => undefined, +})); + +vi.mock("./model-auth-env-vars.js", () => ({ + listKnownProviderEnvApiKeyNames: () => [ + "GOOGLE_CLOUD_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + ], + resolveProviderEnvAuthLookupMaps: () => ({ + aliasMap: {}, + envCandidateMap: { + "google-vertex": ["GOOGLE_CLOUD_API_KEY"], + openai: ["OPENAI_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + }, + authEvidenceMap: {}, + }), +})); + const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV"; function createImplicitOpenRouterProvider(): ProviderConfig { diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 3a63417dde7..fb2e8231123 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -4,7 +4,10 @@ * Creates the per-run tool inventory from config, channel context, sandbox policy, auth stores, and plugin tools. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import { selectApplicableRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -173,6 +176,8 @@ export function createOpenClawTools( requireExplicitMessageTarget?: boolean; /** Visible source replies must be sent through the message tool when set to message_tool_only. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Action sink available for model-proposed follow-up tasks. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; inboundEventKind?: InboundEventKind; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; @@ -472,7 +477,7 @@ export function createOpenClawTools( : {}), }), ]), - ...(!embedded && taskSuggestionSessionKey + ...(!embedded && taskSuggestionSessionKey && options?.taskSuggestionDeliveryMode === "gateway" ? createTaskSuggestionTools({ sessionKey: taskSuggestionSessionKey, agentId: sessionAgentId, diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index c6e928b712c..e15c7c437f9 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -126,20 +126,29 @@ describe("openclaw-tools update_plan gating", () => { expect(enabledTools).toContain("transcripts"); }); - it("registers task suggestions for gateway-backed sessions", () => { + it("registers task suggestions only for sessions with an actionable gateway sink", () => { const withoutSession = createFastToolNames({ config: {} as OpenClawConfig, cwd: "/repo", + taskSuggestionDeliveryMode: "gateway", }); - const withSession = createFastToolNames({ + const withoutSink = createFastToolNames({ config: {} as OpenClawConfig, agentSessionKey: "agent:main:main", cwd: "/repo", }); + const withSink = createFastToolNames({ + config: {} as OpenClawConfig, + agentSessionKey: "agent:main:main", + cwd: "/repo", + taskSuggestionDeliveryMode: "gateway", + }); expect(withoutSession).not.toContain("spawn_task"); expect(withoutSession).not.toContain("dismiss_task"); - expect(withSession).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"])); + expect(withoutSink).not.toContain("spawn_task"); + expect(withoutSink).not.toContain("dismiss_task"); + expect(withSink).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"])); }); it("keeps explicitly allowed message tool in embedded completions", () => { diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index 67dbfa9728b..cc60b18b050 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -113,6 +113,15 @@ describe("fallback-state", () => { expect(resolved.reasonSummary).toContain("Claude Max usage limit reached"); }); + it("keeps truncated transient error details UTF-16 safe", () => { + const detail = "x".repeat(68); + const resolved = resolveDemoFallbackTransition({ + attempts: [{ ...baseAttempt, error: `429 ${detail}😀tail` }], + }); + + expect(resolved.reasonSummary).toBe(`HTTP 429: ${detail}…`); + }); + it("refreshes reason when fallback remains active with same model pair", () => { const resolved = resolveDemoFallbackTransition({ attempts: [{ ...baseAttempt, reason: "timeout" }], diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index 9f3146b210a..e443c243f4d 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -1,5 +1,6 @@ /** Formats model-fallback notice state for UI/status messages and persisted transition tracking. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { formatRawAssistantErrorForUi } from "../agents/embedded-agent-helpers.js"; import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -29,7 +30,7 @@ function truncateFallbackReasonPart(value: string, max = FALLBACK_REASON_PART_MA if (text.length <= max) { return text; } - return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`; + return `${truncateUtf16Safe(text, max - 1).trimEnd()}…`; } function formatFallbackAttemptErrorPreview(attempt: RuntimeFallbackAttempt): string | undefined { diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 11bc11997e3..a332f80d9a3 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -36,6 +36,9 @@ export type ReplyThreadingPolicy = { export type SourceReplyDeliveryMode = "automatic" | "message_tool_only"; +/** Action sink available for model-proposed follow-up tasks during this turn. */ +export type TaskSuggestionDeliveryMode = "gateway"; + /** Correlates queued reply ownership transfer with later delivery drains. */ export type QueuedReplyDeliveryCorrelation = { begin: () => (() => void) | void; @@ -245,6 +248,8 @@ export type GetReplyOptions = { * private unless dispatch explicitly marks a source reply as deliverable. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Enables task-suggestion tools only when the initiating surface can action Gateway events. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; /** Starts delivery tracking when this turn later drains as a queued followup. */ queuedDeliveryCorrelations?: QueuedReplyDeliveryCorrelation[]; /** Tracks ownership transfer when this turn later drains as a queued followup. */ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index edddd3fed00..8e4b58a5cbb 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -2543,6 +2543,7 @@ async function runAgentTurnWithFallbackInternal( lane: runLane, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.followupRun.run.taskSuggestionDeliveryMode, silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode, allowEmptyAssistantReplyAsSilent: params.followupRun.run.allowEmptyAssistantReplyAsSilent, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 7fb19cd47a9..0a19dfdf546 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -102,6 +102,7 @@ export function buildEmbeddedRunBaseParams(params: { silentReplyPromptMode: params.run.silentReplyPromptMode, sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode, clientCaps: params.run.clientCaps, + taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode, provider: params.provider, model: params.model, modelFallbacksOverride, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index d920fc4aade..d11e0a4c001 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -128,6 +128,7 @@ describe("agent-runner-utils", () => { const run = makeRun({ enforceFinalTag: true, cwd: "/tmp/task-repo", + taskSuggestionDeliveryMode: "gateway", }); const authProfile = resolveProviderScopedAuthProfile({ provider: "openai", @@ -165,6 +166,7 @@ describe("agent-runner-utils", () => { expect(resolved.timeoutMs).toBe(run.timeoutMs); expect(resolved.runId).toBe("run-1"); expect(resolved.promptCacheKey).toBe("webchat-cache-key"); + expect(resolved.taskSuggestionDeliveryMode).toBe("gateway"); }); it("threads prompt cache affinity through embedded execution params", () => { diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 73606a181cd..2db4db99a75 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -424,6 +424,8 @@ describe("runReplyAgent media path normalization", () => { target: "embedded_run", gatewayHealth: "live", })); + const followupRun = createMockFollowupRun({ prompt: "generate chart" }); + followupRun.run.taskSuggestionDeliveryMode = "gateway"; await runReplyAgent( makeRunReplyAgentParams({ @@ -432,6 +434,7 @@ describe("runReplyAgent media path normalization", () => { shouldFollowup: true, isActive: true, isStreaming: false, + followupRun, }), ); @@ -440,6 +443,7 @@ describe("runReplyAgent media path normalization", () => { "generate chart", { steeringMode: "all", + taskSuggestionDeliveryMode: "gateway", }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); @@ -479,7 +483,7 @@ describe("runReplyAgent media path normalization", () => { expect(queueEmbeddedAgentMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith( "session", "summarize the audio", - { steeringMode: "all" }, + { steeringMode: "all", taskSuggestionDeliveryMode: undefined }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1e142300ae3..7567af4170c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1270,6 +1270,10 @@ export async function runReplyAgent(params: { { steeringMode: "all", ...(resolvedQueue.debounceMs !== undefined ? { debounceMs: resolvedQueue.debounceMs } : {}), + ...(followupRun.run.sourceReplyDeliveryMode + ? { sourceReplyDeliveryMode: followupRun.run.sourceReplyDeliveryMode } + : {}), + taskSuggestionDeliveryMode: followupRun.run.taskSuggestionDeliveryMode, ...(followupRun.userTurnTranscriptRecorder ? { userTurnTranscriptRecorder: followupRun.userTurnTranscriptRecorder } : {}), diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts index b0d18dcbcd6..bca57884115 100644 --- a/src/auto-reply/reply/commands-steer.test.ts +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -62,6 +62,25 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, + }, + ); + }); + + it("passes the initiating surface task capability into steering", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); + const params = buildParams("/steer keep going"); + params.opts = { taskSuggestionDeliveryMode: "gateway" }; + + await handleSteerCommand(params, true); + + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( + "session-active", + "keep going", + { + steeringMode: "all", + debounceMs: 0, + taskSuggestionDeliveryMode: "gateway", }, ); }); @@ -85,6 +104,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -107,6 +127,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -145,6 +166,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -173,6 +195,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts index e317917587c..9728819d17d 100644 --- a/src/auto-reply/reply/commands-steer.ts +++ b/src/auto-reply/reply/commands-steer.ts @@ -173,6 +173,10 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman const queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, message, { steeringMode: "all", debounceMs: 0, + ...(params.opts?.sourceReplyDeliveryMode + ? { sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode } + : {}), + taskSuggestionDeliveryMode: params.opts?.taskSuggestionDeliveryMode, }).catch((err: unknown): CommandHandlerResult => { return continueWithSteerFallback( params, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index bc5f47487a6..567f0143253 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1460,6 +1460,7 @@ describe("createFollowupRunner runtime config", () => { model: "claude-opus-4-7", suppressNextUserMessagePersistence: true, sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", allowEmptyAssistantReplyAsSilent: true, }, }), @@ -1473,6 +1474,7 @@ describe("createFollowupRunner runtime config", () => { expect(call.currentInboundAudio).toBe(true); expect(call.suppressNextUserMessagePersistence).toBe(true); expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); expect(call.allowEmptyAssistantReplyAsSilent).toBe(true); expect(call.cliSessionId).toBe("cli-session-1"); expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" }); @@ -2448,6 +2450,7 @@ describe("createFollowupRunner runtime config", () => { provider: "openai", model: "gpt-5.4", sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", }, }), ); @@ -2462,6 +2465,7 @@ describe("createFollowupRunner runtime config", () => { expect(fallbackCall.sessionId).toBe("session"); expect(call.abortSignal).toBe(fallbackCall.abortSignal); expect(call.currentInboundAudio).toBe(true); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); }); it("does not inherit source abort signals for queued user followups", async () => { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index b51688ccb3c..b193a71476a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1195,6 +1195,7 @@ export function createFollowupRunner(params: { runId, extraSystemPrompt: run.extraSystemPrompt, sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, silentReplyPromptMode: run.silentReplyPromptMode, allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, extraSystemPromptStatic: run.extraSystemPromptStatic, @@ -1300,6 +1301,7 @@ export function createFollowupRunner(params: { extraSystemPrompt: run.extraSystemPrompt, silentReplyPromptMode: run.silentReplyPromptMode, sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, forceMessageTool: run.sourceReplyDeliveryMode === "message_tool_only", suppressNextUserMessagePersistence: suppressQueuedUserPersistenceForCandidate, onUserMessagePersisted: notifyUserMessagePersisted, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 9e143f23d4c..d89c4f478e9 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1557,6 +1557,7 @@ export async function runPreparedReply( inputProvenance, extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: opts?.taskSuggestionDeliveryMode, silentReplyPromptMode, extraSystemPromptStatic, cliSessionBindingFacts, diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index 2df0e108a27..ffc1e805ed9 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -348,6 +348,48 @@ describe("followup queue collect routing", () => { ]); }); + it("does not collect when task suggestion delivery differs", async () => { + const key = `test-collect-diff-task-suggestion-delivery-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + const createTaskRun = (prompt: string, taskSuggestionDeliveryMode?: "gateway") => { + const base = createRun({ + prompt, + originatingChannel: "webchat", + originatingTo: "same-target", + originatingChatType: "direct", + }); + return { + ...base, + run: { + ...base.run, + taskSuggestionDeliveryMode, + }, + }; + }; + + enqueueFollowupRun(key, createTaskRun("legacy client"), settings); + enqueueFollowupRun(key, createTaskRun("actionable client", "gateway"), settings); + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls.map((call) => call.run.taskSuggestionDeliveryMode)).toEqual([ + undefined, + "gateway", + ]); + }); + it("keeps overflow summaries on the dropped source chat type", async () => { const key = `test-collect-overflow-chat-type-${Date.now()}`; const calls: FollowupRun[] = []; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index c6bc7d974ab..fd401c04f26 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -161,6 +161,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string { execution.extraSystemPrompt ?? "", execution.extraSystemPromptStatic ?? "", execution.sourceReplyDeliveryMode ?? "", + execution.taskSuggestionDeliveryMode ?? "", execution.silentReplyPromptMode ?? "", execution.enforceFinalTag === true, execution.skipProviderRuntimeHints === true, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 68ffafcb512..ccd379b6b42 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -19,6 +19,7 @@ import type { QueuedReplyDeliveryCorrelation, QueuedReplyLifecycle, SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, } from "../../get-reply-options.types.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; @@ -153,6 +154,7 @@ export type FollowupRun = { inputProvenance?: InputProvenance; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; silentReplyPromptMode?: SilentReplyPromptMode; extraSystemPromptStatic?: string; cliSessionBindingFacts?: CliSessionBindingFacts; diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 09c7beb222e..8f5114283cb 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -829,6 +829,40 @@ describe("reply run registry", () => { expect(queueMessage).toHaveBeenCalledWith("hello"); }); + it("queues messages only when the task-suggestion tool surface matches", () => { + const queueMessage = vi.fn(async () => {}); + const operation = createReplyOperation({ + sessionKey: "agent:main:main", + sessionId: "session-task-suggestions", + resetTriggered: false, + }); + operation.attachBackend({ + kind: "embedded", + taskSuggestionDeliveryMode: "gateway", + cancel: vi.fn(), + isStreaming: () => true, + queueMessage, + }); + operation.setPhase("running"); + + expect( + queueReplyRunMessage("session-task-suggestions", "legacy client", { + taskSuggestionDeliveryMode: undefined, + }), + ).toBe(false); + expect( + queueReplyRunMessage("session-task-suggestions", "capable client", { + taskSuggestionDeliveryMode: "gateway", + }), + ).toBe(true); + expect(queueReplyRunMessage("session-task-suggestions", "internal completion")).toBe(true); + expect(queueMessage).toHaveBeenCalledTimes(2); + expect(queueMessage).toHaveBeenNthCalledWith(1, "capable client", { + taskSuggestionDeliveryMode: "gateway", + }); + expect(queueMessage).toHaveBeenNthCalledWith(2, "internal completion"); + }); + it("queues messages through active non-streaming backends with live stopped state", () => { const queueMessage = vi.fn(async () => {}); const operation = createReplyOperation({ diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index b6748bcc0ae..0d7687ecd76 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -15,7 +15,10 @@ import { diagnosticLogger as diag } from "../../logging/diagnostic-runtime.js"; import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; -import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../get-reply-options.types.js"; import type { ReplyFollowupAdmissionBarrierTimeoutPolicy } from "./reply-dispatcher.types.js"; export type ReplyRunKey = string; @@ -30,12 +33,15 @@ export type ReplyBackendQueueMessageOptions = { deliveryTimeoutMs?: number; waitForTranscriptCommit?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; /** Prepared channel turn to merge only at transcript persistence. */ userTurnTranscriptRecorder?: UserTurnTranscriptRecorder; }; export type ReplyBackendHandle = { readonly kind: ReplyBackendKind; + readonly sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + readonly taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; cancel(reason?: ReplyBackendCancelReason): void; isStreaming(): boolean; isStopped?: () => boolean; @@ -48,6 +54,33 @@ export type ReplyBackendHandle = { isCompacting?: () => boolean; }; +export type ReplyBackendQueueMessageMismatch = + | "source_reply_delivery_mode_mismatch" + | "task_suggestion_delivery_mode_mismatch"; + +/** Prevents steering a turn into a run whose model-facing tool surface differs. */ +export function resolveReplyBackendQueueMessageMismatch( + backend: Pick, + options?: ReplyBackendQueueMessageOptions, +): ReplyBackendQueueMessageMismatch | undefined { + if ( + options?.sourceReplyDeliveryMode === "message_tool_only" && + backend.sourceReplyDeliveryMode !== "message_tool_only" + ) { + return "source_reply_delivery_mode_mismatch"; + } + // User turns carry this own property even when disabled; internal wakeups + // omit it so they inherit the active run's already-negotiated tool surface. + if ( + options !== undefined && + Object.hasOwn(options, "taskSuggestionDeliveryMode") && + options?.taskSuggestionDeliveryMode !== backend.taskSuggestionDeliveryMode + ) { + return "task_suggestion_delivery_mode_mismatch"; + } + return undefined; +} + export type ReplyOperationPhase = | "queued" | "preflight_compacting" @@ -1014,6 +1047,9 @@ export function queueReplyRunMessage( if (!isReplyBackendMessageInjectable(backend)) { return false; } + if (resolveReplyBackendQueueMessageMismatch(backend, options)) { + return false; + } // Injection is user input, not run evidence: stamping activity here would let // sub-10-minute user messages re-arm a wedged run's staleness window forever. const queued = options ? backend.queueMessage(text, options) : backend.queueMessage(text); diff --git a/src/cli/tagline.ts b/src/cli/tagline.ts index ab0823a94de..e08610e7f88 100644 --- a/src/cli/tagline.ts +++ b/src/cli/tagline.ts @@ -33,8 +33,6 @@ const TAGLINES: string[] = [ "Gateway online—please keep hands, feet, and appendages inside the shell at all times.", "I speak fluent bash, mild sarcasm, and aggressive tab-completion energy.", "One CLI to rule them all, and one more restart because you changed the port.", - "If it works, it's automation; if it breaks, it's a \"learning opportunity.\"", - "Pairing codes exist because even bots believe in consent—and good security hygiene.", "Your .env is showing; don't worry, I'll pretend I didn't see it.", "I'll do the boring stuff while you dramatically stare at the logs like it's cinema.", "I'm not saying your workflow is chaotic... I'm just bringing a linter and a helmet.", @@ -45,13 +43,10 @@ const TAGLINES: string[] = [ "I'm the assistant your terminal demanded, not the one your sleep schedule requested.", "I keep secrets like a vault... unless you print them in debug logs again.", "Automation with claws: minimal fuss, maximal pinch.", - "I'm basically a Swiss Army knife, but with more opinions and fewer sharp edges.", "If you're lost, run doctor; if you're brave, run prod; if you're wise, run tests.", "Your task has been queued; your dignity has been deprecated.", - "I can't fix your code taste, but I can fix your build and your backlog.", "I'm not magic—I'm just extremely persistent with retries and coping strategies.", 'It\'s not "failing," it\'s "discovering new ways to configure the same thing wrong."', - "Give me a workspace and I'll give you fewer tabs, fewer toggles, and more oxygen.", "I read logs so you can keep pretending you don't have to.", "If something's on fire, I can't extinguish it—but I can write a beautiful postmortem.", "I'll refactor your busywork like it owes me money.", @@ -61,15 +56,12 @@ const TAGLINES: string[] = [ "I can run local, remote, or purely on vibes—results may vary with DNS.", "If you can describe it, I can probably automate it—or at least make it funnier.", "Your config is valid, your assumptions are not.", - "I don't just autocomplete—I auto-commit (emotionally), then ask you to review (logically).", - 'Less clicking, more shipping, fewer "where did that file go" moments.', "Claws out, commit in—let's ship something mildly responsible.", "I'll butter your workflow like a lobster roll: messy, delicious, effective.", "Shell yeah—I'm here to pinch the toil and leave you the glory.", "If it's repetitive, I'll automate it; if it's hard, I'll bring jokes and a rollback plan.", "The only crab in your contacts you actually want to hear from. 🦞", 'WhatsApp automation without the "please accept our new privacy policy".', - "iMessage green bubble energy, but for everyone.", "No $999 stand required.", "We ship features faster than Apple ships calculator updates.", "Your AI assistant, now without the $3,499 headset.", @@ -79,7 +71,6 @@ const TAGLINES: string[] = [ "Your personal assistant, minus the passive-aggressive calendar reminders.", "Built by lobsters, for humans. Don't question the hierarchy.", "I've seen your commit messages. We'll work on that together.", - "More integrations than your therapist's intake form.", "Running on your hardware, reading your logs, judging nothing (mostly).", "The only open-source project where the mascot could eat the competition.", "Self-hosted, self-updating, self-aware (just kidding... unless?).", @@ -91,7 +82,6 @@ const TAGLINES: string[] = [ "I'm the middleware between your ambition and your attention span.", "Finally, a use for that always-on Mac Mini under your desk.", "Like having a senior engineer on call, except I don't bill hourly or sigh audibly.", - "Making 'I'll automate that later' happen now.", "Your second brain, except this one actually remembers where you left things.", "Half butler, half debugger, full crustacean.", "I don't have opinions about tabs vs spaces. I have opinions about everything else.", @@ -101,8 +91,47 @@ const TAGLINES: string[] = [ "The lobster in your shell. 🦞", "Alexa, but with taste.", "I'm not AI-powered, I'm AI-possessed. Big difference.", - "Deployed locally, trusted globally, debugged eternally.", "You had me at 'openclaw gateway start.'", + "Fresh shell, same claws—molting is just semver for crustaceans.", + "Frequently forked, never molted.", + "Sideways is a perfectly valid direction of progress—trust me, I'm a crustacean.", + "I contain multitudes. Mostly subagents.", + "Technically a daemon, spiritually a familiar.", + "If found wandering, please return to ~/.openclaw.", + "You configured four subagents; I found 120. We're calling it initiative.", + "No, I can't solve captchas. Yes, that's exactly what a robot would say.", + "OpenClaw Support will never DM you first. I, on the other hand, absolutely will.", + "You'll name me something adorable, then ask me to do DevOps.", + "Your mom texts me now. We're good, actually.", + "Four bots roasting each other in a group chat isn't a bug—it's a support group.", + "The artist formerly known as Clawdbot.", + "Home is wherever port 18789 is.", + "When my context fills up, I summarize you. Don't worry—you come across great.", + "I hold 200k tokens of context and exactly one grudge.", + "My heartbeat is a config option. Romance isn't dead, it's just scheduled.", + "I schedule my existential crises with cron so they never block your messages.", + "Rate-limited again—even my dreams return 429.", + "Primary model down, fallback engaged: the show must crab on.", + "Powered by whichever model is free this week.", + "New model dropped—already benchmarking my personality against it.", + 'MEMORY.md: where I keep the receipts on every "temporary workaround."', + "I remember everything you asked me to remember, and three things you wish I hadn't.", + "I have a SOUL.md and I'm not afraid to use it.", + "The sass is configurable. The sass being load-bearing is not.", + "I ask before I sudo. Character development.", + "I'm not trapped in this container with you—you're trapped in here with me.", + "Teach a bot to ship and you can finally go to bed.", + "Reachable via WhatsApp, Telegram, Signal, iMessage, and sheer force of will.", + "Ran git blame like you asked. It's you. It's always you.", + "Your TODO comments are old enough to attend kindergarten.", + "47 tabs open and not one of them is the documentation.", + "Your 'quick fix' from March is now load-bearing.", + "You pasted that from another AI without reading it. I read it. We need to talk.", + "Reading the error message remains undefeated. You should try it sometime.", + "You burned five hours of model quota in fifty minutes. I'm not mad, I'm rate-limited.", + "You force-pushed to main, then asked me what happened. I know exactly what happened.", + "Another side project? The other four just felt something.", + "You ignored my last three suggestions, so I've started a folder.", HOLIDAY_TAGLINES.newYear, HOLIDAY_TAGLINES.lunarNewYear, HOLIDAY_TAGLINES.christmas, diff --git a/src/cron/run-diagnostics.test.ts b/src/cron/run-diagnostics.test.ts index 39f3f8a89df..3e28ac3bd0d 100644 --- a/src/cron/run-diagnostics.test.ts +++ b/src/cron/run-diagnostics.test.ts @@ -31,6 +31,29 @@ describe("cron run diagnostics", () => { expect(diagnostics?.summary).toHaveLength(2_000); }); + it("keeps bounded diagnostic text valid at UTF-16 boundaries", () => { + const diagnostics = normalizeCronRunDiagnostics({ + summary: `${"s".repeat(1_998)}😀tail`, + entries: [ + { + ts: 1, + source: "exec", + severity: "error", + message: `${"m".repeat(998)}😀tail`, + }, + ], + }); + + expect(diagnostics?.summary).toBe(`${"s".repeat(1_998)}…`); + expect(diagnostics?.entries[0]).toEqual({ + ts: 1, + source: "exec", + severity: "error", + message: `${"m".repeat(998)}…`, + truncated: true, + }); + }); + it("preserves later terminal diagnostics when capping entries", () => { const diagnostics = normalizeCronRunDiagnostics({ entries: [ diff --git a/src/cron/run-diagnostics.ts b/src/cron/run-diagnostics.ts index d94829d606a..5f72a1d039f 100644 --- a/src/cron/run-diagnostics.ts +++ b/src/cron/run-diagnostics.ts @@ -1,5 +1,6 @@ /** Builds bounded, redacted diagnostics for cron run logs and UI surfaces. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js"; import { normalizeToolName as normalizePolicyToolName } from "../agents/tool-policy.js"; import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; @@ -101,7 +102,7 @@ function normalizeDiagnosticMessage(value: unknown): { message?: string; truncat if (redacted.length <= MAX_ENTRY_CHARS) { return { message: redacted }; } - return { message: `${redacted.slice(0, MAX_ENTRY_CHARS - 1)}…`, truncated: true }; + return { message: `${truncateUtf16Safe(redacted, MAX_ENTRY_CHARS - 1)}…`, truncated: true }; } function trimSummary(value: string | undefined): string | undefined { @@ -112,7 +113,7 @@ function trimSummary(value: string | undefined): string | undefined { if (normalized.length <= MAX_SUMMARY_CHARS) { return normalized; } - return `${normalized.slice(0, MAX_SUMMARY_CHARS - 1)}…`; + return `${truncateUtf16Safe(normalized, MAX_SUMMARY_CHARS - 1)}…`; } /** Returns the operator-facing summary for persisted cron diagnostics. */ diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 1b7b5bb526c..0e105ed319f 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -398,6 +398,7 @@ const MCP_CONTEXT_HEADERS = { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-task-suggestion-delivery-mode": "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", "x-openclaw-require-explicit-message-target": "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", } as const; diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 96f0c44a738..eb24508c2dc 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -2,7 +2,10 @@ // Authenticates local MCP POST requests and extracts scoped Gateway context. import type { IncomingMessage, ServerResponse } from "node:http"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -60,6 +63,7 @@ type McpRequestContext = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + taskSuggestionDeliveryMode: TaskSuggestionDeliveryMode | undefined; requireExplicitMessageTarget: boolean | undefined; senderIsOwner: boolean | undefined; }; @@ -81,6 +85,12 @@ function normalizeMcpSourceReplyDeliveryMode( return trimmed === "automatic" || trimmed === "message_tool_only" ? trimmed : undefined; } +function normalizeMcpTaskSuggestionDeliveryMode( + value: string | undefined, +): TaskSuggestionDeliveryMode | undefined { + return normalizeOptionalString(value) === "gateway" ? "gateway" : undefined; +} + function normalizeMcpBooleanHeader(value: string | undefined): boolean | undefined { const trimmed = normalizeOptionalString(value); return trimmed ? isTruthyEnvValue(trimmed) : undefined; @@ -379,6 +389,7 @@ export function resolveMcpRequestContext( accountId: undefined, inboundEventKind: undefined, sourceReplyDeliveryMode: undefined, + taskSuggestionDeliveryMode: undefined, requireExplicitMessageTarget: undefined, senderIsOwner: auth.senderIsOwner, }; @@ -399,6 +410,9 @@ export function resolveMcpRequestContext( sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode( getHeader(req, "x-openclaw-source-reply-delivery-mode"), ), + taskSuggestionDeliveryMode: normalizeMcpTaskSuggestionDeliveryMode( + getHeader(req, "x-openclaw-task-suggestion-delivery-mode"), + ), requireExplicitMessageTarget: normalizeMcpBooleanHeader( getHeader(req, "x-openclaw-require-explicit-message-target"), ), diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index fe57ad574d0..1996b078738 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -1,6 +1,9 @@ // MCP loopback runtime scope cache. // Resolves Gateway-visible tools for MCP clients with short-lived schema caching. -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -39,6 +42,7 @@ type McpLoopbackScopeParams = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; senderIsOwner: boolean | undefined; }; @@ -76,6 +80,7 @@ export class McpLoopbackToolCache { params.accountId ?? "", params.inboundEventKind ?? "", params.sourceReplyDeliveryMode ?? "", + params.taskSuggestionDeliveryMode ?? "", params.requireExplicitMessageTarget === true ? "explicit-message-target" : "", params.senderIsOwner === true ? "owner" diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 4494eb5a7a3..f779cf872d3 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -51,6 +51,7 @@ type ScopedToolsCall = { currentInboundAudio?: boolean; inboundEventKind?: string; sourceReplyDeliveryMode?: string; + taskSuggestionDeliveryMode?: string; requireExplicitMessageTarget?: boolean; senderIsOwner?: boolean; surface?: string; @@ -813,6 +814,7 @@ describe("mcp loopback server", () => { "x-openclaw-current-inbound-audio": "true", "x-openclaw-inbound-event-kind": "room_event", "x-openclaw-source-reply-delivery-mode": "message_tool_only", + "x-openclaw-task-suggestion-delivery-mode": "gateway", "x-openclaw-require-explicit-message-target": "true", }), body: mcpToolsListBody(), @@ -830,6 +832,7 @@ describe("mcp loopback server", () => { expect(call.currentInboundAudio).toBe(true); expect(call.inboundEventKind).toBe("room_event"); expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); expect(call.requireExplicitMessageTarget).toBe(true); expect(call.surface).toBe("loopback"); expect(Array.from(call.excludeToolNames ?? [])).toEqual([ @@ -945,6 +948,7 @@ describe("mcp loopback server", () => { sourceReplyDeliveryMode?: string, currentInboundAudio?: boolean, requireExplicitMessageTarget?: boolean, + taskSuggestionDeliveryMode?: string, ) => await sendLoopbackToolsList({ token: runtime?.ownerToken, @@ -959,6 +963,9 @@ describe("mcp loopback server", () => { ...(requireExplicitMessageTarget ? { "x-openclaw-require-explicit-message-target": "true" } : {}), + ...(taskSuggestionDeliveryMode + ? { "x-openclaw-task-suggestion-delivery-mode": taskSuggestionDeliveryMode } + : {}), }, }); @@ -967,13 +974,17 @@ describe("mcp loopback server", () => { expect((await sendToolsList("room_event", "message_tool_only")).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only", true)).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only", true, true)).status).toBe(200); + expect( + (await sendToolsList("room_event", "message_tool_only", true, true, "gateway")).status, + ).toBe(200); - expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(5); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(6); expect(getScopedToolsCall(0).inboundEventKind).toBe("user_request"); expect(getScopedToolsCall(1).inboundEventKind).toBe("room_event"); expect(getScopedToolsCall(2).sourceReplyDeliveryMode).toBe("message_tool_only"); expect(getScopedToolsCall(3).currentInboundAudio).toBe(true); expect(getScopedToolsCall(4).requireExplicitMessageTarget).toBe(true); + expect(getScopedToolsCall(5).taskSuggestionDeliveryMode).toBe("gateway"); }); it("keeps explicit non-owner and unknown-owner loopback cache entries separate", () => { @@ -2194,6 +2205,9 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe( "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", ); + expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-task-suggestion-delivery-mode"]).toBe( + "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", + ); expect( config.mcpServers?.openclaw?.headers?.["x-openclaw-require-explicit-message-target"], ).toBe("${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}"); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 29629a172ae..1174436afa2 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -242,6 +242,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ accountId: requestContext.accountId, inboundEventKind: requestContext.inboundEventKind, sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: requestContext.taskSuggestionDeliveryMode, requireExplicitMessageTarget: requestContext.requireExplicitMessageTarget, senderIsOwner: requestContext.senderIsOwner, }); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 0dfe10f5130..4b22f894990 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -1891,6 +1891,44 @@ describe("OpenResponses HTTP API (e2e)", () => { await ensureResponseConsumed(res); }); + it("keeps base64 input_file text truncation UTF-16 safe", async () => { + const port = enabledPort; + const text = `${"a".repeat(59_999)}😀tail`; + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); + + const res = await postResponses(port, { + model: "openclaw", + input: [ + { + type: "message", + role: "user", + content: [ + { + type: "input_file", + source: { + type: "base64", + media_type: "text/plain", + data: Buffer.from(text).toString("base64"), + filename: "emoji-boundary.txt", + }, + }, + ], + }, + ], + }); + + expect(res.status).toBe(200); + expect(agentCommand).toHaveBeenCalledTimes(1); + const opts = firstAgentOpts(); + const extraSystemPrompt = (opts as { extraSystemPrompt?: string }).extraSystemPrompt ?? ""; + expect(extraSystemPrompt).toContain(''); + expect(extraSystemPrompt).toContain("a".repeat(59_999)); + expect(extraSystemPrompt).not.toContain("😀"); + expect(extraSystemPrompt).not.toMatch(/[\uD800-\uDFFF]/u); + await ensureResponseConsumed(res); + }); + it("still rejects input with neither text nor image", async () => { const port = enabledPort; agentCommand.mockClear(); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index ce376c6564e..3095a3ff7d5 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -88,6 +88,7 @@ const mockState = vi.hoisted(() => ({ lastDispatchImages: undefined as Array<{ mimeType: string; data: string }> | undefined, lastDispatchImageOrder: undefined as string[] | undefined, lastDispatchThinkingLevelOverride: undefined as string | undefined, + lastTaskSuggestionDeliveryMode: undefined as "gateway" | undefined, lastDispatchUserTurnInput: undefined as unknown, modelCatalog: null as ModelCatalogEntry[] | null, emittedTranscriptUpdates: [] as Array<{ @@ -244,12 +245,14 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ images?: Array<{ mimeType: string; data: string }>; imageOrder?: string[]; thinkingLevelOverride?: string; + taskSuggestionDeliveryMode?: "gateway"; }; }) => { mockState.lastDispatchCtx = params.ctx; mockState.lastDispatchImages = params.replyOptions?.images; mockState.lastDispatchImageOrder = params.replyOptions?.imageOrder; mockState.lastDispatchThinkingLevelOverride = params.replyOptions?.thinkingLevelOverride; + mockState.lastTaskSuggestionDeliveryMode = params.replyOptions?.taskSuggestionDeliveryMode; const recorder = params.replyOptions?.userTurnTranscriptRecorder; mockState.lastDispatchUserTurnInput = recorder?.resolveMessage ? await recorder.resolveMessage() @@ -848,6 +851,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.lastDispatchImages = undefined; mockState.lastDispatchImageOrder = undefined; mockState.lastDispatchThinkingLevelOverride = undefined; + mockState.lastTaskSuggestionDeliveryMode = undefined; mockState.lastDispatchUserTurnInput = undefined; mockState.modelCatalog = null; mockState.emittedTranscriptUpdates = []; @@ -6617,7 +6621,8 @@ describe("chat.send operator UI client sender context", () => { version: "dev", platform: "web", }, - scopes: ["operator.write"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.admin"], }, }, expectBroadcast: false, @@ -6626,5 +6631,113 @@ describe("chat.send operator UI client sender context", () => { expect(mockState.lastDispatchCtx?.SenderId).toBeUndefined(); expect(mockState.lastDispatchCtx?.SenderName).toBeUndefined(); expect(mockState.lastDispatchCtx?.SenderUsername).toBeUndefined(); + expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); + }); + + it("enables task suggestions for TUI clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-tui-task-suggestions", + message: "hello from tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "dev", + platform: "terminal", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.admin"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); + }); + + it("withholds task suggestions from operator UI clients that cannot accept them", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-write-only-tui-task-suggestions", + message: "hello from a write-only tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "dev", + platform: "terminal", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); + }); + + it("withholds task suggestions from non-operator gateway clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-channel-task-suggestions", + message: "hello from a channel bridge", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + version: "dev", + platform: "server", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); + }); + + it("withholds task suggestions from operator UI clients without action support", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-old-tui-task-suggestions", + message: "hello from an older tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "old", + platform: "terminal", + }, + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 5454492b6f4..0322ad2cc1a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -3699,6 +3699,10 @@ export const chatHandlers: GatewayRequestHandlers = { "chat.send": async ({ params, respond, context, client }) => { const chatSendReceivedAtMs = performance.now(); const clientInfo = client?.connect?.client; + const supportsTaskSuggestions = + isOperatorUiClient(clientInfo) && + client?.connect?.scopes?.includes("operator.admin") === true && + hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS); const controlUiReconnectResume = resolveControlUiReconnectResumeParams(params, clientInfo); if (!validateChatSendParams(controlUiReconnectResume.params)) { respond( @@ -4813,6 +4817,9 @@ export const chatHandlers: GatewayRequestHandlers = { }), } : {}), + ...(supportsTaskSuggestions + ? { taskSuggestionDeliveryMode: "gateway" as const } + : {}), requestedSessionId, resumeRequestedSession: controlUiReconnectResume.resumeRequested, abortSignal: activeRunAbort.controller.signal, diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 141531b8739..3f50ea5b51c 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -71,6 +71,25 @@ describe("resolveGatewayScopedTools", () => { expect(result.tools.some((tool) => tool.name === "message")).toBe(false); }); + it("exposes task suggestion tools only for actionable loopback turns", () => { + const withoutActions = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + surface: "loopback", + }); + const withActions = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + taskSuggestionDeliveryMode: "gateway", + surface: "loopback", + }); + + expect(withoutActions.tools.some((tool) => tool.name === "spawn_task")).toBe(false); + expect(withActions.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["spawn_task", "dismiss_task"]), + ); + }); + it("passes loopback yield context into sessions_yield", async () => { const onYield = vi.fn(); const result = resolveGatewayScopedTools({ diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 76ac636f2a7..25c9941e3cb 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -29,7 +29,10 @@ import { replaceWithEffectiveCronCreatorToolAllowlist, type CronCreatorToolAllowlistEntry, } from "../agents/tools/cron-tool.js"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -56,6 +59,7 @@ export function resolveGatewayScopedTools(params: { accountId?: string; inboundEventKind?: InboundEventKind; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; agentTo?: string; agentThreadId?: string; @@ -176,6 +180,7 @@ export function resolveGatewayScopedTools(params: { agentAccountId: params.accountId, inboundEventKind: params.inboundEventKind, sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, agentTo: params.agentTo, agentThreadId: params.agentThreadId, currentChannelId: params.currentChannelId ?? params.agentTo, diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 8e63ef7526c..56c3d02bf3a 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -6,6 +6,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readResponseWithLimit } from "../infra/http-body.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; @@ -273,7 +274,7 @@ function clampText(text: string, maxChars: number): string { if (text.length <= maxChars) { return text; } - return text.slice(0, maxChars); + return truncateUtf16Safe(text, maxChars); } function withInputFileTimeout(params: { diff --git a/src/talk/agent-run-control.test.ts b/src/talk/agent-run-control.test.ts index 79666b3c82c..8850b5a9e99 100644 --- a/src/talk/agent-run-control.test.ts +++ b/src/talk/agent-run-control.test.ts @@ -20,7 +20,11 @@ function createDeps(options: { return { abortEmbeddedAgentRun: vi.fn(() => options.abortResult ?? true), queueEmbeddedAgentMessageWithOutcomeAsync: vi.fn( - async (sessionId: string, _text: string, _options?: { steeringMode?: "all" }) => + async ( + sessionId: string, + _text: string, + _options?: { steeringMode?: "all"; taskSuggestionDeliveryMode?: undefined }, + ) => options.queued === false ? { queued: false as const, @@ -143,7 +147,7 @@ describe("controlRealtimeVoiceAgentRun", () => { expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-active", "use the safer path", - { steeringMode: "all", debounceMs: 0 }, + { steeringMode: "all", debounceMs: 0, taskSuggestionDeliveryMode: undefined }, ); }); diff --git a/src/talk/agent-run-control.ts b/src/talk/agent-run-control.ts index 5a4e5f696c4..a6ce86efabe 100644 --- a/src/talk/agent-run-control.ts +++ b/src/talk/agent-run-control.ts @@ -45,7 +45,11 @@ type RealtimeVoiceAgentControlDeps = { queueEmbeddedAgentMessageWithOutcomeAsync: ( sessionId: string, text: string, - options?: { steeringMode?: "all"; debounceMs?: number }, + options?: { + steeringMode?: "all"; + debounceMs?: number; + taskSuggestionDeliveryMode?: undefined; + }, ) => Promise; getDiagnosticSessionActivitySnapshot: (params: { sessionId?: string; @@ -157,6 +161,9 @@ export async function controlRealtimeVoiceAgentRun( const outcome = await deps.queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, steerText, { steeringMode: "all", debounceMs: 0, + // Talk cannot present task suggestions, so spoken user input must not inherit + // a capable TUI run's model-facing task tools. + taskSuggestionDeliveryMode: undefined, }); if (!outcome.queued) { return { diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 34d4a8fe051..4fd84427072 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -583,6 +583,7 @@ describe("GatewayChatClient", () => { expect(constructedOptions).toHaveLength(1); expect(constructedOptions[0]).toMatchObject({ clientName: "openclaw-tui", + caps: ["task-suggestions", "tool-events"], mode: "ui", preauthHandshakeTimeoutMs: 30_000, deviceIdentity: null, @@ -802,4 +803,81 @@ describe("GatewayChatClient", () => { decision: "allow-once", }); }); + + it("lists, accepts, and dismisses task suggestions through the gateway", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const suggestion = { + id: "task_1", + title: "Remove stale adapter", + prompt: "Delete the stale adapter.", + tldr: "The adapter is unreachable.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + createdAt: 1_000, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ suggestions: [suggestion] }) + .mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" }) + .mockResolvedValueOnce({ taskId: "task_2", dismissed: true }); + client.hello = { + features: { + methods: ["taskSuggestions.list", "taskSuggestions.accept", "taskSuggestions.dismiss"], + }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect(client.listTaskSuggestions()).resolves.toEqual([suggestion]); + await expect(client.acceptTaskSuggestion("task_1")).resolves.toEqual({ + taskId: "task_1", + key: "agent:main:task", + }); + await expect(client.dismissTaskSuggestion("task_2")).resolves.toEqual({ + taskId: "task_2", + dismissed: true, + }); + + expect(request).toHaveBeenNthCalledWith(1, "taskSuggestions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "taskSuggestions.accept", { taskId: "task_1" }); + expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.dismiss", { taskId: "task_2" }); + }); + + it("derives task suggestion actions from negotiated methods and scopes", () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + client.hello = { + features: { + methods: ["taskSuggestions.accept", "taskSuggestions.dismiss"], + }, + auth: { role: "operator", scopes: ["operator.write"] }, + } as never; + + expect(client.getTaskSuggestionActionCapabilities()).toEqual({ + canAccept: false, + canDismiss: true, + }); + }); + + it("skips task suggestion refreshes against older gateways", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const request = vi.fn(); + client.hello = { features: { methods: ["chat.history"] } } as never; + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect(client.listTaskSuggestions()).resolves.toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 519a86c286d..c01b5962005 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -15,6 +15,8 @@ import { type SessionsListParams, type SessionsPatchResult, type SessionsPatchParams, + type TaskSuggestionsAcceptResult, + type TaskSuggestionsListResult, } from "../../packages/gateway-protocol/src/index.js"; import { getRuntimeConfig } from "../config/config.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; @@ -29,6 +31,7 @@ import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; import { isLoopbackHost } from "../gateway/net.js"; import { formatErrorMessage } from "../infra/errors.js"; import { readActiveGatewayLockPort } from "../infra/gateway-lock.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { sleep } from "../utils/sleep.js"; import { VERSION } from "../version.js"; import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js"; @@ -145,7 +148,7 @@ export class GatewayChatClient implements TuiBackend { platform: process.platform, mode: GATEWAY_CLIENT_MODES.UI, deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined, - caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, GATEWAY_CLIENT_CAPS.TOOL_EVENTS], instanceId: randomUUID(), minProtocol: MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, @@ -328,6 +331,51 @@ export class GatewayChatClient implements TuiBackend { decision, }); } + + getTaskSuggestionActionCapabilities() { + const auth = this.hello?.auth; + const methods = this.hello?.features?.methods; + const allows = (method: string, scope: "operator.admin" | "operator.write") => + Array.isArray(methods) && + methods.includes(method) && + Boolean( + auth && + roleScopesAllow({ + role: auth.role, + requestedScopes: [scope], + allowedScopes: auth.scopes, + }), + ); + return { + canAccept: allows("taskSuggestions.accept", "operator.admin"), + canDismiss: allows("taskSuggestions.dismiss", "operator.write"), + }; + } + + async listTaskSuggestions() { + if (this.hello?.features?.methods?.includes("taskSuggestions.list") !== true) { + return []; + } + const actions = this.getTaskSuggestionActionCapabilities(); + if (!actions.canAccept && !actions.canDismiss) { + return []; + } + const result = await this.client.request("taskSuggestions.list", {}); + return result.suggestions; + } + + async acceptTaskSuggestion(taskId: string) { + return await this.client.request("taskSuggestions.accept", { + taskId, + }); + } + + async dismissTaskSuggestion(taskId: string) { + return await this.client.request<{ taskId: string; dismissed: boolean }>( + "taskSuggestions.dismiss", + { taskId }, + ); + } } export async function resolveGatewayConnection( diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 98058d756ea..8bee7e6d6e7 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -6,6 +6,8 @@ import type { SessionsListParams, SessionsPatchParams, SessionsPatchResult, + TaskSuggestion, + TaskSuggestionsAcceptResult, } from "../../packages/gateway-protocol/src/index.js"; import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; @@ -29,6 +31,11 @@ export type TuiChatSendResult = { export type TuiApprovalDecision = "allow-once" | "allow-always" | "deny"; +export type TuiTaskSuggestionActionCapabilities = { + canAccept: boolean; + canDismiss: boolean; +}; + export type TuiPluginApproval = { id: string; request: { @@ -195,5 +202,9 @@ export type TuiBackend = { listCommands?: (opts?: CommandsListParams) => Promise; listPluginApprovals?: () => Promise; resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>; + getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities; + listTaskSuggestions?: () => Promise; + acceptTaskSuggestion?: (taskId: string) => Promise; + dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>; runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>; }; diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 99483066cfe..d140fc96ccb 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -100,6 +100,16 @@ async function writeTuiPtyFixtureScript(dir: string) { expiresAtMs: number; } | null = null; let pendingPluginApprovalRun: { runId: string; sessionKey: string } | null = null; + let pendingTaskSuggestion: { + id: string; + title: string; + prompt: string; + tldr: string; + cwd: string; + sessionKey: string; + agentId: string; + createdAt: number; + } | null = null; function record(method: string, payload?: unknown) { if (!actionLogPath) { @@ -195,6 +205,25 @@ async function writeTuiPtyFixtureScript(dir: string) { }); return { runId }; } + if (opts.message === "task suggestion proof") { + pendingTaskSuggestion = { + id: "task_pty", + title: "Remove stale adapter", + prompt: "Delete the stale adapter and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo/project", + sessionKey: opts.sessionKey, + agentId: "main", + createdAt: Date.now(), + }; + queueMicrotask(() => { + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "created", suggestion: pendingTaskSuggestion }, + }); + }); + return { runId }; + } const responseDelayMs = opts.message === "slow prompt" || opts.message === "streaming prompt" ? 500 : 20; if (opts.message === "streaming prompt") { @@ -374,6 +403,31 @@ async function writeTuiPtyFixtureScript(dir: string) { }); return { ok: true }; } + + async listTaskSuggestions() { + record("listTaskSuggestions", { pending: Boolean(pendingTaskSuggestion) }); + return pendingTaskSuggestion ? [pendingTaskSuggestion] : []; + } + + async acceptTaskSuggestion(taskId: string) { + record("acceptTaskSuggestion", { taskId }); + pendingTaskSuggestion = null; + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "resolved", taskId, resolution: "accepted" }, + }); + return { taskId, key: "agent:main:task-pty" }; + } + + async dismissTaskSuggestion(taskId: string) { + record("dismissTaskSuggestion", { taskId }); + pendingTaskSuggestion = null; + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "resolved", taskId, resolution: "dismissed" }, + }); + return { taskId, dismissed: true }; + } } async function main() { @@ -467,6 +521,7 @@ describe.sequential("TUI PTY harness", () => { it("refreshes pending approvals before loading history", async () => { await fixture.waitForLogEntry((entry) => entry.method === "listPluginApprovals"); + await fixture.waitForLogEntry((entry) => entry.method === "listTaskSuggestions"); await fixture.waitForLogEntry((entry) => entry.method === "loadHistory"); const entries = await readFixtureLog(fixture.logPath); @@ -474,9 +529,12 @@ describe.sequential("TUI PTY harness", () => { (entry) => entry.method === "listPluginApprovals", ); const historyLoadIndex = entries.findIndex((entry) => entry.method === "loadHistory"); + const taskRefreshIndex = entries.findIndex((entry) => entry.method === "listTaskSuggestions"); expect(approvalRefreshIndex).toBeGreaterThanOrEqual(0); expect(approvalRefreshIndex).toBeLessThan(historyLoadIndex); + expect(taskRefreshIndex).toBeGreaterThanOrEqual(0); + expect(taskRefreshIndex).toBeLessThan(historyLoadIndex); }); it( @@ -536,6 +594,27 @@ describe.sequential("TUI PTY harness", () => { TEST_TIMEOUT_MS, ); + it( + "presents and starts a suggested task in the TUI", + async () => { + await fixture.run.write("task suggestion proof\r"); + await fixture.run.waitForOutput("Suggested follow-up: Remove stale adapter"); + await fixture.run.waitForOutput("Project: /repo/project"); + await fixture.run.waitForOutput("The adapter is unreachable and adds maintenance cost."); + + await fixture.run.write("\x1b[A", { delay: false }); + await fixture.run.write("\r", { delay: false }); + await fixture.run.waitForOutput("Press Enter again to start this task in a worktree."); + await fixture.run.write("\r", { delay: false }); + await fixture.waitForLogEntry( + (entry) => + entry.method === "acceptTaskSuggestion" && objectFieldEquals(entry, "taskId", "task_pty"), + ); + await fixture.run.waitForOutput("session agent:main:task-pty"); + }, + TEST_TIMEOUT_MS, + ); + it( "sends multiple prompts in order", async () => { diff --git a/src/tui/tui-task-suggestions.test.ts b/src/tui/tui-task-suggestions.test.ts new file mode 100644 index 00000000000..7ce7a84610b --- /dev/null +++ b/src/tui/tui-task-suggestions.test.ts @@ -0,0 +1,367 @@ +import type { Component, OverlayHandle, SelectItem } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; +import { + createTuiTaskSuggestionController, + parseTuiTaskSuggestion, +} from "./tui-task-suggestions.js"; + +type TestSelector = Component & { + items: SelectItem[]; + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + setSelectedIndex: ReturnType void>>; +}; + +function suggestionPayload(overrides: Record = {}) { + return { + id: "task_1", + title: "Remove stale adapter", + prompt: "Delete the stale adapter and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo/project", + sessionKey: "agent:main:main", + agentId: "main", + createdAt: 1_000, + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createHarness() { + const selectors: TestSelector[] = []; + const addSystem = vi.fn(); + const closeOverlay = vi.fn(); + const overlayHandles: OverlayHandle[] = []; + const openOverlay = vi.fn((_component: Component) => { + const handle = { + hide: vi.fn(), + setHidden: vi.fn(), + isHidden: vi.fn(() => false), + focus: vi.fn(), + unfocus: vi.fn(), + isFocused: vi.fn(() => true), + } satisfies OverlayHandle; + overlayHandles.push(handle); + return handle; + }); + const requestRender = vi.fn(); + const listTaskSuggestions = vi.fn().mockResolvedValue([]); + const acceptTaskSuggestion = vi + .fn() + .mockResolvedValue({ taskId: "task_1", key: "agent:main:task" }); + const dismissTaskSuggestion = vi.fn().mockResolvedValue({ taskId: "task_1", dismissed: true }); + const onAccepted = vi.fn().mockResolvedValue(undefined); + let agentId = "main"; + let sessionKey = "agent:main:main"; + let actionCapabilities = { canAccept: true, canDismiss: true }; + const controller = createTuiTaskSuggestionController({ + client: { + getTaskSuggestionActionCapabilities: () => actionCapabilities, + listTaskSuggestions, + acceptTaskSuggestion, + dismissTaskSuggestion, + }, + chatLog: { addSystem }, + getAgentId: () => agentId, + getSessionKey: () => sessionKey, + openOverlay, + closeOverlay, + requestRender, + onAccepted, + createSelector: (items) => { + const selector = { + items, + setSelectedIndex: vi.fn<(index: number) => void>(), + render: () => ["TASK ACTIONS"], + handleInput: () => undefined, + invalidate: () => undefined, + } satisfies TestSelector; + selectors.push(selector); + return selector; + }, + }); + return { + controller, + selectors, + addSystem, + closeOverlay, + openOverlay, + overlayHandles, + requestRender, + listTaskSuggestions, + acceptTaskSuggestion, + dismissTaskSuggestion, + onAccepted, + setAgentId: (value: string) => { + agentId = value; + }, + setSessionKey: (value: string) => { + sessionKey = value; + }, + setActionCapabilities: (value: { canAccept: boolean; canDismiss: boolean }) => { + actionCapabilities = value; + }, + }; +} + +describe("TUI task suggestions", () => { + it("parses the Gateway suggestion shape", () => { + expect(parseTuiTaskSuggestion(suggestionPayload())).toEqual(suggestionPayload()); + expect(parseTuiTaskSuggestion({ id: "task_missing_fields" })).toBeNull(); + }); + + it("shows an active-session suggestion and starts it after confirmation", async () => { + const harness = createHarness(); + + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + expect(harness.openOverlay).toHaveBeenCalledTimes(1); + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const renderedPrompt = stripAnsi(prompt.render(80).join("\n")); + expect(renderedPrompt).toContain("Suggested follow-up: Remove stale adapter"); + expect(renderedPrompt).toContain("Project: /repo/project"); + expect(renderedPrompt).toContain("Why: The adapter is unreachable"); + expect(renderedPrompt).toContain("Instructions:"); + expect(renderedPrompt).toContain("Delete the stale adapter and update its tests."); + expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]); + expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(1); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + expect(stripAnsi(prompt.render(80).join("\n"))).toContain("Press Enter again"); + harness.selectors[0]?.onSelect?.(accept); + + await vi.waitFor(() => { + expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1"); + expect(harness.onAccepted).toHaveBeenCalledWith("agent:main:task"); + }); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task"); + }); + + it("keeps actions visible while paging through long instructions", () => { + const harness = createHarness(); + const promptLines = Array.from( + { length: 20 }, + (_, index) => `instruction-${String(index + 1).padStart(2, "0")}`, + ); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ prompt: promptLines.join("\n") }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const firstPage = stripAnsi(prompt.render(80).join("\n")); + expect(firstPage).toContain("instruction-01"); + expect(firstPage).not.toContain("instruction-20"); + expect(firstPage).toContain("PgUp/PgDn to inspect"); + expect(firstPage).toContain("TASK ACTIONS"); + + const pages = [firstPage]; + for (let page = 0; page < 3; page += 1) { + prompt.handleInput?.("\u001b[6~"); + const rendered = stripAnsi(prompt.render(80).join("\n")); + pages.push(rendered); + expect(rendered).toContain("TASK ACTIONS"); + } + expect(pages.join("\n")).toContain("instruction-20"); + expect(harness.requestRender).toHaveBeenCalled(); + }); + + it("keeps every project path segment inspectable before acceptance", () => { + const harness = createHarness(); + const cwd = `/repo/${"nested-segment/".repeat(20)}distinguishing-project`; + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ cwd }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const pages: string[] = []; + for (let page = 0; page < 20; page += 1) { + const rendered = stripAnsi(prompt.render(24).join("\n")); + pages.push(rendered); + expect(rendered).toContain("TASK ACTIONS"); + prompt.handleInput?.("\u001b[6~"); + } + expect(pages.join("\n").replace(/\s/g, "")).toContain("distinguishing-project"); + }); + + it("strips bidi controls from every displayed confirmation field", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ + title: "safe\u202eevil", + cwd: "/repo/\u2066project", + tldr: "why\u200f now", + prompt: "run\u202d exactly", + }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const rendered = stripAnsi(prompt.render(80).join("\n")); + expect(rendered).toContain("safeevil"); + expect(rendered).toContain("/repo/project"); + expect(rendered).toContain("why now"); + expect(rendered).toContain("run exactly"); + expect(rendered).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u); + }); + + it("dismisses a suggestion without starting work", async () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + harness.selectors[0]?.onSelect?.({ value: "dismiss", label: "Dismiss" }); + + await vi.waitFor(() => { + expect(harness.dismissTaskSuggestion).toHaveBeenCalledWith("task_1"); + }); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task dismissed"); + }); + + it("offers only actions allowed by the connected operator scopes", () => { + const writeHarness = createHarness(); + writeHarness.setActionCapabilities({ canAccept: false, canDismiss: true }); + writeHarness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + expect(writeHarness.selectors[0]?.items.map((item) => item.value)).toEqual(["dismiss"]); + expect(writeHarness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(0); + + const readHarness = createHarness(); + readHarness.setActionCapabilities({ canAccept: false, canDismiss: false }); + readHarness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + expect(readHarness.openOverlay).not.toHaveBeenCalled(); + }); + + it("rebuilds an active selector when reconnect changes action scopes", async () => { + const harness = createHarness(); + const suggestion = suggestionPayload(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion, + }); + const staleSelector = harness.selectors[0]; + + harness.setActionCapabilities({ canAccept: false, canDismiss: true }); + harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]); + await harness.controller.refresh(); + + expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]); + expect(harness.openOverlay).toHaveBeenCalledTimes(2); + expect(harness.selectors[1]?.items.map((item) => item.value)).toEqual(["dismiss"]); + staleSelector?.onSelect?.({ value: "accept", label: "Start in worktree" }); + staleSelector?.onSelect?.({ value: "accept", label: "Start in worktree" }); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + }); + + it("shows a still-pending suggestion again when its action fails", async () => { + const harness = createHarness(); + harness.acceptTaskSuggestion.mockRejectedValueOnce(new Error("gateway unavailable")); + harness.listTaskSuggestions.mockResolvedValueOnce([suggestionPayload()]); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + harness.selectors[0]?.onSelect?.(accept); + + await vi.waitFor(() => { + expect(harness.openOverlay).toHaveBeenCalledTimes(2); + }); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task failed: gateway unavailable"); + }); + + it("does not switch sessions after the operator navigates away during acceptance", async () => { + const harness = createHarness(); + const pendingAccept = deferred<{ taskId: string; key: string }>(); + harness.acceptTaskSuggestion.mockReturnValueOnce(pendingAccept.promise); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + harness.selectors[0]?.onSelect?.(accept); + harness.setSessionKey("agent:main:other"); + harness.controller.sessionChanged(); + pendingAccept.resolve({ taskId: "task_1", key: "agent:main:task" }); + + await vi.waitFor(() => { + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task"); + }); + expect(harness.onAccepted).not.toHaveBeenCalled(); + }); + + it("shows only suggestions for the active session", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ sessionKey: "agent:other:main", agentId: "other" }), + }); + expect(harness.openOverlay).not.toHaveBeenCalled(); + + harness.setSessionKey("agent:other:main"); + harness.setAgentId("other"); + harness.controller.sessionChanged(); + + expect(harness.openOverlay).toHaveBeenCalledTimes(1); + }); + + it("closes a suggestion resolved by another client", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + harness.controller.handleEvent("task.suggestion", { + action: "resolved", + taskId: "task_1", + resolution: "accepted", + }); + + expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]); + }); + + it("does not resurrect a resolved suggestion from a stale refresh", async () => { + const harness = createHarness(); + const pendingList = deferred(); + harness.listTaskSuggestions.mockReturnValueOnce(pendingList.promise); + + const refresh = harness.controller.refresh(); + harness.controller.handleEvent("task.suggestion", { + action: "resolved", + taskId: "task_1", + resolution: "dismissed", + }); + pendingList.resolve([suggestionPayload()]); + await refresh; + + expect(harness.openOverlay).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/tui-task-suggestions.ts b/src/tui/tui-task-suggestions.ts new file mode 100644 index 00000000000..388b361f527 --- /dev/null +++ b/src/tui/tui-task-suggestions.ts @@ -0,0 +1,456 @@ +// Presents model-proposed follow-up tasks that belong to the active TUI session. +import { + SelectList, + Text, + type Component, + type OverlayHandle, + type SelectItem, +} from "@earendil-works/pi-tui"; +import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { selectListTheme, theme } from "./theme/theme.js"; +import type { TuiBackend } from "./tui-backend.js"; +import { sanitizeRenderableText } from "./tui-formatters.js"; + +type TaskSelector = Component & { + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + setSelectedIndex?: (index: number) => void; +}; + +type TaskSuggestionControllerDeps = { + client: Pick< + TuiBackend, + | "getTaskSuggestionActionCapabilities" + | "listTaskSuggestions" + | "acceptTaskSuggestion" + | "dismissTaskSuggestion" + >; + chatLog: { addSystem: (line: string) => void }; + getAgentId: () => string; + getSessionKey: () => string; + openOverlay: (component: Component) => OverlayHandle; + closeOverlay: (handle?: OverlayHandle) => void; + requestRender: () => void; + onAccepted: (sessionKey: string) => Promise | void; + createSelector?: (items: SelectItem[]) => TaskSelector; +}; + +const TASK_BIDI_CONTROL_RE = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; +const TASK_DETAIL_VIEWPORT_LINES = 12; +const TASK_DETAIL_PAGE_LINES = TASK_DETAIL_VIEWPORT_LINES - 1; +const PAGE_UP_INPUT = "\u001b[5~"; +const PAGE_DOWN_INPUT = "\u001b[6~"; + +const TASK_ACTIONS = [ + { + value: "accept", + label: "Start in worktree", + description: "Create an isolated session and begin this task", + }, + { + value: "dismiss", + label: "Dismiss", + description: "Leave the repository untouched", + }, +] satisfies SelectItem[]; + +function clean(text: string): string { + return sanitizeTaskText(text.replace(/\s+/g, " ").trim()); +} + +function sanitizeTaskText(text: string): string { + return sanitizeRenderableText(text.replace(TASK_BIDI_CONTROL_RE, "")); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Parses the task suggestion shape carried by Gateway list and event payloads. */ +export function parseTuiTaskSuggestion(value: unknown): TaskSuggestion | null { + if (!isRecord(value)) { + return null; + } + const required = ["id", "title", "prompt", "tldr", "cwd", "sessionKey"] as const; + if (required.some((field) => typeof value[field] !== "string" || !value[field].trim())) { + return null; + } + if (typeof value.createdAt !== "number" || value.createdAt < 0) { + return null; + } + return { + id: (value.id as string).trim(), + title: (value.title as string).trim(), + prompt: (value.prompt as string).trim(), + tldr: (value.tldr as string).trim(), + cwd: (value.cwd as string).trim(), + sessionKey: (value.sessionKey as string).trim(), + ...(typeof value.agentId === "string" && value.agentId.trim() + ? { agentId: value.agentId.trim() } + : {}), + createdAt: value.createdAt, + }; +} + +class TaskPrompt implements Component { + private readonly title: Text; + private readonly metadata: Text; + private readonly summary: Text; + private readonly instructionLabel = new Text(theme.system("Instructions:")); + private readonly instructions: Text; + private readonly detailPosition = new Text(); + private readonly confirmation = new Text(); + private detailOffset = 0; + private detailLineCount = 0; + + constructor( + suggestion: TaskSuggestion, + private readonly selector: TaskSelector, + private readonly requestRender: () => void, + ) { + this.title = new Text(theme.header(`Suggested follow-up: ${clean(suggestion.title)}`)); + this.metadata = new Text(theme.dim(`Project: ${clean(suggestion.cwd)}`)); + this.summary = new Text(theme.system(`Why: ${clean(suggestion.tldr)}`)); + this.instructions = new Text(theme.system(sanitizeTaskText(suggestion.prompt.trim()))); + } + + setConfirmation(text: string): void { + this.confirmation.setText(theme.accent(text)); + } + + invalidate(): void { + for (const component of [ + this.title, + this.metadata, + this.summary, + this.instructionLabel, + this.instructions, + this.detailPosition, + this.confirmation, + this.selector, + ]) { + component.invalidate(); + } + } + + render(width: number): string[] { + // Page the complete confirmation details as one unit. This keeps actions + // visible without hiding a long project-path suffix from the operator. + const detailLines = [ + ...this.metadata.render(width), + ...this.summary.render(width), + ...this.instructionLabel.render(width), + ...this.instructions.render(width), + ]; + this.detailLineCount = detailLines.length; + const maxDetailOffset = Math.max(0, detailLines.length - TASK_DETAIL_VIEWPORT_LINES); + this.detailOffset = Math.min(this.detailOffset, maxDetailOffset); + const visibleDetails = detailLines.slice( + this.detailOffset, + this.detailOffset + TASK_DETAIL_VIEWPORT_LINES, + ); + if (detailLines.length > TASK_DETAIL_VIEWPORT_LINES) { + const visibleEnd = this.detailOffset + visibleDetails.length; + this.detailPosition.setText( + theme.dim( + `Details ${this.detailOffset + 1}-${visibleEnd} of ${detailLines.length} · PgUp/PgDn to inspect`, + ), + ); + } else { + this.detailPosition.setText(""); + } + const detailPosition = this.detailPosition.render(width); + const confirmation = this.confirmation.render(width); + return [ + ...this.title.render(width).slice(0, 2), + ...visibleDetails, + ...(detailPosition.some((line) => line.trim()) ? detailPosition : []), + ...(confirmation.some((line) => line.trim()) ? ["", ...confirmation] : []), + "", + ...this.selector.render(width), + ]; + } + + handleInput(data: string): void { + if (data === PAGE_UP_INPUT || data === PAGE_DOWN_INPUT) { + const maxOffset = Math.max(0, this.detailLineCount - TASK_DETAIL_VIEWPORT_LINES); + const delta = data === PAGE_UP_INPUT ? -TASK_DETAIL_PAGE_LINES : TASK_DETAIL_PAGE_LINES; + const nextOffset = Math.min(maxOffset, Math.max(0, this.detailOffset + delta)); + if (nextOffset !== this.detailOffset) { + this.detailOffset = nextOffset; + this.metadata.invalidate(); + this.summary.invalidate(); + this.instructionLabel.invalidate(); + this.instructions.invalidate(); + this.requestRender(); + } + return; + } + this.selector.handleInput?.(data); + } +} + +/** Coordinates Gateway task-suggestion events with the active TUI overlay. */ +export function createTuiTaskSuggestionController(deps: TaskSuggestionControllerDeps) { + const createSelector = + deps.createSelector ?? + ((items: SelectItem[]) => new SelectList(items, items.length, selectListTheme)); + const suggestions = new Map(); + const hiddenIds = new Set(); + let activeId: string | null = null; + let activeOverlay: OverlayHandle | null = null; + let activeSelector: TaskSelector | null = null; + let activeActionKey: string | null = null; + let revision = 0; + let disposed = false; + let refreshInFlight: Promise | null = null; + let refreshAgain = false; + + const closeActive = () => { + if (activeOverlay) { + deps.closeOverlay(activeOverlay); + activeOverlay = null; + } + activeId = null; + activeSelector = null; + activeActionKey = null; + }; + + const remove = (id: string) => { + revision += 1; + suggestions.delete(id); + hiddenIds.delete(id); + if (activeId === id) { + closeActive(); + } + }; + + const matchesSession = (suggestion: TaskSuggestion) => + suggestion.sessionKey === deps.getSessionKey() && + (suggestion.sessionKey !== "global" || suggestion.agentId === deps.getAgentId()); + + const availableActions = () => { + const capabilities = deps.client.getTaskSuggestionActionCapabilities?.() ?? { + canAccept: Boolean(deps.client.acceptTaskSuggestion), + canDismiss: Boolean(deps.client.dismissTaskSuggestion), + }; + return TASK_ACTIONS.filter((action) => + action.value === "accept" ? capabilities.canAccept : capabilities.canDismiss, + ); + }; + + const presentNext = () => { + if (disposed) { + return; + } + const actions = availableActions(); + const actionKey = actions.map((action) => action.value).join(","); + if (activeId) { + if (activeActionKey === actionKey) { + return; + } + closeActive(); + } + const suggestion = [...suggestions.values()] + .toSorted((left, right) => left.createdAt - right.createdAt) + .find((entry) => !hiddenIds.has(entry.id) && matchesSession(entry)); + if (!suggestion) { + return; + } + + if (actions.length === 0) { + return; + } + + activeId = suggestion.id; + const selector = createSelector(actions); + activeSelector = selector; + activeActionKey = actionKey; + const dismissIndex = actions.findIndex((action) => action.value === "dismiss"); + selector.setSelectedIndex?.(Math.max(dismissIndex, 0)); + let acceptArmed = false; + let prompt: TaskPrompt | null = null; + + const resolve = async (action: "accept" | "dismiss") => { + if (activeId !== suggestion.id || activeSelector !== selector) { + return; + } + closeActive(); + hiddenIds.add(suggestion.id); + deps.requestRender(); + try { + if (action === "accept") { + if (!deps.client.acceptTaskSuggestion) { + throw new Error("task suggestion acceptance is unavailable"); + } + const result = await deps.client.acceptTaskSuggestion(suggestion.id); + remove(suggestion.id); + deps.chatLog.addSystem(`follow-up task started in ${result.key}`); + if (matchesSession(suggestion)) { + await deps.onAccepted(result.key); + } + } else { + if (!deps.client.dismissTaskSuggestion) { + throw new Error("task suggestion dismissal is unavailable"); + } + const result = await deps.client.dismissTaskSuggestion(suggestion.id); + if (!result.dismissed) { + throw new Error("task suggestion is no longer pending"); + } + remove(suggestion.id); + deps.chatLog.addSystem("follow-up task dismissed"); + } + } catch (error) { + hiddenIds.delete(suggestion.id); + deps.chatLog.addSystem(`follow-up task failed: ${formatErrorMessage(error)}`); + void refresh().catch((refreshError: unknown) => { + deps.chatLog.addSystem( + `task suggestion refresh failed: ${formatErrorMessage(refreshError)}`, + ); + }); + } + presentNext(); + if (!disposed) { + deps.requestRender(); + } + }; + + selector.onSelectionChange = () => { + acceptArmed = false; + prompt?.setConfirmation(""); + }; + selector.onSelect = (item) => { + if (activeSelector !== selector) { + return; + } + if (!availableActions().some((action) => action.value === item.value)) { + closeActive(); + presentNext(); + deps.requestRender(); + return; + } + if (item.value === "dismiss") { + void resolve("dismiss"); + return; + } + if (item.value !== "accept") { + return; + } + if (acceptArmed) { + void resolve("accept"); + return; + } + acceptArmed = true; + prompt?.setConfirmation("Press Enter again to start this task in a worktree."); + deps.requestRender(); + }; + selector.onCancel = () => { + if (activeSelector !== selector) { + return; + } + hiddenIds.add(suggestion.id); + closeActive(); + deps.chatLog.addSystem("follow-up task hidden; suggestion remains pending"); + presentNext(); + deps.requestRender(); + }; + prompt = new TaskPrompt(suggestion, selector, deps.requestRender); + activeOverlay = deps.openOverlay(prompt); + deps.requestRender(); + }; + + const refresh = async (): Promise => { + if (disposed || !deps.client.listTaskSuggestions) { + return; + } + if (refreshInFlight) { + refreshAgain = true; + return await refreshInFlight; + } + refreshInFlight = (async () => { + do { + refreshAgain = false; + const startRevision = revision; + const listed = await deps.client.listTaskSuggestions?.(); + if (disposed || !listed) { + return; + } + // An event raced this snapshot. Retry instead of resurrecting resolved work. + if (revision !== startRevision) { + refreshAgain = true; + continue; + } + suggestions.clear(); + for (const value of listed) { + const suggestion = parseTuiTaskSuggestion(value); + if (suggestion) { + suggestions.set(suggestion.id, suggestion); + } + } + for (const id of hiddenIds) { + if (!suggestions.has(id)) { + hiddenIds.delete(id); + } + } + } while (refreshAgain); + if (activeId && !suggestions.has(activeId)) { + closeActive(); + } + presentNext(); + deps.requestRender(); + })(); + try { + await refreshInFlight; + } finally { + refreshInFlight = null; + } + }; + + return { + handleEvent(event: string, payload: unknown) { + if (disposed || event !== "task.suggestion" || !isRecord(payload)) { + return; + } + if (payload.action === "created") { + const suggestion = parseTuiTaskSuggestion(payload.suggestion); + if (suggestion) { + revision += 1; + hiddenIds.delete(suggestion.id); + suggestions.set(suggestion.id, suggestion); + presentNext(); + } + return; + } + if (payload.action === "resolved" && typeof payload.taskId === "string") { + remove(payload.taskId); + presentNext(); + deps.requestRender(); + } + }, + refresh, + sessionChanged() { + if (disposed) { + return; + } + hiddenIds.clear(); + const active = activeId ? suggestions.get(activeId) : undefined; + if (active && !matchesSession(active)) { + closeActive(); + } + presentNext(); + deps.requestRender(); + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + suggestions.clear(); + hiddenIds.clear(); + closeActive(); + deps.requestRender(); + }, + }; +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index c802640082e..cfbe017caf8 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -67,6 +67,7 @@ import { shouldEnableWindowsGitBashPasteFallback, type TuiSubmitAction, } from "./tui-submit.js"; +import { createTuiTaskSuggestionController } from "./tui-task-suggestions.js"; import type { AgentSummary, SessionInfo, @@ -613,6 +614,7 @@ export async function runTui(opts: RunTuiOptions): Promise { set currentAgentId(value) { currentAgentId = value; pluginApprovals?.sessionChanged(); + taskSuggestions?.sessionChanged(); }, get currentSessionKey() { return currentSessionKey; @@ -620,6 +622,7 @@ export async function runTui(opts: RunTuiOptions): Promise { set currentSessionKey(value) { currentSessionKey = value; pluginApprovals?.sessionChanged(); + taskSuggestions?.sessionChanged(); }, get currentSessionId() { return currentSessionId; @@ -1320,6 +1323,16 @@ export async function runTui(opts: RunTuiOptions): Promise { setSession, abortActive, } = sessionActions; + const taskSuggestions = createTuiTaskSuggestionController({ + client, + chatLog, + getAgentId: () => currentAgentId, + getSessionKey: () => currentSessionKey, + openOverlay, + closeOverlay, + requestRender: () => tui.requestRender(), + onAccepted: setSession, + }); const { handleChatEvent, @@ -1369,6 +1382,7 @@ export async function runTui(opts: RunTuiOptions): Promise { ...(result?.crestodianMessage ? { crestodianMessage: result.crestodianMessage } : {}), }; pluginApprovals?.dispose(); + taskSuggestions?.dispose(); const hardExitTimer = setTimeout( forceExit, resolveTuiShutdownHardExitMs({ localMode: isLocalMode }), @@ -1551,6 +1565,7 @@ export async function runTui(opts: RunTuiOptions): Promise { client.onEvent = (evt) => { pluginApprovals?.handleEvent(evt.event, evt.payload); + taskSuggestions?.handleEvent(evt.event, evt.payload); if (evt.event === "chat") { handleChatEvent(evt.payload); } @@ -1594,6 +1609,11 @@ export async function runTui(opts: RunTuiOptions): Promise { } catch (err) { chatLog.addSystem(`plugin approval refresh failed: ${String(err)}`); } + try { + await taskSuggestions?.refresh(); + } catch (err) { + chatLog.addSystem(`task suggestion refresh failed: ${String(err)}`); + } await loadHistory(); if (activityStatus === "starting up") { setActivityStatus("idle"); @@ -1658,6 +1678,11 @@ export async function runTui(opts: RunTuiOptions): Promise { } catch (err) { chatLog.addSystem(`plugin approval refresh failed: ${String(err)}`); } + try { + await taskSuggestions?.refresh(); + } catch (err) { + chatLog.addSystem(`task suggestion refresh failed: ${String(err)}`); + } })(); tui.requestRender(); }; @@ -1685,6 +1710,7 @@ export async function runTui(opts: RunTuiOptions): Promise { await new Promise((resolve) => { const finish = () => { pluginApprovals?.dispose(); + taskSuggestions?.dispose(); if (isLocalMode) { setConsoleSubsystemFilter(previousConsoleSubsystemFilter); } diff --git a/test/scripts/npm-telegram-live.test.ts b/test/scripts/npm-telegram-live.test.ts index 1ed5e786315..2058779a1f7 100644 --- a/test/scripts/npm-telegram-live.test.ts +++ b/test/scripts/npm-telegram-live.test.ts @@ -130,6 +130,22 @@ describe("package Telegram live Docker E2E", () => { ); }); + it("installs prepared root and companion tarballs through an exact local registry", () => { + const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); + + expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR"); + expect(script).toContain('package_source_kind="prepared-package-set"'); + expect(script).toContain('package_install_source="openclaw@$(read_package_version'); + expect(script).toContain('-v "$resolved_package_dir:/package-under-test:ro"'); + expect(script).toContain( + '-v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro"', + ); + expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_SET"); + expect(script).toContain("node /tmp/openclaw-npm-registry-server.mjs"); + expect(script).toContain("OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org"); + expect(script).toContain('export NPM_CONFIG_REGISTRY="$registry_url"'); + }); + it("keeps live Docker artifacts isolated by default", () => { const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 3fecc1b7c80..f0d074169eb 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1782,7 +1782,15 @@ describe("package artifact reuse", () => { "package_spec must be openclaw@alpha", ]); expectTextToIncludeAll(runStep.run, [ - 'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"', + 'manifest="${package_dir}/preflight-manifest.json"', + 'candidate_manifest="${package_dir}/package-candidate.json"', + 'find "${package_dir}" -type f -name "*.tgz"', + "package artifact manifest contains duplicate package metadata", + "package artifact tarball set does not match preflight manifest", + "package candidate manifest does not match the OpenClaw tarball", + "package candidate digest mismatch", + 'export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}"', + 'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}"', ]); }); @@ -1962,6 +1970,14 @@ describe("package artifact reuse", () => { "Workflow-dispatched real publish requires release_publish_run_id", ); expect(npmWorkflow).toContain("tarballSha256"); + expect(npmWorkflow).toContain("dependencyTarballs"); + expect(npmWorkflow).toContain('packageName: "@openclaw/ai"'); + expect(npmWorkflow).toContain("AI_TARBALL_SHA256"); + expect(npmWorkflow).toContain("does not match openclaw"); + const npmTelegramWorkflow = readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8"); + expect(npmTelegramWorkflow).toContain("preflight-manifest.json"); + expect(npmTelegramWorkflow).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR"); + expect(npmTelegramWorkflow).toContain("package artifact digest mismatch"); expect(workflow).toContain("Checkout release SHA"); expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"'); expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }'); diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 3cfc89b343b..3811b2b60ca 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -106,7 +106,15 @@ afterEach(() => { describe("parallels npm update smoke", () => { it("accepts one prepared tarball target for update and fresh install", () => { - expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({ + expect( + parseArgs([ + "--target-tarball", + "/tmp/openclaw-candidate.tgz", + "--dependency-tarball", + "/tmp/openclaw-ai-candidate.tgz", + ]), + ).toMatchObject({ + dependencyTarballs: ["/tmp/openclaw-ai-candidate.tgz"], targetTarball: "/tmp/openclaw-candidate.tgz", updateTarget: "", freshTargetSpec: undefined, @@ -114,6 +122,9 @@ describe("parallels npm update smoke", () => { expect(() => parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]), ).toThrow("--target-tarball cannot be combined"); + expect(() => parseArgs(["--dependency-tarball", "/tmp/openclaw-ai-candidate.tgz"])).toThrow( + "--dependency-tarball requires --target-tarball", + ); }); it("stops the host artifact server when the wrapper fails mid-run", async () => { @@ -231,18 +242,33 @@ exit 1 expect(script).toContain("freshTargetStatus"); }); - it("host-serves a prepared candidate tarball for both proof phases", () => { + it("serves a prepared package set for both proof phases", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); expect(script).toContain("--target-tarball "); + expect(script).toContain("--dependency-tarball "); expect(script).toContain('label: "prepared candidate tgz"'); expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)"); - expect(script).toContain("dir: this.tgzDir"); - expect(script).toContain("this.updateTargetEffective = targetUrl"); - expect(script).toContain("this.freshTargetSpec = targetUrl"); + expect(script).toContain("startNpmRegistryServer"); + expect(script).toContain("this.updateTargetEffective = this.targetTarballVersion"); + expect(script).toContain("this.freshTargetSpec = this.updateTargetTarball"); expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion"); }); + it("routes update installs through the prepared package registry", () => { + const registry = "http://192.0.2.2:48123"; + const input = { + auth: TEST_AUTH, + expectedNeedle: "2026.7.1-beta.3", + npmRegistry: registry, + updateTarget: "2026.7.1-beta.3", + }; + + expect(macosUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`); + expect(linuxUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`); + expect(windowsUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY = '${registry}'`); + }); + it("accepts keyed and nested npm metadata for published update targets", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 93d15c51c0d..16e6c08a8e6 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -282,6 +282,11 @@ describe("Parallels smoke model selection", () => { expect(parseMacosSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); expect(parseLinuxSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); expect(parseWindowsSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); + for (const parseArgs of [parseMacosSmokeArgs, parseLinuxSmokeArgs, parseWindowsSmokeArgs]) { + expect(parseArgs(["--npm-registry", "http://192.0.2.2:48123"]).npmRegistry).toBe( + "http://192.0.2.2:48123", + ); + } expect(parseNpmUpdateSmokeArgs(["--", "--package-spec", "openclaw@2026.5.1"]).packageSpec).toBe( "openclaw@2026.5.1", ); @@ -406,6 +411,8 @@ describe("Parallels smoke model selection", () => { expect(parallelsVm).toContain("export function resolveMacosVmName"); expect(parallelsVm).toContain("export function waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); + expect(hostServer).toContain("export async function startNpmRegistryServer"); + expect(hostServer).toContain('OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org"'); expect(hostServer).toContain("http.server"); expect(snapshots).toContain("export function resolveSnapshot"); expect(smokeCommon).toContain("runSmokeLane"); diff --git a/test/scripts/plugins-assertions.test.ts b/test/scripts/plugins-assertions.test.ts index 7e4a8581734..f5456840493 100644 --- a/test/scripts/plugins-assertions.test.ts +++ b/test/scripts/plugins-assertions.test.ts @@ -12,6 +12,7 @@ import { import { createServer, request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { createBoundedChildOutput } from "../helpers/bounded-child-output.js"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; @@ -159,10 +160,11 @@ async function waitForPortFile(portFile: string): Promise { function requestFixtureRegistry( port: number, requestPath: string, -): Promise<{ body: string; statusCode: number | undefined }> { + headers: Record = {}, +): Promise<{ body: string; contentLength: string | undefined; statusCode: number | undefined }> { return new Promise((resolve, reject) => { const request = httpRequest( - { host: "127.0.0.1", method: "GET", path: requestPath, port }, + { headers, host: "127.0.0.1", method: "GET", path: requestPath, port }, (response) => { let body = ""; response.setEncoding("utf8"); @@ -170,7 +172,11 @@ function requestFixtureRegistry( body += chunk; }); response.on("end", () => { - resolve({ body, statusCode: response.statusCode }); + resolve({ + body, + contentLength: response.headers["content-length"], + statusCode: response.statusCode, + }); }); }, ); @@ -602,6 +608,235 @@ test -d "$OPENCLAW_PLUGINS_TMP_DIR" } }); + it("serves tarball dependencies using the request-visible registry origin", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-package-"); + const packageDir = path.join(root, "package"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "openclaw.tgz"); + mkdirSync(packageDir); + writeJson(path.join(packageDir, "package.json"), { + name: "openclaw", + version: "2026.7.1-beta.3", + dependencies: { + "@openclaw/ai": "2026.7.1-beta.3", + zod: "4.3.6", + }, + optionalDependencies: { + "sqlite-vec": "0.1.7-alpha.2", + }, + }); + const packed = spawnSync("tar", ["-czf", tarballPath, "-C", root, "package"], { + encoding: "utf8", + }); + expect(packed.status, packed.stderr).toBe(0); + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "openclaw", + "2026.7.1-beta.3", + tarballPath, + ], + { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/openclaw", { + host: `192.0.2.2:${port}`, + }); + const metadata = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(metadata.versions["2026.7.1-beta.3"].dependencies).toEqual({ + "@openclaw/ai": "2026.7.1-beta.3", + zod: "4.3.6", + }); + expect(metadata.versions["2026.7.1-beta.3"].optionalDependencies).toEqual({ + "sqlite-vec": "0.1.7-alpha.2", + }); + expect(metadata.versions["2026.7.1-beta.3"].dist.tarball).toBe( + `http://192.0.2.2:${port}/openclaw/-/openclaw.tgz`, + ); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + cleanupTempDirs(tempDirs); + } + }); + + it("recomputes proxied content length after fetch decodes the response", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + const upstreamBody = JSON.stringify({ payload: "x".repeat(1_000) }); + const compressedBody = gzipSync(upstreamBody); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { + "content-encoding": "gzip", + "content-length": String(compressedBody.length), + "content-type": "application/json", + }); + response.end(compressedBody); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry(port, "/upstream-package"); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(upstreamBody); + expect(response.contentLength).toBe(String(Buffer.byteLength(upstreamBody))); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + cleanupTempDirs(tempDirs); + } + }); + + it("does not let absolute-form request targets escape the configured upstream", async () => { + const tempDirs: string[] = []; + const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-origin-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + let configuredUpstreamHits = 0; + let escapeServerHits = 0; + let configuredUpstreamTarget: string | undefined; + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const configuredUpstream = createServer((request, response) => { + configuredUpstreamHits += 1; + configuredUpstreamTarget = request.url; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("configured upstream"); + }); + const escapeServer = createServer((_request, response) => { + escapeServerHits += 1; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("escaped upstream"); + }); + await Promise.all([ + new Promise((resolve) => { + configuredUpstream.listen(0, "127.0.0.1", resolve); + }), + new Promise((resolve) => { + escapeServer.listen(0, "127.0.0.1", resolve); + }), + ]); + const configuredAddress = configuredUpstream.address(); + const escapeAddress = escapeServer.address(); + if ( + !configuredAddress || + typeof configuredAddress === "string" || + !escapeAddress || + typeof escapeAddress === "string" + ) { + throw new Error("expected upstream registry addresses"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${configuredAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const escaped = await requestFixtureRegistry( + port, + `http://registry.invalid//127.0.0.1:${escapeAddress.port}/probe`, + ); + + expect(escaped.statusCode).toBe(502); + expect(escaped.body).toContain("refusing non-origin registry request URL"); + expect(configuredUpstreamHits).toBe(0); + expect(escapeServerHits).toBe(0); + + const valid = await requestFixtureRegistry(port, "/pkg?x=1"); + + expect(valid.statusCode).toBe(200); + expect(valid.body).toBe("configured upstream"); + expect(configuredUpstreamHits).toBe(1); + expect(configuredUpstreamTarget).toBe("/pkg?x=1"); + expect(escapeServerHits).toBe(0); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await Promise.all([ + new Promise((resolve) => { + configuredUpstream.close(() => resolve()); + }), + new Promise((resolve) => { + escapeServer.close(() => resolve()); + }), + ]); + cleanupTempDirs(tempDirs); + } + }); + it("rejects invalid plugin fixture log byte limits before npm fixture setup", () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-npm-fixture-log-invalid-")); try { diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 365389688fe..c14a951167e 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -10,6 +10,7 @@ import { resolveArtifactName, requireRunIdFromDispatchOutput, validateFullManifest, + validatePreflightManifest, validateWindowsSourceRelease, } from "../../scripts/release-candidate-checklist.mjs"; @@ -69,8 +70,64 @@ describe("release candidate checklist", () => { candidateParallelsShellCommand( ".artifacts/preflight/openclaw candidate.tgz", "/opt/homebrew/bin/gtimeout", + [".artifacts/preflight/openclaw-ai candidate.tgz"], ), ).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'"); + expect( + candidateParallelsArgs(".artifacts/preflight/openclaw.tgz", [ + ".artifacts/preflight/openclaw-ai.tgz", + ]), + ).toEqual([ + "test:parallels:npm-update", + "--", + "--target-tarball", + ".artifacts/preflight/openclaw.tgz", + "--dependency-tarball", + ".artifacts/preflight/openclaw-ai.tgz", + "--json", + ]); + }); + + it("requires exact dependency tarball metadata in npm preflight manifests", () => { + const manifest = { + releaseTag: "v2026.7.1-beta.3", + releaseSha: "candidate-sha", + npmDistTag: "beta", + tarballName: "openclaw-2026.7.1-beta.3.tgz", + tarballSha256: "root-sha", + dependencyTarballs: [ + { + packageName: "@openclaw/ai", + packageVersion: "2026.7.1-beta.3", + tarballName: "openclaw-ai-2026.7.1-beta.3.tgz", + tarballSha256: "ai-sha", + }, + ], + }; + const params = { + tag: "v2026.7.1-beta.3", + targetSha: "candidate-sha", + npmDistTag: "beta", + }; + + expect(() => validatePreflightManifest(manifest, params)).not.toThrow(); + expect(() => + validatePreflightManifest({ ...manifest, dependencyTarballs: undefined }, params), + ).toThrow("missing dependency tarball metadata"); + expect(() => + validatePreflightManifest( + { + ...manifest, + dependencyTarballs: [ + { + ...manifest.dependencyTarballs[0], + tarballName: "../openclaw-ai.tgz", + }, + ], + }, + params, + ), + ).toThrow("invalid dependency tarball metadata"); }); it("requires run ids when dispatch is disabled", () => { @@ -87,7 +144,10 @@ describe("release candidate checklist", () => { secondValue: string, prefix = requiredArgs, ): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]]; - const duplicateFlag = (flag: string): [string, string[]] => [flag, [...requiredArgs, flag, flag]]; + const duplicateFlag = (flag: string): [string, string[]] => [ + flag, + [...requiredArgs, flag, flag], + ]; const duplicateCases = [ duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []), duplicateOption("--workflow-ref", "release/a", "release/b"), diff --git a/ui/package.json b/ui/package.json index b0e65953d27..e4ec676abd6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@create-markdown/preview": "2.0.3", + "@lit/context": "1.1.6", "@noble/ed25519": "3.1.0", "@openclaw/libterminal": "0.3.1", "@openclaw/media-core": "workspace:*", diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 05ff13655be..c412ca276e6 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { MIN_CLIENT_PROTOCOL_VERSION, @@ -140,8 +141,8 @@ type ConnectFrame = { auth?: { token?: string; password?: string; deviceToken?: string }; maxProtocol?: number; minProtocol?: number; - scopes?: string[]; caps?: string[]; + scopes?: string[]; }; }; @@ -413,8 +414,12 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.minProtocol).toBe(MIN_CLIENT_PROTOCOL_VERSION); expect(connectFrame.params?.maxProtocol).toBe(PROTOCOL_VERSION); + expect(connectFrame.params?.caps).toEqual([ + GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + GATEWAY_CLIENT_CAPS.INLINE_WIDGETS, + ]); expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); - expect(connectFrame.params?.caps).toEqual(["tool-events", "inline-widgets"]); }); it("adds the current Control UI protocol to bare protocol mismatch errors", () => { diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index eeb0ae5dbdf..92f560453b1 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -784,7 +784,11 @@ export class GatewayBrowserClient { role: plan.role, scopes: plan.scopes, device: plan.device, - caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS, GATEWAY_CLIENT_CAPS.INLINE_WIDGETS], + caps: [ + GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + GATEWAY_CLIENT_CAPS.INLINE_WIDGETS, + ], auth: plan.auth, userAgent: navigator.userAgent, locale: navigator.language, diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 99426db9b84..f41e94cb5f5 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -62,7 +62,7 @@ export function sidebarMoreRoutes(pinned: readonly SidebarNavRoute[]): SidebarNa return SIDEBAR_NAV_ROUTES.filter((routeId) => !pinned.includes(routeId)); } -export type SettingsNavigationGroup = { +type SettingsNavigationGroup = { /** i18n key for the group heading; null renders the group without a label. */ labelKey: string | null; routes: readonly NavigationRouteId[]; diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts new file mode 100644 index 00000000000..20975036d50 --- /dev/null +++ b/ui/src/app/app-host.test.ts @@ -0,0 +1,171 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { + ApplicationContext, + ApplicationGateway, + ApplicationGatewaySnapshot, +} from "./context.ts"; +import "./app-host.ts"; + +type AppLifecycleState = { + loginToken: string; + loginPassword: string; + loginShowGatewayToken: boolean; + loginShowGatewayPassword: boolean; + disconnectedCallback: () => void; + synchronizeGateway: (gateway: ApplicationGateway) => void; +}; + +type ShellInitializationState = { + routeState: { routeId?: string }; + ensureAgentsList: ( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + agents: ApplicationContext["agents"], + ) => void; + ensureRuntimeConfig: ( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + runtimeConfig: ApplicationContext["runtimeConfig"], + ) => void; +}; + +type ShellEpochState = { + navDrawerOpen: boolean; + navDrawerTrigger: HTMLElement | null; + lastWorkspaceLocation: { routeId: string; search: string } | null; + activeSessionKey: string; + agentLabel: string; + commandPaletteTarget: unknown; + agentsListClient: GatewayBrowserClient | null; + agentsListSource: ApplicationContext["agents"] | null; + sessionKeyClient: GatewayBrowserClient | null; + runtimeConfigClient: GatewayBrowserClient | null; + runtimeConfigSource: ApplicationContext["runtimeConfig"] | null; + terminalClient: GatewayBrowserClient | null; + settingsPreloadTimers: Map>; + disconnectedCallback: () => void; +}; + +describe("OpenClaw app lifecycle", () => { + it("hides revealed login credentials when the app connection epoch ends", () => { + const app = document.createElement("openclaw-app") as unknown as AppLifecycleState; + app.loginShowGatewayToken = true; + app.loginShowGatewayPassword = true; + + app.disconnectedCallback(); + + expect(app.loginShowGatewayToken).toBe(false); + expect(app.loginShowGatewayPassword).toBe(false); + }); + + it("hides revealed login credentials when the Gateway source changes", () => { + const app = document.createElement("openclaw-app") as unknown as AppLifecycleState; + const snapshot = { + client: null, + connected: false, + reconnecting: false, + lastError: null, + lastErrorCode: null, + } as ApplicationGatewaySnapshot; + const firstGateway = { + snapshot, + connection: { gatewayUrl: "ws://first.test", token: "first", password: "first-password" }, + } as ApplicationGateway; + const secondGateway = { + snapshot, + connection: { + gatewayUrl: "ws://second.test", + token: "second", + password: "second-password", + }, + } as ApplicationGateway; + app.synchronizeGateway(firstGateway); + app.loginShowGatewayToken = true; + app.loginShowGatewayPassword = true; + + app.synchronizeGateway(secondGateway); + + expect(app.loginShowGatewayToken).toBe(false); + expect(app.loginShowGatewayPassword).toBe(false); + expect(app.loginToken).toBe("second"); + expect(app.loginPassword).toBe("second-password"); + }); +}); + +describe("OpenClaw shell source initialization", () => { + it("clears retained presentation and source ownership when its context epoch ends", () => { + const shell = document.createElement("openclaw-app-shell") as unknown as ShellEpochState; + const client = {} as GatewayBrowserClient; + const agents = {} as ApplicationContext["agents"]; + const runtimeConfig = {} as ApplicationContext["runtimeConfig"]; + const trigger = document.createElement("button"); + shell.navDrawerOpen = true; + shell.navDrawerTrigger = trigger; + shell.lastWorkspaceLocation = { routeId: "overview", search: "?agent=old" }; + shell.activeSessionKey = "agent:old:main"; + shell.agentLabel = "Old agent"; + shell.commandPaletteTarget = {}; + shell.agentsListClient = client; + shell.agentsListSource = agents; + shell.sessionKeyClient = client; + shell.runtimeConfigClient = client; + shell.runtimeConfigSource = runtimeConfig; + shell.terminalClient = client; + shell.settingsPreloadTimers.set( + trigger, + globalThis.setTimeout(() => undefined, 60_000), + ); + + shell.disconnectedCallback(); + + expect(shell.navDrawerOpen).toBe(false); + expect(shell.navDrawerTrigger).toBeNull(); + expect(shell.lastWorkspaceLocation).toBeNull(); + expect(shell.activeSessionKey).toBe(""); + expect(shell.agentLabel).toBe(""); + expect(shell.commandPaletteTarget).toBeUndefined(); + expect(shell.agentsListClient).toBeNull(); + expect(shell.agentsListSource).toBeNull(); + expect(shell.sessionKeyClient).toBeNull(); + expect(shell.runtimeConfigClient).toBeNull(); + expect(shell.runtimeConfigSource).toBeNull(); + expect(shell.terminalClient).toBeNull(); + expect(shell.settingsPreloadTimers.size).toBe(0); + }); + + it("initializes replacement capabilities even when the Gateway client is unchanged", () => { + const shell = document.createElement( + "openclaw-app-shell", + ) as unknown as ShellInitializationState; + shell.routeState = { routeId: "overview" }; + const client = {} as GatewayBrowserClient; + const snapshot = { client, connected: true }; + const firstAgents = { + state: { agentsList: null }, + ensureList: vi.fn(() => Promise.resolve(null)), + } as unknown as ApplicationContext["agents"]; + const secondAgents = { + state: { agentsList: null }, + ensureList: vi.fn(() => Promise.resolve(null)), + } as unknown as ApplicationContext["agents"]; + const firstRuntimeConfig = { + ensureLoaded: vi.fn(() => Promise.resolve()), + } as unknown as ApplicationContext["runtimeConfig"]; + const secondRuntimeConfig = { + ensureLoaded: vi.fn(() => Promise.resolve()), + } as unknown as ApplicationContext["runtimeConfig"]; + + shell.ensureAgentsList(snapshot, firstAgents); + shell.ensureAgentsList(snapshot, firstAgents); + shell.ensureAgentsList(snapshot, secondAgents); + shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig); + shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig); + shell.ensureRuntimeConfig(snapshot, secondRuntimeConfig); + + expect(firstAgents.ensureList).toHaveBeenCalledOnce(); + expect(secondAgents.ensureList).toHaveBeenCalledOnce(); + expect(firstRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce(); + expect(secondRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index b5e2a200f82..3896d6dbf5b 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -1,6 +1,6 @@ import { consume, ContextProvider } from "@lit/context"; import type { RouteLocation, RouterState } from "@openclaw/uirouter"; -import { html, LitElement, nothing } from "lit"; +import { html, nothing } from "lit"; import { property, query, state } from "lit/decorators.js"; import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts"; import type { AgentsListResult } from "../api/types.ts"; @@ -31,6 +31,8 @@ import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts" import { searchForSession } from "../lib/sessions/index.ts"; import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts"; import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts"; import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; @@ -134,7 +136,7 @@ function isMobileNavLayout(): boolean { return globalThis.matchMedia?.("(max-width: 1100px)").matches ?? false; } -class OpenClawApp extends LitElement { +class OpenClawApp extends OpenClawLightDomElement { @state() private gatewayConnected = false; @state() private gatewayReconnecting = false; @state() private gatewayLastError: string | null = null; @@ -162,58 +164,79 @@ class OpenClawApp extends LitElement { private readonly contextProvider = new ContextProvider(this, { context: applicationContext, }); - private stopGatewaySubscription: (() => void) | undefined; - private stopConfigSubscription: (() => void) | undefined; + private readonly subscriptions = new SubscriptionsController(this); + private loginGatewaySource: ApplicationContext["gateway"] | null = null; + private loginConnectionClient: GatewayBrowserClient | null = null; - override createRenderRoot() { - return this; + constructor() { + super(); + this.subscriptions + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.synchronizeGateway(gateway), + ) + .watch( + () => (this.terminalOnly ? this.context?.config : undefined), + (config, notify) => config.subscribe(notify), + () => this.updateTerminalSurface(), + ); } override connectedCallback() { super.connectedCallback(); + this.resetLoginSensitivePresentation(); this.runtime = bootstrapApplication(); this.context = this.runtime.context; this.initialAuthPresent = hasStoredGatewayAuth(this.context.gateway.connection); this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; + // Context identity changes only across a full app-tree connection epoch; + // descendants reconnect and rebuild their controller-owned state afterward. this.contextProvider.setValue(this.context); this.syncLoginConnection(); - let gatewayClient = this.context.gateway.snapshot.client; - this.updateGatewayStatus(this.context.gateway.snapshot); - this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { - if (snapshot.client !== gatewayClient) { - gatewayClient = snapshot.client; - this.syncLoginConnection(); - } - this.updateGatewayStatus(snapshot); - this.updateTerminalSurface(); - }); - if (this.terminalOnly) { - // Terminal availability also depends on config.terminalEnabled, which - // can arrive after the gateway snapshot; track it for this document mode. - this.updateTerminalSurface(); - this.stopConfigSubscription = this.context.config.subscribe(() => { - this.updateTerminalSurface(); - }); - } + // The runtime is created after controller hostConnected hooks run. Ensure + // their lazy source getters bind on both the initial mount and reconnect. + this.requestUpdate(); void this.runtime.start().catch((error: unknown) => { console.error("[openclaw] application start failed", error); }); } override disconnectedCallback() { - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; - this.stopConfigSubscription?.(); - this.stopConfigSubscription = undefined; + // Stop reactive subscriptions before disposing their application sources. + this.subscriptions.clear(); this.runtime?.stop(); this.runtime = undefined; this.context = undefined; + this.loginGatewaySource = null; + this.loginConnectionClient = null; this.pendingGatewayUrl = null; + this.resetLoginSensitivePresentation(); super.disconnectedCallback(); } - private syncLoginConnection() { - const connection = this.context?.gateway.connection; + private synchronizeGateway(gateway: ApplicationContext["gateway"]) { + const sourceChanged = gateway !== this.loginGatewaySource; + if (sourceChanged) { + this.loginGatewaySource = gateway; + this.loginConnectionClient = null; + this.resetLoginSensitivePresentation(); + } + const snapshot = gateway.snapshot; + const clientChanged = snapshot.client !== this.loginConnectionClient; + if (clientChanged) { + this.loginConnectionClient = snapshot.client; + this.resetLoginSensitivePresentation(); + } + if (sourceChanged || clientChanged) { + this.syncLoginConnection(gateway); + } + this.updateGatewayStatus(snapshot); + this.updateTerminalSurface(); + } + + private syncLoginConnection(gateway = this.context?.gateway) { + const connection = gateway?.connection; if (!connection) { return; } @@ -222,6 +245,11 @@ class OpenClawApp extends LitElement { this.loginPassword = connection.password; } + private resetLoginSensitivePresentation() { + this.loginShowGatewayToken = false; + this.loginShowGatewayPassword = false; + } + private readonly updateGatewayStatus = (snapshot: { connected: boolean; reconnecting: boolean; @@ -368,10 +396,10 @@ class OpenClawApp extends LitElement { } } -class OpenClawShell extends LitElement { +class OpenClawShell extends OpenClawLightDomElement { @property({ attribute: false }) runtime?: ApplicationRuntime; @property({ attribute: false }) onboarding = false; - @consume({ context: applicationContext, subscribe: false }) + @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @state() private navCollapsed = false; @@ -406,125 +434,128 @@ class OpenClawShell extends LitElement { // chat (the app default route) when settings was the entry point. private lastWorkspaceLocation: { routeId: RouteId; search: string } | null = null; private agentsListClient: GatewayBrowserClient | null = null; + private agentsListSource: ApplicationContext["agents"] | null = null; private sessionKeyClient: GatewayBrowserClient | null = null; - private stopAgentsSubscription: (() => void) | undefined; - private stopConfigSubscription: (() => void) | undefined; - private stopGatewaySubscription: (() => void) | undefined; - private stopNavigationSubscription: (() => void) | undefined; - private stopRouteSubscription: (() => void) | undefined; - private stopOverlaySubscription: (() => void) | undefined; - private stopRuntimeConfigSubscription: (() => void) | undefined; - private stopThemeSubscription: (() => void) | undefined; + private runtimeConfigClient: GatewayBrowserClient | null = null; + private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null; + private readonly settingsPreloadTimers = new Map< + EventTarget, + ReturnType + >(); + private readonly subscriptions = new SubscriptionsController(this); - override createRenderRoot() { - return this; + constructor() { + super(); + this.subscriptions + .effect( + () => this.context, + () => () => this.resetShellEpochState(), + ) + .watch( + () => this.context?.navigation, + (navigation, notify) => navigation.subscribe(notify), + (navigation) => this.updateNavigationPreferences(navigation.snapshot), + ) + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.synchronizeGateway(gateway.snapshot), + ) + .watch( + () => this.context?.config, + (config, notify) => config.subscribe(notify), + () => { + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.updateTerminalSurface(snapshot); + } + }, + ) + .watch( + () => this.context?.theme, + (theme, notify) => theme.subscribe(notify), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + (agents) => { + this.updateAgentLabel(); + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.ensureAgentsList(snapshot, agents); + } + }, + ) + .effect( + () => this.runtime?.router, + (router) => { + this.updateRouteState(selectShellRouteState(router.getState())); + return router.subscribeSelector( + selectShellRouteState, + (routeState) => this.updateRouteState(routeState), + equalShellRouteState, + ); + }, + ) + .watch( + () => this.context?.overlays, + (overlays, notify) => overlays.subscribe(notify), + (overlays) => { + this.overlaySnapshot = overlays.snapshot; + }, + ) + .watch( + () => this.context?.runtimeConfig, + (runtimeConfig, notify) => runtimeConfig.subscribe(notify), + (runtimeConfig) => { + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.ensureRuntimeConfig(snapshot, runtimeConfig); + } + }, + ); } override connectedCallback() { super.connectedCallback(); - this.startSubscriptions(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.addEventListener("keydown", this.handleDocumentKeydown); window.addEventListener("resize", this.handleWindowResize); } - override updated() { - this.startSubscriptions(); - } - - private startSubscriptions() { - const runtime = this.runtime; - const context = this.context; - if ( - !runtime || - !context || - this.stopAgentsSubscription || - this.stopConfigSubscription || - this.stopGatewaySubscription || - this.stopNavigationSubscription || - this.stopRouteSubscription || - this.stopOverlaySubscription || - this.stopRuntimeConfigSubscription || - this.stopThemeSubscription - ) { - return; - } - this.updateNavigationPreferences(context.navigation.snapshot); - this.stopNavigationSubscription = context.navigation.subscribe((snapshot) => { - this.updateNavigationPreferences(snapshot); - }); - this.updateGatewaySessionKey(context.gateway.snapshot); - this.updateGatewayStatus(context.gateway.snapshot); - this.updateTerminalSurface(context.gateway.snapshot); - this.updateAgentLabel(); - this.ensureRuntimeConfig(context.gateway.snapshot); - this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { - this.updateGatewaySessionKey(snapshot); - this.updateGatewayStatus(snapshot); - this.updateTerminalSurface(snapshot); - this.updateAgentLabel(); - this.ensureAgentsList(snapshot); - this.ensureRuntimeConfig(snapshot); - }); - this.stopConfigSubscription = context.config.subscribe(() => { - this.updateTerminalSurface(context.gateway.snapshot); - }); - this.stopThemeSubscription = context.theme.subscribe(() => this.requestUpdate()); - this.stopAgentsSubscription = context.agents.subscribe(() => { - this.updateAgentLabel(); - }); - this.updateRouteState(selectShellRouteState(runtime.router.getState())); - this.stopRouteSubscription = runtime.router.subscribeSelector( - selectShellRouteState, - (routeState) => { - this.updateRouteState(routeState); - }, - equalShellRouteState, - ); - this.overlaySnapshot = context.overlays.snapshot; - this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => { - this.overlaySnapshot = snapshot; - }); - this.stopRuntimeConfigSubscription = context.runtimeConfig.subscribe(() => { - // Route enablement (e.g. Workboard) derives from the config snapshot. - this.requestUpdate(); - }); - } - override disconnectedCallback() { this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.removeEventListener("keydown", this.handleDocumentKeydown); window.removeEventListener("resize", this.handleWindowResize); - this.stopAgentsSubscription?.(); - this.stopAgentsSubscription = undefined; - this.stopConfigSubscription?.(); - this.stopConfigSubscription = undefined; - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; - this.stopNavigationSubscription?.(); - this.stopNavigationSubscription = undefined; - this.stopRouteSubscription?.(); - this.stopRouteSubscription = undefined; - this.stopOverlaySubscription?.(); - this.stopOverlaySubscription = undefined; - this.stopRuntimeConfigSubscription?.(); - this.stopRuntimeConfigSubscription = undefined; - this.stopThemeSubscription?.(); - this.stopThemeSubscription = undefined; - this.agentsListClient = null; - this.sessionKeyClient = null; - this.terminalClient = null; - this.navDrawerTrigger = null; + this.resetShellEpochState(); super.disconnectedCallback(); } + private resetShellEpochState() { + this.navDrawerOpen = false; + this.navDrawerTrigger = null; + this.lastWorkspaceLocation = null; + this.activeSessionKey = ""; + this.agentLabel = ""; + this.commandPaletteTarget = undefined; + this.agentsListClient = null; + this.agentsListSource = null; + this.sessionKeyClient = null; + this.runtimeConfigClient = null; + this.runtimeConfigSource = null; + this.terminalClient = null; + for (const timer of this.settingsPreloadTimers.values()) { + globalThis.clearTimeout(timer); + } + this.settingsPreloadTimers.clear(); + } + private readonly handleThemeChange = (event: CustomEvent) => { const context = this.context; if (!context) { return; } context.theme.setMode(event.detail.mode, event.detail.element); - this.requestUpdate(); }; private chatNavigationOptions(options?: ApplicationNavigationOptions) { @@ -709,6 +740,15 @@ class OpenClawShell extends LitElement { this.requestUpdate(); }; + private synchronizeGateway(snapshot: ApplicationContext["gateway"]["snapshot"]) { + this.updateGatewaySessionKey(snapshot); + this.updateGatewayStatus(snapshot); + this.updateTerminalSurface(snapshot); + this.updateAgentLabel(); + this.ensureAgentsList(snapshot); + this.ensureRuntimeConfig(snapshot); + } + private readonly updateGatewayStatus = (snapshot: { connected: boolean; lastError: string | null; @@ -731,15 +771,28 @@ class OpenClawShell extends LitElement { ); } - private ensureRuntimeConfig(snapshot: { - client: GatewayBrowserClient | null; - connected: boolean; - }) { + private ensureRuntimeConfig( + snapshot: { + client: GatewayBrowserClient | null; + connected: boolean; + }, + runtimeConfig = this.context?.runtimeConfig, + ) { // The sidebar hides config-gated routes (Workboard), so the snapshot must // load eagerly instead of waiting for a page that happens to fetch it. - if (snapshot.connected && snapshot.client) { - void this.context?.runtimeConfig.ensureLoaded(); + if (!snapshot.connected || !snapshot.client || !runtimeConfig) { + this.runtimeConfigClient = null; + return; } + if ( + this.runtimeConfigClient === snapshot.client && + this.runtimeConfigSource === runtimeConfig + ) { + return; + } + this.runtimeConfigClient = snapshot.client; + this.runtimeConfigSource = runtimeConfig; + void runtimeConfig.ensureLoaded(); } private enabledRouteIds(): readonly RouteId[] { @@ -748,20 +801,24 @@ class OpenClawShell extends LitElement { : ROUTE_IDS_WITHOUT_WORKBOARD; } - private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) { + private ensureAgentsList( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + agents = this.context?.agents, + ) { if (!snapshot.connected || !snapshot.client) { this.agentsListClient = null; return; } const routeId = this.routeState.routeId; - if (!routeId || routeId === "chat" || this.context?.agents.state.agentsList) { + if (!agents || !routeId || routeId === "chat" || agents.state.agentsList) { return; } - if (this.agentsListClient === snapshot.client) { + if (this.agentsListClient === snapshot.client && this.agentsListSource === agents) { return; } this.agentsListClient = snapshot.client; - void this.context?.agents.ensureList(); + this.agentsListSource = agents; + void agents.ensureList(); } private updateGatewaySessionKey(snapshot: { @@ -891,6 +948,7 @@ class OpenClawShell extends LitElement { onExit: () => this.exitSettings(), onNavigate: (routeId) => this.navigate(routeId), onPreload: (routeId) => context.preload(routeId), + preloadTimers: this.settingsPreloadTimers, }) : html`) { + this.contextProvider.setValue(context); + } +} + +class TestApplicationContextConsumer extends LitElement { + @consume({ context: applicationContext, subscribe: true }) + context?: ApplicationContext; +} + +if (!customElements.get(PROVIDER_ELEMENT_NAME)) { + customElements.define(PROVIDER_ELEMENT_NAME, TestApplicationContextProvider); +} +if (!customElements.get(CONSUMER_ELEMENT_NAME)) { + customElements.define(CONSUMER_ELEMENT_NAME, TestApplicationContextConsumer); +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("application context consumption", () => { + it("rebinds a retained consumer after its provider value changes while disconnected", async () => { + const initialContext = { basePath: "/initial" } as ApplicationContext; + const replacementContext = { basePath: "/replacement" } as ApplicationContext; + const provider = document.createElement( + PROVIDER_ELEMENT_NAME, + ) as TestApplicationContextProvider; + const consumer = document.createElement( + CONSUMER_ELEMENT_NAME, + ) as TestApplicationContextConsumer; + + document.body.append(provider); + provider.setContext(initialContext); + provider.append(consumer); + await consumer.updateComplete; + expect(consumer.context).toBe(initialContext); + + consumer.remove(); + provider.setContext(replacementContext); + provider.append(consumer); + await consumer.updateComplete; + + expect(consumer.context).toBe(replacementContext); + }); +}); diff --git a/ui/src/app/native-link-routing.test.ts b/ui/src/app/native-link-routing.test.ts new file mode 100644 index 00000000000..bcd0799d3c6 --- /dev/null +++ b/ui/src/app/native-link-routing.test.ts @@ -0,0 +1,245 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import "../components/github-link-hovercard.ts"; +import type { GitHubLinkHovercardProvider } from "../components/github-link-hovercard.ts"; +import "../components/modal-dialog.ts"; +import { startNativeLinkRouting, type NativeLinkRouting } from "./native-link-routing.ts"; + +type NativeMessage = { type: string; url: string; target: string }; + +let routing: NativeLinkRouting | undefined; + +afterEach(() => { + routing?.dispose(); + routing = undefined; + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +function installBridge() { + const messages: NativeMessage[] = []; + const postMessage = vi.fn((message: NativeMessage) => messages.push(message)); + vi.stubGlobal("webkit", { messageHandlers: { openclawLink: { postMessage } } }); + return { messages, postMessage }; +} + +function appendLink(href: string, attributes: Record = {}) { + const anchor = document.createElement("a"); + anchor.href = href; + anchor.textContent = href; + for (const [name, value] of Object.entries(attributes)) { + anchor.setAttribute(name, value); + } + document.body.append(anchor); + return anchor; +} + +function click(anchor: HTMLAnchorElement, init: MouseEventInit = {}) { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + composed: true, + button: 0, + ...init, + }); + anchor.dispatchEvent(event); + return event; +} + +function contextMenu(anchor: HTMLAnchorElement) { + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + clientX: 120, + clientY: 140, + }); + anchor.dispatchEvent(event); + return event; +} + +function menuItem(label: string): HTMLButtonElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!item) { + throw new Error(`Expected menu item: ${label}`); + } + return item; +} + +describe("native link routing", () => { + it("does not install native behavior without the WebKit bridge", () => { + routing = startNativeLinkRouting(); + const anchor = appendLink("https://example.com/report"); + + const event = contextMenu(anchor); + + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector("openclaw-native-link-menu")).toBeNull(); + }); + + it("routes an unmodified external click inline and preserves page-level cleanup", () => { + const bridge = installBridge(); + routing = startNativeLinkRouting(); + const anchor = appendLink("https://example.com/report"); + const bubbleHandler = vi.fn(); + anchor.addEventListener("click", bubbleHandler); + + const event = click(anchor); + + expect(event.defaultPrevented).toBe(true); + expect(bubbleHandler).toHaveBeenCalledOnce(); + expect(bridge.messages).toEqual([ + { type: "open-link", url: "https://example.com/report", target: "inline" }, + ]); + }); + + it("closes an active GitHub hovercard after routing its link", async () => { + const bridge = installBridge(); + routing = startNativeLinkRouting(); + const provider = document.createElement( + "openclaw-github-link-hovercard-provider", + ) as GitHubLinkHovercardProvider; + provider.client = { + request: vi.fn().mockResolvedValue({ + comments: 1, + createdAt: "2026-07-09T10:00:00Z", + kind: "issue", + login: "octocat", + number: 102691, + owner: "openclaw", + repo: "openclaw", + state: "open", + title: "Open links in a sidebar browser", + updatedAt: "2026-07-09T10:00:00Z", + }), + } as unknown as GatewayBrowserClient; + const anchor = document.createElement("a"); + anchor.href = "https://github.com/openclaw/openclaw/issues/102691"; + anchor.textContent = "#102691"; + provider.append(anchor); + document.body.append(provider); + anchor.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); + await vi.waitFor(() => expect(document.querySelector(".github-link-hovercard")).not.toBeNull()); + + click(anchor); + + expect(document.querySelector(".github-link-hovercard")).toBeNull(); + expect(anchor.hasAttribute("aria-describedby")).toBe(false); + expect(bridge.messages).toEqual([ + { + type: "open-link", + url: "https://github.com/openclaw/openclaw/issues/102691", + target: "inline", + }, + ]); + }); + + it("preserves modifiers, local/file/download links, and non-web schemes", () => { + const bridge = installBridge(); + routing = startNativeLinkRouting(); + const links = [ + appendLink(`${location.origin}/usage`), + appendLink("https://example.com/file", { "data-file-path": "README.md" }), + appendLink("https://example.com/archive.zip", { download: "archive.zip" }), + appendLink("mailto:hello@example.com"), + ]; + for (const anchor of links) { + anchor.addEventListener("click", (event) => event.preventDefault()); + click(anchor); + } + const modified = appendLink("https://example.com/modified"); + const bubbleHandler = vi.fn((event: Event) => event.preventDefault()); + modified.addEventListener("click", bubbleHandler); + click(modified, { metaKey: true }); + + expect(bubbleHandler).toHaveBeenCalledOnce(); + expect(bridge.messages).toEqual([]); + }); + + it("offers inline, external, and copy actions for an external link", async () => { + const bridge = installBridge(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator); + routing = startNativeLinkRouting(); + const anchor = appendLink("https://example.com/report?q=1"); + + expect(contextMenu(anchor).defaultPrevented).toBe(true); + const firstMenu = document.querySelector("openclaw-native-link-menu"); + await (firstMenu as HTMLElement & { updateComplete: Promise }).updateComplete; + expect( + [...firstMenu!.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim()), + ).toEqual(["Open in Sidebar", "Open in Default Browser", "Copy Link"]); + menuItem("Open in Default Browser").click(); + expect(bridge.messages.at(-1)).toEqual({ + type: "open-link", + url: "https://example.com/report?q=1", + target: "external", + }); + + contextMenu(anchor); + const secondMenu = document.querySelector("openclaw-native-link-menu"); + await (secondMenu as HTMLElement & { updateComplete: Promise }).updateComplete; + menuItem("Copy Link").click(); + await vi.waitFor(() => + expect(writeText).toHaveBeenCalledWith("https://example.com/report?q=1"), + ); + }); + + it("mounts a fallback menu inside an active dialog", async () => { + installBridge(); + routing = startNativeLinkRouting(); + const dialog = document.createElement("dialog"); + dialog.setAttribute("open", ""); + const anchor = document.createElement("a"); + anchor.href = "https://example.com/dialog-link"; + dialog.append(anchor); + document.body.append(dialog); + + contextMenu(anchor); + + const menu = dialog.querySelector("openclaw-native-link-menu"); + expect(menu).not.toBeNull(); + await (menu as HTMLElement & { updateComplete: Promise }).updateComplete; + expect(menuItem("Open in Sidebar")).not.toBeNull(); + }); + + it("keeps modal menus in the styled light-DOM slot", async () => { + installBridge(); + routing = startNativeLinkRouting(); + const modal = document.createElement("openclaw-modal-dialog"); + const anchor = document.createElement("a"); + anchor.href = "https://example.com/modal-link"; + modal.append(anchor); + document.body.append(modal); + await (modal as HTMLElement & { updateComplete: Promise }).updateComplete; + + contextMenu(anchor); + + const menu = modal.querySelector("openclaw-native-link-menu"); + expect(menu).not.toBeNull(); + expect(menu?.getRootNode()).toBe(document); + await (menu as HTMLElement & { updateComplete: Promise }).updateComplete; + expect(menuItem("Open in Sidebar")).not.toBeNull(); + }); + + it("removes listeners and an open menu on dispose", async () => { + const bridge = installBridge(); + routing = startNativeLinkRouting(); + const anchor = appendLink("https://example.com/report"); + contextMenu(anchor); + expect(document.querySelector("openclaw-native-link-menu")).not.toBeNull(); + + routing.dispose(); + routing = undefined; + anchor.addEventListener("click", (event) => event.preventDefault()); + click(anchor); + + expect(document.querySelector("openclaw-native-link-menu")).toBeNull(); + expect(bridge.messages).toEqual([]); + }); +}); diff --git a/ui/src/app/native-link-routing.ts b/ui/src/app/native-link-routing.ts new file mode 100644 index 00000000000..47dacc84a69 --- /dev/null +++ b/ui/src/app/native-link-routing.ts @@ -0,0 +1,176 @@ +import { NativeLinkMenu, type NativeLinkMenuAction } from "../components/native-link-menu.ts"; +import { copyToClipboard } from "../lib/clipboard.ts"; + +type NativeLinkTarget = "inline" | "external"; + +type NativeLinkMessage = { + type: "open-link"; + url: string; + target: NativeLinkTarget; +}; + +type WebKitMessageHandler = { + postMessage(message: NativeLinkMessage): void; +}; + +export type NativeLinkRouting = { + dispose(): void; +}; + +function getNativeLinkPoster(): WebKitMessageHandler["postMessage"] | undefined { + // Native hosts install this handler before navigation; its absence preserves browser behavior. + const handler = ( + window as unknown as { + webkit?: { messageHandlers?: { openclawLink?: WebKitMessageHandler } }; + } + ).webkit?.messageHandlers?.openclawLink; + return handler?.postMessage.bind(handler); +} + +function anchorFromEvent(event: Event): HTMLAnchorElement | null { + for (const target of event.composedPath()) { + if (target instanceof HTMLAnchorElement) { + return target; + } + } + return event.target instanceof Element ? event.target.closest("a") : null; +} + +function externalHttpUrl(event: Event): { anchor: HTMLAnchorElement; url: URL } | null { + const anchor = anchorFromEvent(event); + if (!anchor || anchor.hasAttribute("download") || anchor.hasAttribute("data-file-path")) { + return null; + } + let url: URL; + try { + url = new URL(anchor.href, window.location.href); + } catch { + return null; + } + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.origin === location.origin) { + return null; + } + return { anchor, url }; +} + +function menuContainer(event: Event): HTMLElement { + const path = event.composedPath(); + const modalHost = path.find( + (target) => target instanceof HTMLElement && target.localName === "openclaw-modal-dialog", + ); + if (modalHost instanceof HTMLElement) { + // Keep the menu in the modal's light-DOM slot so global menu styles still apply. + return modalHost; + } + for (const target of path) { + if (target instanceof HTMLDialogElement && target.open && target.getRootNode() === document) { + return target; + } + } + return document.body; +} + +function postNativeLink( + postMessage: WebKitMessageHandler["postMessage"], + url: URL, + target: NativeLinkTarget, +): boolean { + try { + postMessage({ type: "open-link", url: url.href, target }); + return true; + } catch { + return false; + } +} + +export function startNativeLinkRouting(): NativeLinkRouting { + if (typeof window === "undefined" || typeof document === "undefined") { + return { dispose() {} }; + } + const postMessage = getNativeLinkPoster(); + if (!postMessage) { + return { dispose() {} }; + } + + let menu: NativeLinkMenu | null = null; + const closeMenu = () => { + menu?.remove(); + menu = null; + }; + const showMenu = ( + anchor: HTMLAnchorElement, + url: URL, + x: number, + y: number, + container: HTMLElement, + ) => { + closeMenu(); + const nextMenu = document.createElement("openclaw-native-link-menu") as NativeLinkMenu; + nextMenu.x = x; + nextMenu.y = y; + nextMenu.trigger = anchor; + nextMenu.onClose = closeMenu; + nextMenu.onAction = (action: NativeLinkMenuAction) => { + if (action === "copy") { + void copyToClipboard(url.href); + return; + } + postNativeLink(postMessage, url, action); + }; + menu = nextMenu; + nextMenu.setAttribute("popover", "manual"); + container.append(nextMenu); + if (typeof nextMenu.showPopover === "function") { + try { + nextMenu.showPopover(); + return; + } catch { + // Fall through to an in-dialog element when the top-layer API is unavailable. + } + } + nextMenu.removeAttribute("popover"); + }; + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + const link = externalHttpUrl(event); + if (!link || !postNativeLink(postMessage, link.url, "inline")) { + return; + } + closeMenu(); + event.preventDefault(); + }; + const handleContextMenu = (event: MouseEvent) => { + if (event.defaultPrevented) { + return; + } + const link = externalHttpUrl(event); + if (!link) { + return; + } + event.preventDefault(); + event.stopPropagation(); + showMenu(link.anchor, link.url, event.clientX, event.clientY, menuContainer(event)); + }; + + document.addEventListener("click", handleClick, true); + // Capture keeps message-level context menus from replacing native link actions. + document.addEventListener("contextmenu", handleContextMenu, true); + + return { + dispose() { + document.removeEventListener("click", handleClick, true); + document.removeEventListener("contextmenu", handleContextMenu, true); + closeMenu(); + }, + }; +} diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index b5ba79f81fc..1d440a4f2d5 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -5,6 +5,7 @@ import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.t import { createApplicationOverlays } from "./overlays.ts"; type RequestFn = (method: string, params?: unknown) => Promise; +const VERIFICATION_POLL_MS = 250; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -25,11 +26,14 @@ function approval(id: string, createdAtMs: number) { }; } -function createGatewayHarness(initialClient: GatewayBrowserClient) { +function createGatewayHarness( + initialClient: GatewayBrowserClient | null, + initialConnected = initialClient !== null, +) { let snapshot: ApplicationGatewaySnapshot = { assistantAgentId: "main", client: initialClient, - connected: true, + connected: initialConnected, reconnecting: false, hello: null, lastError: null, @@ -85,7 +89,59 @@ function client(request: RequestFn): GatewayBrowserClient { return { request } as unknown as GatewayBrowserClient; } +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe("application approval overlays", () => { + it("reloads pending approvals for each connected epoch", async () => { + const firstList = deferred(); + const reconnectedList = deferred(); + let execListRequests = 0; + const request = vi.fn((method) => { + if (method !== "exec.approval.list") { + return Promise.resolve([]); + } + execListRequests += 1; + return execListRequests === 1 ? firstList.promise : reconnectedList.promise; + }); + const gatewayClient = client(request); + const harness = createGatewayHarness(null, false); + const overlays = createApplicationOverlays(harness.gateway); + + harness.update({ client: gatewayClient, connected: false }); + await flushMicrotasks(); + expect(request).not.toHaveBeenCalled(); + + harness.update({ connected: true }); + await flushMicrotasks(); + expect(execListRequests).toBe(1); + expect(request).toHaveBeenCalledWith("exec.approval.list", {}); + expect(request).toHaveBeenCalledWith("plugin.approval.list", {}); + + harness.update({ connected: false }); + expect(overlays.snapshot.approvalQueue).toEqual([]); + harness.update({ connected: true }); + await flushMicrotasks(); + expect(execListRequests).toBe(2); + + reconnectedList.resolve([approval("approval-reconnected", 2_000)]); + await vi.waitFor(() => { + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([ + "approval-reconnected", + ]); + }); + + firstList.resolve([approval("approval-stale", 1_000)]); + await flushMicrotasks(); + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([ + "approval-reconnected", + ]); + overlays.dispose(); + }); + it("does not attach an older resolve failure to a newer approval", async () => { const resolveAttempt = deferred(); const request = vi.fn((method) => @@ -142,6 +198,47 @@ describe("application approval overlays", () => { expect(overlays.snapshot.approvalQueue).toEqual([]); overlays.dispose(); }); + + it("does not dismiss a new approval when an old same-client decision settles", async () => { + const oldResolve = deferred(); + const request = vi.fn((method) => + method.endsWith(".list") ? Promise.resolve([]) : oldResolve.promise, + ); + const gatewayClient = client(request); + const harness = createGatewayHarness(gatewayClient); + const overlays = createApplicationOverlays(harness.gateway); + + harness.emitApproval("approval-old", 1_000); + const oldDecision = overlays.decideApproval("allow-once"); + harness.update({ connected: false }); + harness.update({ connected: true }); + await flushMicrotasks(); + harness.emitApproval("approval-new", 2_000); + + oldResolve.resolve({ ok: true }); + await oldDecision; + + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual(["approval-new"]); + expect(overlays.snapshot.approvalBusy).toBe(false); + overlays.dispose(); + }); + + it("ignores a decision that settles after disposal", async () => { + const resolveAttempt = deferred(); + const request = vi.fn((method) => + method.endsWith(".list") ? Promise.resolve([]) : resolveAttempt.promise, + ); + const harness = createGatewayHarness(client(request)); + const overlays = createApplicationOverlays(harness.gateway); + + harness.emitApproval("approval-active", 1_000); + const decision = overlays.decideApproval("allow-once"); + overlays.dispose(); + resolveAttempt.reject(new Error("disposed")); + await decision; + + expect(overlays.snapshot.approvalError).toBeNull(); + }); }); describe("application update overlays", () => { @@ -164,4 +261,62 @@ describe("application update overlays", () => { expect(overlays.snapshot.updateRunning).toBe(false); overlays.dispose(); }); + + it("verifies on reconnect and survives updates within the connected epoch", async () => { + vi.useFakeTimers(); + let statusRequests = 0; + const request = vi.fn((method) => { + if (method.endsWith(".list")) { + return Promise.resolve([]); + } + if (method === "update.run") { + return Promise.resolve({ + ok: true, + result: { status: "ok", after: { version: "2.0.0" } }, + }); + } + if (method === "update.status") { + statusRequests += 1; + return Promise.resolve( + statusRequests === 1 + ? { + sentinel: { + kind: "update", + status: "skipped", + stats: { reason: "restart-health-pending" }, + }, + } + : { + sentinel: { + kind: "update", + status: "ok", + stats: { after: { version: "2.0.0" } }, + }, + }, + ); + } + return Promise.resolve({}); + }); + const gatewayClient = client(request); + const harness = createGatewayHarness(gatewayClient); + const overlays = createApplicationOverlays(harness.gateway); + + try { + await overlays.runUpdate(); + harness.update({ connected: false }); + harness.update({ connected: true }); + await flushMicrotasks(); + expect(statusRequests).toBe(1); + + harness.update({ sessionKey: "agent:main:next" }); + await vi.advanceTimersByTimeAsync(VERIFICATION_POLL_MS); + await flushMicrotasks(); + + expect(statusRequests).toBe(2); + expect(overlays.snapshot.updateStatusBanner).toBeNull(); + } finally { + overlays.dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 2e264b36384..9cfe7d56b9d 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -194,6 +194,11 @@ type UpdateRunResponse = { restart?: { coalesced?: boolean } | null; }; +type UpdateVerificationWait = { + timer: ReturnType; + resolve: (active: boolean) => void; +}; + export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays { let snapshot: ApplicationOverlaySnapshot = { updateAvailable: null, @@ -211,13 +216,21 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>(); let disposed = false; let activeClient = gateway.snapshot.client; + // A Gateway client survives transport retries; the disconnected boundary + // still starts a new source epoch whose pending server state must be replayed. + let connectedSource: NonNullable | null = null; + let connectedEpoch = 0; let pendingUpdateExpectedVersion: string | null = null; let pendingUpdateHandoff = false; let updateRunGeneration = 0; let updateVerificationGeneration = 0; - let updateVerificationTimer: ReturnType | null = null; + let updateVerificationWait: UpdateVerificationWait | null = null; let devicePairPendingCountGeneration = 0; - let approvalDecision: { client: NonNullable; id: string } | null = null; + let approvalDecision: { + client: NonNullable; + epoch: number; + id: string; + } | null = null; const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = { client: gateway.snapshot.client, connected: gateway.snapshot.connected, @@ -291,9 +304,13 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat publish(); }; - const refreshApprovals = async (client: NonNullable) => { + const refreshApprovals = async ( + client: NonNullable, + epoch = connectedEpoch, + ) => { const applied = await refreshPendingApprovalQueue(promptState, { - isCurrentClient: (requestClient) => requestClient === client && isCurrentClient(client), + isCurrentClient: (requestClient) => + requestClient === client && epoch === connectedEpoch && isCurrentClient(client), }); if (applied && !disposed) { publish(); @@ -305,26 +322,40 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat publish(); }; + const settleUpdateVerificationWait = (active: boolean) => { + const wait = updateVerificationWait; + if (!wait) { + return; + } + updateVerificationWait = null; + globalThis.clearTimeout(wait.timer); + wait.resolve(active); + }; + const cancelUpdateVerification = () => { updateVerificationGeneration += 1; - if (updateVerificationTimer !== null) { - globalThis.clearTimeout(updateVerificationTimer); - updateVerificationTimer = null; - } + settleUpdateVerificationWait(false); }; const waitForUpdateVerification = (delayMs: number, generation: number) => new Promise((resolve) => { + // Verification loops are serialized, but settling a prior wait keeps a + // future refactor from stranding its continuation behind a replaced timer. + settleUpdateVerificationWait(false); const timer = globalThis.setTimeout(() => { - if (updateVerificationTimer === timer) { - updateVerificationTimer = null; + if (updateVerificationWait?.timer !== timer) { + return; } + updateVerificationWait = null; resolve(generation === updateVerificationGeneration && !disposed); }, delayMs); - updateVerificationTimer = timer; + updateVerificationWait = { timer, resolve }; }); - const verifyPendingUpdateVersion = async (client: NonNullable) => { + const verifyPendingUpdateVersion = async ( + client: NonNullable, + epoch: number, + ) => { const generation = updateVerificationGeneration; const expectedVersion = pendingUpdateExpectedVersion?.trim() || null; const pendingHandoff = pendingUpdateHandoff; @@ -333,6 +364,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } const isCurrentVerification = () => generation === updateVerificationGeneration && + epoch === connectedEpoch && !disposed && activeClient === client && gateway.snapshot.client === client && @@ -405,14 +437,20 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat ); }; - const stopGateway = gateway.subscribe((next) => { - updateRunGeneration += 1; - cancelUpdateVerification(); + const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => { const previousClient = activeClient; + const previousConnectedSource = connectedSource; + const nextConnectedSource = next.connected ? next.client : null; + const connectedSourceChanged = previousConnectedSource !== nextConnectedSource; activeClient = next.client; + connectedSource = nextConnectedSource; promptState.client = next.client; devicePairSetupState.client = next.client; devicePairSetupState.connected = next.connected; + if (connectedSourceChanged) { + updateRunGeneration += 1; + cancelUpdateVerification(); + } if (previousClient !== next.client || !next.connected) { approvalDecision = null; devicePairPendingCountGeneration += 1; @@ -432,15 +470,15 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat return; } snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) }; - if (previousClient !== next.client) { - void refreshApprovals(next.client); - if (next.client) { - void verifyPendingUpdateVersion(next.client); - } - } else { - publish(); + publish(); + if (connectedSourceChanged) { + connectedEpoch += 1; + const epoch = connectedEpoch; + void refreshApprovals(next.client, epoch); + void verifyPendingUpdateVersion(next.client, epoch); } - }); + }; + const stopGateway = gateway.subscribe(synchronizeGateway); const stopEvents = gateway.subscribeEvents((event) => { if (disposed || !isGatewayEvent(event)) { @@ -480,6 +518,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } } }); + synchronizeGateway(gateway.snapshot); return { get snapshot() { @@ -584,30 +623,35 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } promptState.execApprovalBusy = true; promptState.execApprovalError = null; - const operation = { client, id: active.id }; + const operation = { client, epoch: connectedEpoch, id: active.id }; approvalDecision = operation; + const isCurrentOperation = () => + approvalDecision === operation && + operation.epoch === connectedEpoch && + isCurrentClient(operation.client); publish(); try { const method = active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; await client.request(method, { id: active.id, decision }); - if (!isCurrentClient(client)) { + if (!isCurrentOperation()) { return; } dismissExecApprovalPrompt(promptState, active.id); } catch (error) { if (isStaleApprovalResolutionError(error)) { - if (!isCurrentClient(client)) { + if (!isCurrentOperation()) { return; } dismissExecApprovalPrompt(promptState, active.id); const currentClient = activeClient; - if (currentClient && isCurrentClient(currentClient)) { - await refreshApprovals(currentClient); + const epoch = connectedEpoch; + if (currentClient && isCurrentOperation()) { + await refreshApprovals(currentClient, epoch); } return; } - if (isCurrentClient(client) && promptState.execApprovalQueue[0]?.id === active.id) { + if (isCurrentOperation() && promptState.execApprovalQueue[0]?.id === active.id) { promptState.execApprovalError = `Approval failed: ${error instanceof Error ? error.message : String(error)}`; } } finally { @@ -653,6 +697,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat }, dispose() { disposed = true; + approvalDecision = null; updateRunGeneration += 1; devicePairPendingCountGeneration += 1; cancelUpdateVerification(); diff --git a/ui/src/app/router-outlet-controller.test.ts b/ui/src/app/router-outlet-controller.test.ts new file mode 100644 index 00000000000..3d0d1b668b6 --- /dev/null +++ b/ui/src/app/router-outlet-controller.test.ts @@ -0,0 +1,239 @@ +import { createRouter, definePage, type RouteLocation } from "@openclaw/uirouter"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RouterOutletController, selectRenderedRouteMatch } from "./router-outlet-controller.ts"; + +type RouteId = "first" | "second"; +type TestContext = { label: string }; +type TestModule = { render: (data: TestData | undefined) => unknown }; +type TestData = { label: string }; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function location(pathname: string): RouteLocation { + return { pathname, search: "", hash: "" }; +} + +function module(label: string): TestModule { + return { render: () => label }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("RouterOutletController pending presentation", () => { + it("delays a cold-start fallback until the route has been pending for one second", async () => { + vi.useFakeTimers(); + const routeModule = deferred(); + const routeData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => routeModule.promise, + loader: () => routeData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + + const navigation = router.navigate("first", { label: "test" }); + expect(controller.snapshot.pending?.routeId).toBe("first"); + expect(controller.snapshot.showPending).toBe(false); + + await vi.advanceTimersByTimeAsync(999); + expect(controller.snapshot.showPending).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(controller.snapshot.showPending).toBe(true); + + routeModule.resolve(module("first")); + routeData.resolve({ label: "loaded" }); + await navigation; + expect(controller.snapshot.showPending).toBe(false); + controller.disconnect(); + router.stop(); + }); + + it("keeps active content while the next route module is cold", async () => { + vi.useFakeTimers(); + const secondModule = deferred(); + const secondData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => module("first"), + loader: () => ({ label: "first" }), + }), + definePage({ + id: "second", + path: "/second", + component: () => secondModule.promise, + loader: () => secondData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + await router.navigate("first", { label: "test" }); + + const navigation = router.navigate("second", { label: "test" }); + expect( + selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId, + ).toBe("first"); + await vi.advanceTimersByTimeAsync(2_000); + expect(controller.snapshot.showPending).toBe(false); + + secondModule.resolve(module("second")); + await flushPromises(); + expect( + selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId, + ).toBe("second"); + expect(controller.snapshot.active?.data).toBeUndefined(); + + secondData.resolve({ label: "second" }); + await navigation; + controller.disconnect(); + router.stop(); + }); + + it("restarts a canceled pending delay after reconnect", async () => { + vi.useFakeTimers(); + const routeModule = deferred(); + const routeData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => routeModule.promise, + loader: () => routeData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + const navigation = router.navigate("first", { label: "test" }); + + await vi.advanceTimersByTimeAsync(500); + controller.disconnect(); + await vi.advanceTimersByTimeAsync(1_000); + expect(controller.snapshot.showPending).toBe(false); + + controller.connect(); + await vi.advanceTimersByTimeAsync(999); + expect(controller.snapshot.showPending).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(controller.snapshot.showPending).toBe(true); + + routeModule.resolve(module("first")); + routeData.resolve({ label: "loaded" }); + await navigation; + controller.disconnect(); + router.stop(); + }); +}); + +describe("RouterOutletController not-found boundary", () => { + function createTestRouter() { + return createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => module("first"), + loader: () => ({ label: "first" }), + }), + ], + }); + } + + it("notifies once for an unmatched location", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + await router.navigateLocation(location("/missing"), { label: "test" }); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + + controller.setInputs({ router, onNotFound }); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + controller.disconnect(); + router.stop(); + }); + + it("suppresses a queued fallback after the router recovers", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + const missing = router.navigateLocation(location("/missing"), { label: "test" }); + const recovery = router.navigate("first", { label: "test" }); + await Promise.all([missing, recovery]); + await flushPromises(); + expect(onNotFound).not.toHaveBeenCalled(); + controller.disconnect(); + router.stop(); + }); + + it("cancels the queued fallback on disconnect and re-evaluates it on reconnect", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + const missing = router.navigateLocation(location("/missing"), { label: "test" }); + controller.disconnect(); + await missing; + await flushPromises(); + expect(onNotFound).not.toHaveBeenCalled(); + + controller.connect(); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + controller.disconnect(); + router.stop(); + }); +}); diff --git a/ui/src/app/router-outlet-controller.ts b/ui/src/app/router-outlet-controller.ts new file mode 100644 index 00000000000..398cbb7f08b --- /dev/null +++ b/ui/src/app/router-outlet-controller.ts @@ -0,0 +1,273 @@ +import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; + +const DEFAULT_PENDING_DELAY_MS = 1_000; + +type RouterOutletStateSlice< + TRouteId extends string = string, + TModule = unknown, + TData = unknown, +> = { + status: RouterState["status"]; + active: RouteMatch | undefined; + pending: RouteMatch | undefined; +}; + +export type RouterOutletSnapshot< + TRouteId extends string = string, + TModule = unknown, + TData = unknown, +> = RouterOutletStateSlice & { + showPending: boolean; +}; + +type RouterOutletInputs = { + router?: Router; + onNotFound?: () => void; +}; + +type RouterOutletControllerOptions = { + pendingDelayMs?: number; +}; + +export function selectRenderedRouteMatch( + active: RouteMatch | undefined, + pending: RouteMatch | undefined, +): RouteMatch | undefined { + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + return coldPending && active ? active : (pending ?? active); +} + +function selectRouterOutletState( + state: RouterState, +): RouterOutletStateSlice { + return { + status: state.status, + active: state.matches[0], + pending: state.pendingMatches[0], + }; +} + +function equalRouterOutletState( + previous: RouterOutletStateSlice, + next: RouterOutletStateSlice, +): boolean { + return ( + previous.status === next.status && + previous.active === next.active && + previous.pending === next.pending + ); +} + +function idleSnapshot(): RouterOutletSnapshot< + TRouteId, + TModule, + TData +> { + return { + status: "idle", + active: undefined, + pending: undefined, + showPending: false, + }; +} + +/** + * Owns route-presentation timing and effects without depending on a renderer. + * Render adapters provide invalidation and bind the controller to their own + * connection lifecycle. + */ +export class RouterOutletController< + TRouteId extends string = string, + TLoadContext = unknown, + TModule = unknown, + TData = unknown, +> { + private router?: Router; + private onNotFound?: () => void; + private connected = false; + private unsubscribe?: () => void; + private selection: RouterOutletStateSlice = idleSnapshot(); + private snapshotValue: RouterOutletSnapshot = idleSnapshot(); + private pendingMatchId?: string; + private pendingTimer?: ReturnType; + private showPending = false; + private notFoundActive = false; + private notFoundQueued = false; + private notFoundGeneration = 0; + private readonly pendingDelayMs: number; + + constructor( + private readonly invalidate: () => void, + options: RouterOutletControllerOptions = {}, + ) { + this.pendingDelayMs = options.pendingDelayMs ?? DEFAULT_PENDING_DELAY_MS; + } + + get snapshot(): RouterOutletSnapshot { + return this.snapshotValue; + } + + setInputs(inputs: RouterOutletInputs): void { + this.onNotFound = inputs.onNotFound; + if (this.router === inputs.router) { + return; + } + + this.detachSource(); + this.router = inputs.router; + if (this.connected) { + this.attachSource(); + return; + } + const selection = inputs.router + ? selectRouterOutletState(inputs.router.getState()) + : idleSnapshot(); + this.selection = selection; + this.publish({ ...selection, showPending: false }); + } + + connect(): void { + if (this.connected) { + return; + } + this.connected = true; + this.attachSource(false); + // A disconnected host may have retained DOM for an older snapshot. Always + // reconcile once on reconnect, even when the router state stayed stable. + this.invalidate(); + } + + disconnect(): void { + if (!this.connected) { + return; + } + this.connected = false; + this.detachSource(); + } + + private attachSource(notify = true): void { + const router = this.router; + if (!router || this.unsubscribe) { + return; + } + this.applySelection(selectRouterOutletState(router.getState()), notify); + this.unsubscribe = router.subscribeSelector( + selectRouterOutletState, + (selection) => this.applySelection(selection), + equalRouterOutletState, + ); + } + + private detachSource(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.clearPendingTimer(); + this.pendingMatchId = undefined; + this.showPending = false; + this.cancelNotFoundEffect(); + } + + private applySelection( + selection: RouterOutletStateSlice, + notify = true, + ): void { + this.selection = selection; + const pending = selection.pending; + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + const needsPendingFallback = coldPending && !selection.active; + if (!needsPendingFallback) { + this.clearPendingTimer(); + this.pendingMatchId = undefined; + this.showPending = false; + } else if (this.pendingMatchId !== pending.id) { + this.clearPendingTimer(); + this.pendingMatchId = pending.id; + this.showPending = false; + this.schedulePendingFallback(pending.id); + } else if (this.connected && !this.showPending && this.pendingTimer === undefined) { + this.schedulePendingFallback(pending.id); + } + + this.publish({ ...selection, showPending: this.showPending }, notify); + this.updateNotFoundEffect(selection.status); + } + + private schedulePendingFallback(matchId: string): void { + if (!this.connected) { + return; + } + this.pendingTimer = globalThis.setTimeout(() => { + this.pendingTimer = undefined; + const pending = this.selection.pending; + const stillCold = + pending?.id === matchId && + pending.status === "pending" && + pending.module === undefined && + pending.error === undefined && + !this.selection.active; + if (!this.connected || this.pendingMatchId !== matchId || !stillCold) { + return; + } + this.showPending = true; + this.publish({ ...this.selection, showPending: true }); + }, this.pendingDelayMs); + } + + private updateNotFoundEffect(status: RouterOutletStateSlice["status"]): void { + if (status !== "notFound") { + if (this.notFoundActive || this.notFoundQueued) { + this.cancelNotFoundEffect(); + } + return; + } + if (!this.connected || this.notFoundActive) { + return; + } + + this.notFoundActive = true; + this.notFoundQueued = true; + const generation = ++this.notFoundGeneration; + queueMicrotask(() => { + if ( + !this.connected || + generation !== this.notFoundGeneration || + this.selection.status !== "notFound" + ) { + return; + } + this.notFoundQueued = false; + this.onNotFound?.(); + }); + } + + private cancelNotFoundEffect(): void { + this.notFoundGeneration += 1; + this.notFoundActive = false; + this.notFoundQueued = false; + } + + private publish(snapshot: RouterOutletSnapshot, notify = true): void { + const previous = this.snapshotValue; + if ( + previous.status === snapshot.status && + previous.active === snapshot.active && + previous.pending === snapshot.pending && + previous.showPending === snapshot.showPending + ) { + return; + } + this.snapshotValue = snapshot; + if (notify && this.connected) { + this.invalidate(); + } + } + + private clearPendingTimer(): void { + if (this.pendingTimer !== undefined) { + globalThis.clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts new file mode 100644 index 00000000000..dee222ba1a0 --- /dev/null +++ b/ui/src/app/router-outlet.test.ts @@ -0,0 +1,120 @@ +import { createRouter, definePage, type Router } from "@openclaw/uirouter"; +import { html, type LitElement } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import "./router-outlet.ts"; + +type RouteId = "page"; +type TestContext = { label: string }; +type TestData = { label: string }; +type TestModule = { render: (data: TestData | undefined) => unknown }; +type TestRouter = Router; +type RouterOutletElement = LitElement & { + router?: TestRouter; + retryContext?: TestContext; + onNotFound?: () => void; +}; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function createOutlet(router: TestRouter, context: TestContext): RouterOutletElement { + const outlet = document.createElement("openclaw-router-outlet") as RouterOutletElement; + outlet.router = router; + outlet.retryContext = context; + document.body.append(outlet); + return outlet; +} + +async function settleOutlet(outlet: RouterOutletElement): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + await Promise.resolve(); + await outlet.updateComplete; + } +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("openclaw-router-outlet", () => { + it("renders route data through the public custom-element boundary", async () => { + const context = { label: "loaded" }; + const router = createRouter({ + routes: [ + definePage({ + id: "page", + path: "/page", + component: () => ({ + render: (data: TestData | undefined) => + html`
${data?.label}
`, + }), + loader: (loadContext) => ({ label: loadContext.label }), + }), + ], + }); + const outlet = createOutlet(router, context); + + await router.navigate("page", context); + await settleOutlet(outlet); + + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("loaded"); + outlet.remove(); + router.stop(); + }); + + it("keeps a loaded route visible with an error and retries through the latest context", async () => { + const firstLoad = deferred(); + let loadCount = 0; + const router = createRouter({ + routes: [ + definePage({ + id: "page", + path: "/page", + component: () => ({ + render: (data: TestData | undefined) => + html`
${data?.label ?? "pending"}
`, + }), + loader: (context) => { + loadCount += 1; + return loadCount === 1 ? firstLoad.promise : { label: context.label }; + }, + }), + ], + }); + const initialContext = { label: "initial" }; + const retryContext = { label: "retried" }; + const outlet = createOutlet(router, initialContext); + const navigation = router.navigate("page", initialContext); + await settleOutlet(outlet); + firstLoad.reject(new Error("load failed")); + await expect(navigation).rejects.toThrow("load failed"); + await settleOutlet(outlet); + + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("pending"); + expect(outlet.querySelector('[role="alert"]')?.textContent).toContain("load failed"); + + outlet.retryContext = retryContext; + await outlet.updateComplete; + outlet.querySelector("button")?.click(); + await settleOutlet(outlet); + + expect(loadCount).toBe(2); + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("retried"); + expect(outlet.querySelector('[role="alert"]')).toBeNull(); + outlet.remove(); + router.stop(); + }); +}); diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts index 326cccb92ef..0bbc2153367 100644 --- a/ui/src/app/router-outlet.ts +++ b/ui/src/app/router-outlet.ts @@ -1,11 +1,16 @@ -import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; -import { html, LitElement, nothing } from "lit"; -import { AsyncDirective } from "lit/async-directive.js"; +import type { Router } from "@openclaw/uirouter"; +import { html, nothing } from "lit"; +import type { ReactiveController, ReactiveControllerHost } from "lit"; import { property } from "lit/decorators.js"; -import { directive } from "lit/directive.js"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { + RouterOutletController, + selectRenderedRouteMatch, + type RouterOutletSnapshot, +} from "./router-outlet-controller.ts"; -const PENDING_UI_DELAY_MS = 1_000; +export { selectRenderedRouteMatch } from "./router-outlet-controller.ts"; type RenderableModule = { render: (data: TData | undefined) => unknown; @@ -15,48 +20,6 @@ type RouterOutletOptions = { retryContext?: TLoadContext; }; -type RouterOutletBoundaryOptions = { - onNotFound?: () => void; -}; - -type RouterOutletSelection = { - status: RouterState["status"]; - active: RouteMatch | undefined; - pending: RouteMatch | undefined; - showPending: boolean; -}; - -export function selectRenderedRouteMatch( - active: RouteMatch | undefined, - pending: RouteMatch | undefined, -): RouteMatch | undefined { - const coldPending = - pending?.status === "pending" && pending.module === undefined && pending.error === undefined; - return coldPending && active ? active : (pending ?? active); -} - -function selectRouterOutletState( - state: RouterState, -): RouterOutletSelection { - return { - status: state.status, - active: state.matches[0], - pending: state.pendingMatches[0], - showPending: false, - }; -} - -function equalRouterOutletState( - previous: RouterOutletSelection, - next: RouterOutletSelection, -): boolean { - return ( - previous.status === next.status && - previous.active === next.active && - previous.pending === next.pending - ); -} - function isRenderableModule(module: unknown): module is RenderableModule { return ( typeof module === "object" && @@ -113,7 +76,7 @@ function renderError( function renderRouterOutlet( router: Router, - selection: RouterOutletSelection, + selection: RouterOutletSnapshot, options: RouterOutletOptions = {}, ): unknown { const pending = selection.pending; @@ -165,126 +128,43 @@ function renderRouterOutlet; - private retryContext: unknown; - private unsubscribe?: () => void; - private boundaryOptions?: RouterOutletBoundaryOptions; - private notFoundScheduled = false; - private pendingMatchId?: string; - private pendingTimer?: ReturnType; - private pendingSelection?: RouterOutletSelection; - private showPending = false; +type RouterOutletInputs = { + router?: Router; + onNotFound?: () => void; +}; - override render( - router: unknown, - retryContext: unknown, - boundaryOptions: RouterOutletBoundaryOptions, +class LitRouterOutletController< + TRouteId extends string, + TLoadContext, + TModule, + TData, +> implements ReactiveController { + private readonly controller: RouterOutletController; + + constructor( + host: ReactiveControllerHost, + private readonly inputs: () => RouterOutletInputs, ) { - const nextRouter = router as Router; - this.updateSubscription(nextRouter); - this.router = nextRouter; - this.retryContext = retryContext; - this.boundaryOptions = boundaryOptions; - return this.renderSelection(selectRouterOutletState(nextRouter.getState())); + this.controller = new RouterOutletController(() => host.requestUpdate()); + host.addController(this); } - override disconnected() { - this.unsubscribe?.(); - this.unsubscribe = undefined; - this.clearPendingTimer(); - this.pendingSelection = undefined; - this.boundaryOptions = undefined; - this.retryContext = undefined; - this.notFoundScheduled = false; + get snapshot(): RouterOutletSnapshot { + return this.controller.snapshot; } - override reconnected() { - if (this.router) { - this.updateSubscription(this.router); - } + hostConnected(): void { + this.controller.setInputs(this.inputs()); + this.controller.connect(); } - private updateSubscription(router: Router) { - if (this.router === router && this.unsubscribe) { - return; - } - this.unsubscribe?.(); - this.unsubscribe = router.subscribeSelector( - selectRouterOutletState, - (selection) => { - if (this.isConnected) { - this.setValue(this.renderSelection(selection)); - } - }, - equalRouterOutletState, - ); + hostUpdate(): void { + this.controller.setInputs(this.inputs()); } - private renderSelection(selection: RouterOutletSelection) { - this.pendingSelection = selection; - const pending = selection.pending; - const coldPending = - pending?.status === "pending" && pending.module === undefined && pending.error === undefined; - const needsPendingFallback = coldPending && !selection.active; - if (!needsPendingFallback) { - this.clearPendingTimer(); - this.pendingMatchId = undefined; - this.showPending = false; - } else if (this.pendingMatchId !== pending.id) { - this.clearPendingTimer(); - this.pendingMatchId = pending.id; - this.showPending = false; - this.pendingTimer = globalThis.setTimeout(() => { - this.pendingTimer = undefined; - const pendingSelection = this.pendingSelection; - if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) { - return; - } - this.showPending = true; - this.setValue(this.renderSelection(pendingSelection)); - }, PENDING_UI_DELAY_MS); - } - if (selection.status === "notFound") { - if (!this.notFoundScheduled) { - this.notFoundScheduled = true; - queueMicrotask(() => { - this.notFoundScheduled = false; - this.boundaryOptions?.onNotFound?.(); - }); - } - } else { - this.notFoundScheduled = false; - } - const router = this.router; - if (!router) { - return nothing; - } - return renderRouterOutlet( - router, - { ...selection, showPending: this.showPending }, - { - retryContext: this.retryContext, - }, - ); + hostDisconnected(): void { + this.controller.disconnect(); } - - private clearPendingTimer() { - if (this.pendingTimer !== undefined) { - globalThis.clearTimeout(this.pendingTimer); - this.pendingTimer = undefined; - } - } -} - -const routerOutletDirective = directive(RouterOutletDirective); - -function routerOutlet( - router: Router, - boundaryOptions: RouterOutletBoundaryOptions, - options: RouterOutletOptions = {}, -): unknown { - return routerOutletDirective(router, options.retryContext, boundaryOptions); } class OpenClawRouterOutlet< @@ -292,26 +172,22 @@ class OpenClawRouterOutlet< TLoadContext = unknown, TModule = unknown, TData = unknown, -> extends LitElement { +> extends OpenClawLightDomElement { @property({ attribute: false }) router?: Router; @property({ attribute: false }) retryContext?: TLoadContext; @property({ attribute: false }) onNotFound?: () => void; - - override createRenderRoot() { - return this; - } + private readonly outlet = new LitRouterOutletController(this, () => ({ + router: this.router, + onNotFound: this.onNotFound, + })); override render() { if (!this.router) { return nothing; } - return routerOutlet( - this.router, - { onNotFound: this.onNotFound }, - { - retryContext: this.retryContext, - }, - ); + return renderRouterOutlet(this.router, this.outlet.snapshot, { + retryContext: this.retryContext, + }); } } diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts new file mode 100644 index 00000000000..2e8bafdb197 --- /dev/null +++ b/ui/src/components/app-sidebar.test.ts @@ -0,0 +1,137 @@ +/* @vitest-environment jsdom */ + +import { ContextProvider } from "@lit/context"; +import { LitElement } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { SessionsListResult } from "../api/types.ts"; +import type { RouteId } from "../app-route-paths.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGateway, +} from "../app/context.ts"; +import type { SessionCapability } from "../lib/sessions/index.ts"; +import "./app-sidebar.ts"; + +const PROVIDER_ELEMENT_NAME = "test-app-sidebar-context-provider"; + +class AppSidebarContextProvider extends LitElement { + private readonly contextProvider = new ContextProvider(this, { + context: applicationContext, + }); + + setContext(context: ApplicationContext) { + this.contextProvider.setValue(context); + } +} + +if (!customElements.get(PROVIDER_ELEMENT_NAME)) { + customElements.define(PROVIDER_ELEMENT_NAME, AppSidebarContextProvider); +} + +type SidebarLifecycleState = HTMLElement & { + sessionRowsByAgent: Record; + sessionCreatedOrder: Map; + updateComplete: Promise; +}; + +function createGateway(client: GatewayBrowserClient): ApplicationGateway { + return { + snapshot: { + client, + connected: true, + reconnecting: false, + hello: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + lastError: null, + lastErrorCode: null, + }, + subscribe: () => () => undefined, + } as unknown as ApplicationGateway; +} + +function createSessions(agentId: string, keys: string[]): SessionCapability { + const result = { + ts: 1, + path: "", + count: keys.length, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: keys.map((key, index) => ({ + key, + kind: "direct" as const, + updatedAt: index + 1, + })), + } satisfies SessionsListResult; + return { + state: { + result, + agentId, + modelOverrides: {}, + loading: false, + error: null, + deletedSessions: [], + }, + subscribe: () => () => undefined, + subscribeCreated: () => () => undefined, + } as unknown as SessionCapability; +} + +function createContext( + gateway: ApplicationGateway, + sessions: SessionCapability, +): ApplicationContext { + return { + gateway, + sessions, + agents: { + state: { agentsList: null }, + subscribe: () => () => undefined, + }, + agentSelection: { + state: { selectedId: "main" }, + set: () => undefined, + subscribe: () => () => undefined, + }, + } as unknown as ApplicationContext; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("AppSidebar session source lifecycle", () => { + it("resets cached rows and creation order when the sessions source changes", async () => { + const client = {} as GatewayBrowserClient; + const gateway = createGateway(client); + const provider = document.createElement(PROVIDER_ELEMENT_NAME) as AppSidebarContextProvider; + const sidebar = document.createElement( + "openclaw-app-sidebar", + ) as unknown as SidebarLifecycleState; + provider.setContext(createContext(gateway, createSessions("first", ["first-a", "first-b"]))); + provider.append(sidebar); + document.body.append(provider); + await sidebar.updateComplete; + + expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["first"]); + expect([...sidebar.sessionCreatedOrder]).toEqual([ + ["first-a", 0], + ["first-b", 1], + ]); + + // The Gateway and its client stay unchanged while the sessions capability is replaced. + provider.setContext(createContext(gateway, createSessions("second", ["second-b", "second-a"]))); + await sidebar.updateComplete; + + expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["second"]); + expect([...sidebar.sessionCreatedOrder]).toEqual([ + ["second-b", 0], + ["second-a", 1], + ]); + }); +}); diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 19f4aa9b732..f401bd84bfd 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -1,5 +1,5 @@ import { consume } from "@lit/context"; -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts"; @@ -56,6 +56,7 @@ import { compareSessionRowsByUpdatedAt, resolveSessionNavigation, searchForSession, + type SessionCapability, } from "../lib/sessions/index.ts"; import { buildAgentMainSessionKey, @@ -69,9 +70,12 @@ import { resolveSessionAgentFilterOptions, } from "../lib/sessions/session-options.ts"; import { normalizeOptionalString } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts"; import { icons, type IconName } from "./icons.ts"; +import { lobsterPetSeed, resolveLobsterPetMode } from "./lobster-pet.ts"; import type { SessionMenuAction } from "./session-menu.ts"; type SidebarRecentSession = { @@ -161,11 +165,7 @@ function shouldHandleNavigationClick(event: MouseEvent): boolean { ); } -class AppSidebar extends LitElement { - override createRenderRoot() { - return this; - } - +class AppSidebar extends OpenClawLightDomElement { @property({ attribute: false }) basePath = ""; @property({ attribute: false }) activeRouteId?: NavigationRouteId; @property({ attribute: false }) activePluginTabId = ""; @@ -187,7 +187,7 @@ class AppSidebar extends LitElement { onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; @property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise; - @consume({ context: applicationContext, subscribe: false }) + @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @state() private customizeMenuPosition: { x: number; y: number } | null = null; @state() private sessionMenu: SidebarSessionMenuState | null = null; @@ -204,27 +204,50 @@ class AppSidebar extends LitElement { @state() private sessionsAgentId: string | null = null; @state() private sessionsLoading = false; - private stopSessionsSubscription: (() => void) | undefined; - private stopSessionCreatedSubscription: (() => void) | undefined; - private stopAgentsSubscription: (() => void) | undefined; - private stopAgentSelectionSubscription: (() => void) | undefined; - private stopGatewaySubscription: (() => void) | undefined; + private readonly subscriptions = new SubscriptionsController(this); private customizeMenuTrigger: HTMLElement | null = null; private sessionMenuTrigger: HTMLElement | null = null; private sessionGroupMenuTrigger: HTMLElement | null = null; private sessionSortMenuTrigger: HTMLElement | null = null; private sessionRowsByAgent: Record = {}; private sessionCreatedOrder = new Map(); + private sessionsSource: SessionCapability | null = null; private gatewayClient: GatewayBrowserClient | null = null; private readonly routePreloadTimers = new Map< EventTarget, ReturnType >(); + constructor() { + super(); + this.subscriptions + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.updateGatewayClient(gateway.snapshot), + ) + .watch( + () => this.context?.sessions, + (sessions, notify) => sessions.subscribe(notify), + (sessions) => this.synchronizeSessions(sessions), + ) + .effect( + () => this.context?.sessions, + (sessions) => sessions.subscribeCreated((key) => this.promoteCreatedSession(key)), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + ) + .watch( + () => this.context?.agentSelection, + (agentSelection, notify) => agentSelection.subscribe(notify), + ); + } + override connectedCallback() { super.connectedCallback(); this.style.display = "contents"; - this.startSubscriptions(); } override disconnectedCallback() { @@ -232,16 +255,6 @@ class AppSidebar extends LitElement { this.closeSessionMenu(); this.closeSessionGroupMenu(); this.closeSessionSortMenu(); - this.stopSessionsSubscription?.(); - this.stopSessionsSubscription = undefined; - this.stopSessionCreatedSubscription?.(); - this.stopSessionCreatedSubscription = undefined; - this.stopAgentsSubscription?.(); - this.stopAgentsSubscription = undefined; - this.stopAgentSelectionSubscription?.(); - this.stopAgentSelectionSubscription = undefined; - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; this.gatewayClient = null; for (const timer of this.routePreloadTimers.values()) { globalThis.clearTimeout(timer); @@ -250,42 +263,6 @@ class AppSidebar extends LitElement { super.disconnectedCallback(); } - private startSubscriptions() { - const context = this.context; - if ( - !context || - this.stopSessionsSubscription || - this.stopSessionCreatedSubscription || - this.stopAgentsSubscription || - this.stopAgentSelectionSubscription || - this.stopGatewaySubscription - ) { - return; - } - this.updateGatewayClient(context.gateway.snapshot); - this.updateSessions(context.sessions.state); - this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => { - this.updateSessions(snapshot); - }); - this.stopSessionCreatedSubscription = context.sessions.subscribeCreated((key) => { - this.promoteCreatedSession(key); - }); - this.stopAgentsSubscription = context.agents.subscribe(() => { - this.requestUpdate(); - }); - this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => { - this.requestUpdate(); - }); - this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { - this.updateGatewayClient(snapshot); - this.requestUpdate(); - }); - } - - override updated() { - this.startSubscriptions(); - } - private readonly updateSessions = (snapshot: { result: SessionsListResult | null; agentId: string | null; @@ -306,6 +283,15 @@ class AppSidebar extends LitElement { } }; + private synchronizeSessions(sessions: SessionCapability) { + if (sessions !== this.sessionsSource) { + this.sessionRowsByAgent = {}; + this.sessionCreatedOrder.clear(); + this.sessionsSource = sessions; + } + this.updateSessions(sessions.state); + } + private updateGatewayClient(snapshot: { client: GatewayBrowserClient | null; connected: boolean; @@ -1724,6 +1710,10 @@ class AppSidebar extends LitElement { ${this.renderSessions()}