Merge remote-tracking branch 'origin/main' into codex/pr-78226-rewrite

This commit is contained in:
Peter Steinberger 2026-07-09 05:36:26 -07:00
commit c779185bd9
No known key found for this signature in database
312 changed files with 18255 additions and 3475 deletions

View file

@ -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}"

View file

@ -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",
};

View file

@ -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",

View file

@ -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(

View file

@ -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)

View file

@ -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(

View file

@ -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<Intent>()
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<Intent>()
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)
}
}

View file

@ -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,
),
)
}
}

View file

@ -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,
)

View file

@ -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.<id>.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

View file

@ -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.

View file

@ -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

View file

@ -868,6 +868,9 @@ Reply -> TTS enabled?
<ParamField path="timeoutMs" type="number">Command timeout in milliseconds. Default `120000`.</ParamField>
<ParamField path="cwd" type="string">Optional command working directory.</ParamField>
<ParamField path="env" type="Record<string, string>">Optional environment overrides for the command.</ParamField>
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.
</Accordion>
<Accordion title="Microsoft (no API key)">

View file

@ -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: [

View file

@ -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;
}

View file

@ -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({

View file

@ -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,

View file

@ -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);

View file

@ -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)) : "";

View file

@ -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);

View file

@ -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<string, unknown>): 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<string, unknown>): 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}` },

View file

@ -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 });

View file

@ -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) => {

View file

@ -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<string, unknown> | 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<Buffer> {
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<Buffer> {
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<Buffer> {
// 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 {

View file

@ -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;

View file

@ -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,

View file

@ -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`;

View file

@ -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;

3
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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));
});

View file

@ -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

View file

@ -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<NpmRegistryServer> {
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,

View file

@ -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 <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main.
--npm-registry <url> 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"]);
}

View file

@ -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 <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main.
--npm-registry <url> 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 <var> 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`);
}

View file

@ -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

View file

@ -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> Target passed to guest 'openclaw update --tag'.
Default: host-served tgz packed from current checkout.
--target-tarball <path> Host-serve this prepared tgz for update and fresh install.
--dependency-tarball <path> Companion package tgz required by the target. Repeatable.
--fresh-target <npm-spec> 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<string, string>;
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] ?? "<missing>"}, 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}`,

View file

@ -24,6 +24,7 @@ export interface SmokeRunOptions {
json: boolean;
keepServer: boolean;
mode: Mode;
npmRegistry?: string;
provider: Provider;
snapshotHint: string;
targetPackageSpec?: string;

View file

@ -45,3 +45,14 @@ export interface HostServer {
urlFor(filePath: string): string;
stop(): Promise<void>;
}
export interface NpmRegistryPackage {
name: string;
version: string;
tarballPath: string;
}
export interface NpmRegistryServer {
url: string;
stop(): Promise<void>;
}

View file

@ -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 <npm-spec>
Install this npm package tarball instead of packing current main.
--npm-registry <url> 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

View file

@ -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.")

View file

@ -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(

View file

@ -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,

View file

@ -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,
}),
);

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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),
};

View file

@ -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;

View file

@ -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", {

View file

@ -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 };

View file

@ -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);
});
});
});

View file

@ -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<object> = 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 };
}

View file

@ -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);
});
});

View file

@ -42,6 +42,7 @@ import {
describeFailoverError,
isFailoverError,
isNonProviderRuntimeCoordinationError,
resolveModelFallbackError,
} from "./failover-error.js";
import {
shouldAllowCooldownProbeForReason,
@ -412,7 +413,13 @@ async function runFallbackCandidate<T>(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<T>(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,
};
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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", () => {

View file

@ -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" }],

View file

@ -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 {

View file

@ -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. */

View file

@ -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,

View file

@ -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,

View file

@ -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", () => {

View file

@ -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();
});

View file

@ -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 }
: {}),

View file

@ -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,
},
);
});

View file

@ -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,

View file

@ -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 () => {

View file

@ -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,

View file

@ -1557,6 +1557,7 @@ export async function runPreparedReply(
inputProvenance,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
sourceReplyDeliveryMode,
taskSuggestionDeliveryMode: opts?.taskSuggestionDeliveryMode,
silentReplyPromptMode,
extraSystemPromptStatic,
cliSessionBindingFacts,

View file

@ -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<void>();
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[] = [];

View file

@ -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,

View file

@ -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;

View file

@ -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({

View file

@ -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<ReplyBackendHandle, "sourceReplyDeliveryMode" | "taskSuggestionDeliveryMode">,
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);

View file

@ -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,

View file

@ -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: [

View file

@ -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. */

View file

@ -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;

View file

@ -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"),
),

View file

@ -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"

View file

@ -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}");

View file

@ -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,
});

View file

@ -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('<file name="emoji-boundary.txt">');
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();

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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({

View file

@ -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,

View file

@ -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<T>(params: {

View file

@ -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 },
);
});

View file

@ -45,7 +45,11 @@ type RealtimeVoiceAgentControlDeps = {
queueEmbeddedAgentMessageWithOutcomeAsync: (
sessionId: string,
text: string,
options?: { steeringMode?: "all"; debounceMs?: number },
options?: {
steeringMode?: "all";
debounceMs?: number;
taskSuggestionDeliveryMode?: undefined;
},
) => Promise<EmbeddedAgentQueueMessageOutcome>;
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 {

View file

@ -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();
});
});

View file

@ -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<TaskSuggestionsListResult>("taskSuggestions.list", {});
return result.suggestions;
}
async acceptTaskSuggestion(taskId: string) {
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
taskId,
});
}
async dismissTaskSuggestion(taskId: string) {
return await this.client.request<{ taskId: string; dismissed: boolean }>(
"taskSuggestions.dismiss",
{ taskId },
);
}
}
export async function resolveGatewayConnection(

View file

@ -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<CommandEntry[]>;
listPluginApprovals?: () => Promise<unknown>;
resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>;
getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities;
listTaskSuggestions?: () => Promise<TaskSuggestion[]>;
acceptTaskSuggestion?: (taskId: string) => Promise<TaskSuggestionsAcceptResult>;
dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>;
runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>;
};

View file

@ -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 () => {

View file

@ -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<typeof vi.fn<(index: number) => void>>;
};
function suggestionPayload(overrides: Record<string, unknown> = {}) {
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<unknown[]>();
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();
});
});

View file

@ -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> | 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<string, unknown> {
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<string, TaskSuggestion>();
const hiddenIds = new Set<string>();
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<void> | 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<void> => {
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();
},
};
}

View file

@ -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<TuiResult> {
set currentAgentId(value) {
currentAgentId = value;
pluginApprovals?.sessionChanged();
taskSuggestions?.sessionChanged();
},
get currentSessionKey() {
return currentSessionKey;
@ -620,6 +622,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
set currentSessionKey(value) {
currentSessionKey = value;
pluginApprovals?.sessionChanged();
taskSuggestions?.sessionChanged();
},
get currentSessionId() {
return currentSessionId;
@ -1320,6 +1323,16 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
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<TuiResult> {
...(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<TuiResult> {
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<TuiResult> {
} 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<TuiResult> {
} 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<TuiResult> {
await new Promise<void>((resolve) => {
const finish = () => {
pluginApprovals?.dispose();
taskSuggestions?.dispose();
if (isLocalMode) {
setConsoleSubsystemFilter(previousConsoleSubsystemFilter);
}

Some files were not shown because too many files have changed in this diff Show more